diff --git a/api/build.gradle b/api/build.gradle index ca8b55c..a3e5288 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -27,6 +27,10 @@ jar { description = "twigs-server" } +test { + useJUnitPlatform() +} + mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication" sourceCompatibility = 14 diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/Category.java b/api/src/main/java/com/wbrawner/budgetserver/category/Category.java index c41a624..828e2d6 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/category/Category.java +++ b/api/src/main/java/com/wbrawner/budgetserver/category/Category.java @@ -3,6 +3,9 @@ package com.wbrawner.budgetserver.category; import com.wbrawner.budgetserver.budget.Budget; import javax.persistence.*; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Set; @Entity public class Category implements Comparable { @@ -11,26 +14,21 @@ public class Category implements Comparable { private final Long id = null; private String title; private String description; - private long amount; + @JoinColumn(nullable = false) + @OneToMany(mappedBy = "category", fetch = FetchType.EAGER) + private final Set amounts = new HashSet<>(); @JoinColumn(nullable = false) @ManyToOne private Budget budget; private boolean expense; public Category() { - this(null, null, 0L, null, true); + this(null, null, null, true); } - public Category( - String title, - String description, - Long amount, - Budget budget, - boolean expense - ) { + public Category(String title, String description, Budget budget, boolean expense) { this.title = title; this.description = description; - this.amount = amount; this.budget = budget; this.expense = expense; } @@ -60,12 +58,51 @@ public class Category implements Comparable { this.description = description; } - public long getAmount() { - return amount; + /** + * Returns the effective amount for the given year and month. + * + * Category amounts are stored via the {@link CategoryAmount} object, where the month is the first month where + * the given amount is active and the year is the first year the given amount is active. So if you created a + * category in January of 2020 and then queried the amount for April of 2020, the amount would be returned from + * January. If in May the amount is updated, then that amount would be returned for all the months following May + * until the next update. + * @param month the maximum month to search + * @param year the maximum year to search + * @return the amount value from the {@link CategoryAmount} that corresponds to the given month and year, or 0 if + * no current or previous amounts can be found. + */ + public long getAmount(int month, int year) { + return this.amounts.stream() + .filter(categoryAmount -> categoryAmount.getMonth() <= month && categoryAmount.getYear() <= year) + .max(Comparator.comparingInt(CategoryAmount::getYear).thenComparingInt(CategoryAmount::getMonth)) + .orElse(new CategoryAmount(this, 0L, month, year)) + .getAmount(); } - public void setAmount(long amount) { - this.amount = amount; + public long getMostRecentAmount() { + return this.amounts.stream() + .sorted() + .findFirst() + .orElse(new CategoryAmount(this, 0L, 0, 0)) + .getAmount(); + } + + public void setAmount(long amount, int month, int year) { + CategoryAmount categoryAmount = this.amounts.stream() + .filter(ca -> ca.getMonth() == month && ca.getYear() == year) + .findAny() + .orElse(null); + if (categoryAmount == null) { + categoryAmount = new CategoryAmount(); + this.amounts.add(categoryAmount); + } + categoryAmount.setAmount(amount); + categoryAmount.setMonth(month); + categoryAmount.setYear(year); + } + + public void setAmount(CategoryAmountRequest amount) { + setAmount(amount.getAmount(), amount.getMonth(), amount.getYear()); } public Budget getBudget() { diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmount.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmount.java new file mode 100644 index 0000000..1cf9d02 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmount.java @@ -0,0 +1,74 @@ +package com.wbrawner.budgetserver.category; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; +import java.util.Comparator; + +@Entity +public class CategoryAmount implements Comparable { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @ManyToOne + private Category category; + private long amount; + private int month; + private int year; + + public CategoryAmount(Category category, long amount, int month, int year) { + this.category = category; + this.amount = amount; + if (month < 0 || month > 11) throw new IllegalArgumentException("Invalid month"); + this.month = month; + this.year = year; + } + + public CategoryAmount() { + // required empty constructor for Hibernate + this(null, 0L, 0, 0); + } + + public Long getId() { + return id; + } + + public Category getCategory() { + return category; + } + + public long getAmount() { + return amount; + } + + public int getMonth() { + return month; + } + + public int getYear() { + return year; + } + + public void setCategory(Category category) { + this.category = category; + } + + public void setAmount(long amount) { + this.amount = amount; + } + + public void setMonth(int month) { + this.month = month; + } + + public void setYear(int year) { + this.year = year; + } + + @Override + public int compareTo(@NotNull CategoryAmount o) { + int yearComparison = Integer.compare(o.getYear(), getYear()); + if (yearComparison != 0) return yearComparison; + return Integer.compare(o.getMonth(), getMonth()); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmountRequest.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmountRequest.java new file mode 100644 index 0000000..7c4cb14 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryAmountRequest.java @@ -0,0 +1,31 @@ +package com.wbrawner.budgetserver.category; + +import java.util.Calendar; + +public class CategoryAmountRequest { + private final Long amount; + private final Integer month; + private final Integer year; + + public CategoryAmountRequest() { + this(null, null, null); + } + + public CategoryAmountRequest(Long amount, Integer month, Integer year) { + this.amount = amount; + this.month = month; + this.year = year; + } + + public long getAmount() { + return amount != null ? amount : 0L; + } + + public int getMonth() { + return month != null ? month : Calendar.getInstance().get(Calendar.MONTH); + } + + public int getYear() { + return year != null ? year : Calendar.getInstance().get(Calendar.YEAR); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java index 9acc73c..b48f051 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java +++ b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryController.java @@ -41,14 +41,21 @@ class CategoryController { @GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE}) @ApiOperation(value = "getCategories", nickname = "getCategories", tags = {"Categories"}) - ResponseEntity> getCategories( + ResponseEntity getCategories( @RequestParam(name = "budgetIds", required = false) List budgetIds, @RequestParam(name = "isExpense", required = false) Boolean isExpense, + @RequestParam(name = "month", required = false) Integer month, + @RequestParam(name = "year", required = false) Integer year, @RequestParam(name = "count", required = false) Integer count, @RequestParam(name = "page", required = false) Integer page, @RequestParam(name = "false", required = false) String sortBy, @RequestParam(name = "sortOrder", required = false) Sort.Direction sortOrder ) { + if ((month == null && year != null) || (month != null && year == null)) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("month and year must be provided together or not at all.")); + } + List userPermissions; if (budgetIds != null && !budgetIds.isEmpty()) { userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn( @@ -69,6 +76,7 @@ class CategoryController { sortOrder != null ? sortOrder : Sort.Direction.ASC, sortBy != null ? sortBy : "title" ); + List categories; if (isExpense == null) { categories = categoryRepository.findAllByBudgetIn(budgets, pageRequest); @@ -78,7 +86,13 @@ class CategoryController { return ResponseEntity.ok( categories.stream() - .map(CategoryResponse::new) + .map(category -> { + if (month != null) { + return new CategoryResponse(category, month, year); + } else { + return new CategoryResponse(category); + } + }) .collect(Collectors.toList()) ); } @@ -122,13 +136,14 @@ class CategoryController { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } var budget = userResponse.getBudget(); - return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(new Category( + var category = new Category( request.getTitle(), request.getDescription(), - request.getAmount(), budget, request.getExpense() - )))); + ); + category.setAmount(request.getAmount()); + return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(category))); } @PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java index 15839cd..1ca8a34 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java +++ b/api/src/main/java/com/wbrawner/budgetserver/category/CategoryResponse.java @@ -10,12 +10,23 @@ public class CategoryResponse { private final long budgetId; private final boolean expense; + public CategoryResponse(Category category, int month, int year) { + this( + Objects.requireNonNull(category.getId()), + category.getTitle(), + category.getDescription(), + category.getAmount(month, year), + Objects.requireNonNull(category.getBudget()).getId(), + category.isExpense() + ); + } + public CategoryResponse(Category category) { this( Objects.requireNonNull(category.getId()), category.getTitle(), category.getDescription(), - category.getAmount(), + category.getMostRecentAmount(), Objects.requireNonNull(category.getBudget()).getId(), category.isExpense() ); diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java b/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java index 8c9a344..5740ad3 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java +++ b/api/src/main/java/com/wbrawner/budgetserver/category/NewCategoryRequest.java @@ -3,7 +3,7 @@ package com.wbrawner.budgetserver.category; public class NewCategoryRequest { private final String title; private final String description; - private final Long amount; + private final CategoryAmountRequest amount; private final Long budgetId; private final Boolean expense; @@ -11,7 +11,11 @@ public class NewCategoryRequest { this(null, null, null, null, null); } - public NewCategoryRequest(String title, String description, Long amount, Long budgetId, Boolean expense) { + public NewCategoryRequest(String title, + String description, + CategoryAmountRequest amount, + Long budgetId, + Boolean expense) { this.title = title; this.description = description; this.amount = amount; @@ -27,7 +31,7 @@ public class NewCategoryRequest { return description; } - public Long getAmount() { + public CategoryAmountRequest getAmount() { return amount; } diff --git a/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java b/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java index 2fc9530..ffb6c83 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java +++ b/api/src/main/java/com/wbrawner/budgetserver/category/UpdateCategoryRequest.java @@ -3,14 +3,19 @@ package com.wbrawner.budgetserver.category; public class UpdateCategoryRequest { private final String title; private final String description; - private final Long amount; + private final CategoryAmountRequest amount; private final Boolean expense; public UpdateCategoryRequest() { this(null, null, null, null); } - public UpdateCategoryRequest(String title, String description, Long amount, Boolean expense) { + public UpdateCategoryRequest( + String title, + String description, + CategoryAmountRequest amount, + Boolean expense + ) { this.title = title; this.description = description; this.amount = amount; @@ -25,7 +30,7 @@ public class UpdateCategoryRequest { return description; } - public Long getAmount() { + public CategoryAmountRequest getAmount() { return amount; } diff --git a/api/src/test/java/com/wbrawner/budgetserver/category/CategoryAmountTest.java b/api/src/test/java/com/wbrawner/budgetserver/category/CategoryAmountTest.java new file mode 100644 index 0000000..746c437 --- /dev/null +++ b/api/src/test/java/com/wbrawner/budgetserver/category/CategoryAmountTest.java @@ -0,0 +1,28 @@ +package com.wbrawner.budgetserver.category; + + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +public class CategoryAmountTest { + + @Test + public void sortTest() { + var first = new CategoryAmount(null, 0L, 4, 2020); + var second = new CategoryAmount(null, 0L, 2, 2020); + var third = new CategoryAmount(null, 0L, 2, 2019); + var categoryAmounts = Arrays.asList( + third, + first, + second + ); + Collections.sort(categoryAmounts); + assertEquals(first, categoryAmounts.get(0)); + assertEquals(second, categoryAmounts.get(1)); + assertEquals(third, categoryAmounts.get(2)); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/wbrawner/budgetserver/category/CategoryTest.java b/api/src/test/java/com/wbrawner/budgetserver/category/CategoryTest.java new file mode 100644 index 0000000..1faa119 --- /dev/null +++ b/api/src/test/java/com/wbrawner/budgetserver/category/CategoryTest.java @@ -0,0 +1,52 @@ +package com.wbrawner.budgetserver.category; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class CategoryTest { + private final Category category = new Category("Test Category", null, null, false); + + @BeforeEach + public void setup() { + category.setAmount(815L, 8, 2015); + category.setAmount(310L, 3, 2010); + category.setAmount(920L, 9, 2020); + category.setAmount(1117L, 11, 2017); + category.setAmount(217L, 2, 2017); + category.setAmount(412L, 4, 2012); + } + + @ParameterizedTest(name = "{index} amount: {0} month: {1}, year: {2}") + @ArgumentsSource(AmountArgumentsSource.class) + void getAmount(long expected, int month, int year) { + assertEquals(expected, category.getAmount(month, year)); + } + + @Test + void getMostRecentAmount() { + assertEquals(920L, category.getMostRecentAmount()); + } + + public static class AmountArgumentsSource implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + arguments(0L, 1, 2014), + arguments(412L, 5, 2013), + arguments(310L, 3, 2010), + arguments(920L, 10, 2021) + ); + } + } +} \ No newline at end of file