WIP: Allow different amounts per month for categories
This commit is contained in:
parent
7b8939a349
commit
65c25f9db9
10 changed files with 287 additions and 26 deletions
|
@ -27,6 +27,10 @@ jar {
|
|||
description = "twigs-server"
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication"
|
||||
|
||||
sourceCompatibility = 14
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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})
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue