WIP: Allow different amounts per month for categories

This commit is contained in:
William Brawner 2020-06-04 15:10:28 -07:00
parent 7b8939a349
commit 65c25f9db9
10 changed files with 287 additions and 26 deletions

View file

@ -27,6 +27,10 @@ jar {
description = "twigs-server"
}
test {
useJUnitPlatform()
}
mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication"
sourceCompatibility = 14

View file

@ -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<Category> {
@ -11,26 +14,21 @@ public class Category implements Comparable<Category> {
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<CategoryAmount> 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<Category> {
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() {

View file

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

View file

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

View file

@ -41,14 +41,21 @@ class CategoryController {
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
@ApiOperation(value = "getCategories", nickname = "getCategories", tags = {"Categories"})
ResponseEntity<List<CategoryResponse>> getCategories(
ResponseEntity<Object> getCategories(
@RequestParam(name = "budgetIds", required = false) List<Long> 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<UserPermission> 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<Category> 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})

View file

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

View file

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

View file

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

View file

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

View file

@ -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<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(
arguments(0L, 1, 2014),
arguments(412L, 5, 2013),
arguments(310L, 3, 2010),
arguments(920L, 10, 2021)
);
}
}
}