Skip to content

Commit 193d3a2

Browse files
committed
FIN-353 Refactor budget handling and improve local testing infrastructure
Refactored budget-related code to improve readability, maintainability and testing. The new patch endpoints allow creating or updating the budget for the current month.
1 parent c9aa182 commit 193d3a2

File tree

7 files changed

+355
-145
lines changed

7 files changed

+355
-145
lines changed

domain/src/main/java/com/jongsoft/finance/domain/user/Budget.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ public String toString() {
120120

121121
@BusinessMethod
122122
public Budget indexBudget(LocalDate perDate, double expectedIncome) {
123-
if (!Objects.equals(this.expectedIncome, expectedIncome)) {
123+
if (!Objects.equals(this.start, perDate)) {
124124
this.close(perDate);
125125

126126
var deviation = BigDecimal.ONE

fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetCreateRequest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import jakarta.validation.constraints.Min;
66
import lombok.Builder;
77

8+
import java.time.LocalDate;
9+
810
@Builder
911
@Serdeable.Deserializable
1012
class BudgetCreateRequest {
@@ -19,10 +21,16 @@ class BudgetCreateRequest {
1921
@Min(0)
2022
private double income;
2123

24+
public LocalDate getStart() {
25+
return LocalDate.of(year, month, 1);
26+
}
27+
28+
@Deprecated
2229
public int getYear() {
2330
return year;
2431
}
2532

33+
@Deprecated
2634
public int getMonth() {
2735
return month;
2836
}

fintrack-api/src/main/java/com/jongsoft/finance/rest/budget/BudgetResource.java

Lines changed: 93 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,70 +8,76 @@
88
import com.jongsoft.finance.providers.BudgetProvider;
99
import com.jongsoft.finance.providers.ExpenseProvider;
1010
import com.jongsoft.finance.providers.TransactionProvider;
11+
import com.jongsoft.finance.rest.ApiDefaults;
1112
import com.jongsoft.finance.rest.model.BudgetResponse;
1213
import com.jongsoft.finance.rest.model.ExpenseResponse;
1314
import com.jongsoft.finance.security.CurrentUserProvider;
1415
import com.jongsoft.lang.Collections;
1516
import io.micronaut.core.annotation.Nullable;
17+
import io.micronaut.http.HttpStatus;
1618
import io.micronaut.http.annotation.*;
19+
import io.micronaut.http.hateoas.JsonError;
1720
import io.micronaut.security.annotation.Secured;
1821
import io.micronaut.security.rules.SecurityRule;
1922
import io.swagger.v3.oas.annotations.Operation;
2023
import io.swagger.v3.oas.annotations.Parameter;
2124
import io.swagger.v3.oas.annotations.enums.ParameterIn;
25+
import io.swagger.v3.oas.annotations.media.Content;
2226
import io.swagger.v3.oas.annotations.media.Schema;
27+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
2328
import io.swagger.v3.oas.annotations.tags.Tag;
2429
import jakarta.inject.Inject;
2530
import jakarta.validation.Valid;
2631
import lombok.RequiredArgsConstructor;
32+
import lombok.extern.slf4j.Slf4j;
2733

2834
import java.math.BigDecimal;
2935
import java.time.LocalDate;
3036
import java.util.List;
37+
import java.util.Objects;
3138

39+
@Slf4j
3240
@Tag(name = "Budget")
3341
@Controller("/api/budgets")
3442
@Secured(SecurityRule.IS_AUTHENTICATED)
3543
@RequiredArgsConstructor(onConstructor_ = @Inject)
3644
public class BudgetResource {
3745

38-
private final CurrentUserProvider currentUserProvider;
3946
private final BudgetProvider budgetProvider;
4047
private final ExpenseProvider expenseProvider;
4148
private final FilterFactory filterFactory;
4249

50+
private final CurrentUserProvider currentUserProvider;
4351
private final TransactionProvider transactionProvider;
4452

45-
@Get
53+
@Get("/current")
4654
@Operation(
47-
summary = "First budget start",
48-
description = "Computes the date of the start of the first budget registered in FinTrack"
55+
summary = "Current month",
56+
description = "Get the budget for the current month."
4957
)
50-
LocalDate firstBudget() {
51-
return budgetProvider.first()
52-
.map(Budget::getStart)
53-
.getOrThrow(() -> StatusException.notFound("No budget found"));
58+
@ApiDefaults
59+
BudgetResponse currentMonth() {
60+
return budgetProvider.lookup(LocalDate.now().getYear(), LocalDate.now().getMonthValue())
61+
.map(BudgetResponse::new)
62+
.getOrThrow(() -> StatusException.notFound("Budget not found for current month."));
5463
}
5564

5665
@Get("/{year}/{month}")
5766
@Operation(
58-
summary = "Get budget",
59-
description = "Lookup the active budget during the provided year and month",
60-
parameters = {
61-
@Parameter(name = "year", in = ParameterIn.PATH, schema = @Schema(implementation = Integer.class, description = "The year")),
62-
@Parameter(name = "month", in = ParameterIn.PATH, schema = @Schema(implementation = Integer.class, description = "The month"))
63-
}
67+
summary = "Get any month",
68+
description = "Get the budget for the given year and month combination."
6469
)
65-
BudgetResponse budget(@PathVariable int year, @PathVariable int month) {
70+
@ApiDefaults
71+
BudgetResponse givenMonth(@PathVariable int year, @PathVariable int month) {
6672
return budgetProvider.lookup(year, month)
6773
.map(BudgetResponse::new)
68-
.getOrThrow(() -> StatusException.notFound("No budget found"));
74+
.getOrThrow(() -> StatusException.notFound("Budget not found for month."));
6975
}
7076

7177
@Get("/auto-complete{?token}")
7278
@Operation(
7379
summary = "Lookup expense",
74-
description = "Search in FinTrack for expenses that match the provided token",
80+
description = "Search for expenses that match the provided token",
7581
parameters = @Parameter(name = "token", in = ParameterIn.QUERY, schema = @Schema(implementation = String.class))
7682
)
7783
List<ExpenseResponse> autocomplete(@Nullable String token) {
@@ -81,49 +87,89 @@ List<ExpenseResponse> autocomplete(@Nullable String token) {
8187
.toJava();
8288
}
8389

84-
@Put
90+
@Get
8591
@Operation(
86-
summary = "Create budget",
87-
description = "Create a new budget in the system with the provided start date"
92+
summary = "First budget start",
93+
description = "Computes the date of the start of the first budget registered in FinTrack"
8894
)
89-
BudgetResponse create(@Valid @Body BudgetCreateRequest budgetCreateRequest) {
90-
LocalDate startDate = LocalDate.of(budgetCreateRequest.getYear(), budgetCreateRequest.getMonth(), 1);
95+
LocalDate firstBudget() {
96+
return budgetProvider.first()
97+
.map(Budget::getStart)
98+
.getOrThrow(() -> StatusException.notFound("No budget found"));
99+
}
91100

92-
var budget = currentUserProvider.currentUser()
93-
.createBudget(startDate, budgetCreateRequest.getIncome());
94-
return new BudgetResponse(budget);
101+
@Put
102+
@Operation(
103+
summary = "Create initial budget",
104+
description = "Create a new budget in the system."
105+
)
106+
@ApiResponse(
107+
responseCode = "400",
108+
content = @Content(schema = @Schema(implementation = JsonError.class)),
109+
description = "There is already an open budget."
110+
)
111+
@Status(HttpStatus.CREATED)
112+
void create(@Valid @Body BudgetCreateRequest createRequest) {
113+
var startDate = createRequest.getStart();
114+
var existing = budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue());
115+
if (existing.isPresent()) {
116+
throw StatusException.badRequest("Cannot start a new budget, there is already a budget open.");
117+
}
118+
119+
currentUserProvider.currentUser()
120+
.createBudget(startDate, createRequest.getIncome());
95121
}
96122

97-
@Post
123+
@Patch
98124
@Operation(
99-
summary = "Index budget",
100-
description = "Indexing a budget will change it expenses and expected income by a percentage"
125+
summary = "Patch budget.",
126+
description = "Update an existing budget that is not yet closed in the system."
101127
)
102-
BudgetResponse index(@Valid @Body BudgetCreateRequest budgetUpdateRequest) {
103-
var startDate = LocalDate.of(budgetUpdateRequest.getYear(), budgetUpdateRequest.getMonth(), 1);
128+
BudgetResponse patchBudget(@Valid @Body BudgetCreateRequest patchRequest) {
129+
var startDate = patchRequest.getStart();
104130

105-
return budgetProvider.lookup(budgetUpdateRequest.getYear(), budgetUpdateRequest.getMonth())
106-
.map(budget -> budget.indexBudget(startDate, budgetUpdateRequest.getIncome()))
131+
var budget = budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue())
132+
.getOrThrow(() -> StatusException.notFound("No budget is active yet, create a budget first."));
133+
134+
budget.indexBudget(startDate, patchRequest.getIncome());
135+
return budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue())
107136
.map(BudgetResponse::new)
108-
.getOrThrow(() -> StatusException.notFound("No budget found"));
137+
.getOrThrow(() -> StatusException.internalError("Could not get budget after updating the period."));
109138
}
110139

111-
@Put("/expenses")
140+
@Patch("/expenses")
112141
@Operation(
113-
summary = "Create expense",
114-
description = "Add a new expense to all existing budgets"
142+
summary = "Patch Expenses",
143+
description = "Create or update an expense in the currents month budget."
115144
)
116-
BudgetResponse createExpense(@Valid @Body ExpenseCreateRequest createRequest) {
117-
var now = LocalDate.now();
118-
119-
return budgetProvider.lookup(now.getYear(), now.getMonthValue())
120-
.map(budget -> {
121-
budget.createExpense(createRequest.getName(), createRequest.getLowerBound(), createRequest.getUpperBound());
122-
return budgetProvider.lookup(now.getYear(), now.getMonthValue())
123-
.map(BudgetResponse::new)
124-
.getOrThrow(() -> StatusException.notFound("No budget found"));
125-
})
126-
.getOrThrow(() -> StatusException.notFound("No budget found"));
145+
BudgetResponse patchExpenses(@Valid @Body ExpensePatchRequest patchRequest) {
146+
var currentDate = LocalDate.now().withDayOfMonth(1);
147+
148+
var budget = budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
149+
.getOrThrow(() -> StatusException.notFound("Cannot update expenses, no budget available yet."));
150+
151+
if (patchRequest.expenseId() != null) {
152+
log.debug("Updating expense {} within active budget.", patchRequest.expenseId());
153+
154+
if (budget.getStart().isBefore(currentDate)) {
155+
log.info("Starting new budget period as the current period {} is after the existing start of {}", currentDate, budget.getStart());
156+
budget.indexBudget(currentDate, budget.getExpectedIncome());
157+
budget = budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
158+
.getOrThrow(() -> StatusException.internalError("Updating of budget failed."));
159+
}
160+
161+
var toUpdate = budget.getExpenses()
162+
.first(expense -> Objects.equals(expense.getId(), patchRequest.expenseId()))
163+
.getOrThrow(() -> StatusException.badRequest("Attempted to update a non existing expense."));
164+
165+
toUpdate.updateExpense(patchRequest.amount());
166+
} else {
167+
budget.createExpense(patchRequest.name(), patchRequest.amount() - 0.01, patchRequest.amount());
168+
}
169+
170+
return budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
171+
.map(BudgetResponse::new)
172+
.getOrThrow(() -> StatusException.internalError("Error whilst fetching updated budget."));
127173
}
128174

129175
@Get("/expenses/{id}/{year}/{month}")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.jongsoft.finance.rest.budget;
2+
3+
import io.micronaut.serde.annotation.Serdeable;
4+
import jakarta.validation.constraints.Min;
5+
6+
@Serdeable
7+
public record ExpensePatchRequest(
8+
Long expenseId,
9+
String name,
10+
@Min(0)
11+
double amount) {
12+
}

fintrack-api/src/test/java/com/jongsoft/finance/rest/TestSetup.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@
3636
@MicronautTest(environments = {"no-camunda", "test"})
3737
public class TestSetup {
3838

39-
protected final UserAccount ACTIVE_USER = UserAccount.builder()
39+
protected final UserAccount ACTIVE_USER = Mockito.spy(UserAccount.builder()
4040
.id(1L)
4141
.username("test-user")
4242
.password("1234")
4343
.theme("dark")
4444
.primaryCurrency(Currency.getInstance("EUR"))
4545
.secret(Base32.random())
4646
.roles(Collections.List(new Role("admin")))
47-
.build();
47+
.build());
4848

4949
@Inject
5050
protected CurrentUserProvider currentUserProvider;
@@ -84,7 +84,7 @@ public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider ser
8484
.build();
8585

8686
Mockito.when(currentUserProvider.currentUser()).thenReturn(ACTIVE_USER);
87-
Mockito.when(authenticationFacade.authenticated()).thenReturn(ACTIVE_USER.getUsername());
87+
Mockito.when(authenticationFacade.authenticated()).thenReturn("test-user");
8888
Mockito.when(userProvider.lookup(ACTIVE_USER.getUsername())).thenReturn(Control.Option(ACTIVE_USER));
8989

9090
// initialize the event bus

0 commit comments

Comments
 (0)