Skip to content

Commit

Permalink
FIN-353 Refactor budget handling and improve local testing infrastruc…
Browse files Browse the repository at this point in the history
…ture

Refactored budget-related code to improve readability, maintainability and testing. The new patch endpoints allow creating or updating the budget for the current month.
  • Loading branch information
gjong committed Mar 5, 2024
1 parent c9aa182 commit 193d3a2
Show file tree
Hide file tree
Showing 7 changed files with 355 additions and 145 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ public String toString() {

@BusinessMethod
public Budget indexBudget(LocalDate perDate, double expectedIncome) {
if (!Objects.equals(this.expectedIncome, expectedIncome)) {
if (!Objects.equals(this.start, perDate)) {
this.close(perDate);

var deviation = BigDecimal.ONE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import jakarta.validation.constraints.Min;
import lombok.Builder;

import java.time.LocalDate;

@Builder
@Serdeable.Deserializable
class BudgetCreateRequest {
Expand All @@ -19,10 +21,16 @@ class BudgetCreateRequest {
@Min(0)
private double income;

public LocalDate getStart() {
return LocalDate.of(year, month, 1);
}

@Deprecated
public int getYear() {
return year;
}

@Deprecated
public int getMonth() {
return month;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,70 +8,76 @@
import com.jongsoft.finance.providers.BudgetProvider;
import com.jongsoft.finance.providers.ExpenseProvider;
import com.jongsoft.finance.providers.TransactionProvider;
import com.jongsoft.finance.rest.ApiDefaults;
import com.jongsoft.finance.rest.model.BudgetResponse;
import com.jongsoft.finance.rest.model.ExpenseResponse;
import com.jongsoft.finance.security.CurrentUserProvider;
import com.jongsoft.lang.Collections;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.*;
import io.micronaut.http.hateoas.JsonError;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;

@Slf4j
@Tag(name = "Budget")
@Controller("/api/budgets")
@Secured(SecurityRule.IS_AUTHENTICATED)
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class BudgetResource {

private final CurrentUserProvider currentUserProvider;
private final BudgetProvider budgetProvider;
private final ExpenseProvider expenseProvider;
private final FilterFactory filterFactory;

private final CurrentUserProvider currentUserProvider;
private final TransactionProvider transactionProvider;

@Get
@Get("/current")
@Operation(
summary = "First budget start",
description = "Computes the date of the start of the first budget registered in FinTrack"
summary = "Current month",
description = "Get the budget for the current month."
)
LocalDate firstBudget() {
return budgetProvider.first()
.map(Budget::getStart)
.getOrThrow(() -> StatusException.notFound("No budget found"));
@ApiDefaults
BudgetResponse currentMonth() {
return budgetProvider.lookup(LocalDate.now().getYear(), LocalDate.now().getMonthValue())
.map(BudgetResponse::new)
.getOrThrow(() -> StatusException.notFound("Budget not found for current month."));
}

@Get("/{year}/{month}")
@Operation(
summary = "Get budget",
description = "Lookup the active budget during the provided year and month",
parameters = {
@Parameter(name = "year", in = ParameterIn.PATH, schema = @Schema(implementation = Integer.class, description = "The year")),
@Parameter(name = "month", in = ParameterIn.PATH, schema = @Schema(implementation = Integer.class, description = "The month"))
}
summary = "Get any month",
description = "Get the budget for the given year and month combination."
)
BudgetResponse budget(@PathVariable int year, @PathVariable int month) {
@ApiDefaults
BudgetResponse givenMonth(@PathVariable int year, @PathVariable int month) {
return budgetProvider.lookup(year, month)
.map(BudgetResponse::new)
.getOrThrow(() -> StatusException.notFound("No budget found"));
.getOrThrow(() -> StatusException.notFound("Budget not found for month."));
}

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

@Put
@Get
@Operation(
summary = "Create budget",
description = "Create a new budget in the system with the provided start date"
summary = "First budget start",
description = "Computes the date of the start of the first budget registered in FinTrack"
)
BudgetResponse create(@Valid @Body BudgetCreateRequest budgetCreateRequest) {
LocalDate startDate = LocalDate.of(budgetCreateRequest.getYear(), budgetCreateRequest.getMonth(), 1);
LocalDate firstBudget() {
return budgetProvider.first()
.map(Budget::getStart)
.getOrThrow(() -> StatusException.notFound("No budget found"));
}

var budget = currentUserProvider.currentUser()
.createBudget(startDate, budgetCreateRequest.getIncome());
return new BudgetResponse(budget);
@Put
@Operation(
summary = "Create initial budget",
description = "Create a new budget in the system."
)
@ApiResponse(
responseCode = "400",
content = @Content(schema = @Schema(implementation = JsonError.class)),
description = "There is already an open budget."
)
@Status(HttpStatus.CREATED)
void create(@Valid @Body BudgetCreateRequest createRequest) {
var startDate = createRequest.getStart();
var existing = budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue());
if (existing.isPresent()) {
throw StatusException.badRequest("Cannot start a new budget, there is already a budget open.");
}

currentUserProvider.currentUser()
.createBudget(startDate, createRequest.getIncome());
}

@Post
@Patch
@Operation(
summary = "Index budget",
description = "Indexing a budget will change it expenses and expected income by a percentage"
summary = "Patch budget.",
description = "Update an existing budget that is not yet closed in the system."
)
BudgetResponse index(@Valid @Body BudgetCreateRequest budgetUpdateRequest) {
var startDate = LocalDate.of(budgetUpdateRequest.getYear(), budgetUpdateRequest.getMonth(), 1);
BudgetResponse patchBudget(@Valid @Body BudgetCreateRequest patchRequest) {
var startDate = patchRequest.getStart();

return budgetProvider.lookup(budgetUpdateRequest.getYear(), budgetUpdateRequest.getMonth())
.map(budget -> budget.indexBudget(startDate, budgetUpdateRequest.getIncome()))
var budget = budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue())
.getOrThrow(() -> StatusException.notFound("No budget is active yet, create a budget first."));

budget.indexBudget(startDate, patchRequest.getIncome());
return budgetProvider.lookup(startDate.getYear(), startDate.getMonthValue())
.map(BudgetResponse::new)
.getOrThrow(() -> StatusException.notFound("No budget found"));
.getOrThrow(() -> StatusException.internalError("Could not get budget after updating the period."));
}

@Put("/expenses")
@Patch("/expenses")
@Operation(
summary = "Create expense",
description = "Add a new expense to all existing budgets"
summary = "Patch Expenses",
description = "Create or update an expense in the currents month budget."
)
BudgetResponse createExpense(@Valid @Body ExpenseCreateRequest createRequest) {
var now = LocalDate.now();

return budgetProvider.lookup(now.getYear(), now.getMonthValue())
.map(budget -> {
budget.createExpense(createRequest.getName(), createRequest.getLowerBound(), createRequest.getUpperBound());
return budgetProvider.lookup(now.getYear(), now.getMonthValue())
.map(BudgetResponse::new)
.getOrThrow(() -> StatusException.notFound("No budget found"));
})
.getOrThrow(() -> StatusException.notFound("No budget found"));
BudgetResponse patchExpenses(@Valid @Body ExpensePatchRequest patchRequest) {
var currentDate = LocalDate.now().withDayOfMonth(1);

var budget = budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
.getOrThrow(() -> StatusException.notFound("Cannot update expenses, no budget available yet."));

if (patchRequest.expenseId() != null) {
log.debug("Updating expense {} within active budget.", patchRequest.expenseId());

if (budget.getStart().isBefore(currentDate)) {
log.info("Starting new budget period as the current period {} is after the existing start of {}", currentDate, budget.getStart());
budget.indexBudget(currentDate, budget.getExpectedIncome());
budget = budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
.getOrThrow(() -> StatusException.internalError("Updating of budget failed."));
}

var toUpdate = budget.getExpenses()
.first(expense -> Objects.equals(expense.getId(), patchRequest.expenseId()))
.getOrThrow(() -> StatusException.badRequest("Attempted to update a non existing expense."));

toUpdate.updateExpense(patchRequest.amount());
} else {
budget.createExpense(patchRequest.name(), patchRequest.amount() - 0.01, patchRequest.amount());
}

return budgetProvider.lookup(currentDate.getYear(), currentDate.getMonthValue())
.map(BudgetResponse::new)
.getOrThrow(() -> StatusException.internalError("Error whilst fetching updated budget."));
}

@Get("/expenses/{id}/{year}/{month}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jongsoft.finance.rest.budget;

import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.Min;

@Serdeable
public record ExpensePatchRequest(
Long expenseId,
String name,
@Min(0)
double amount) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@
@MicronautTest(environments = {"no-camunda", "test"})
public class TestSetup {

protected final UserAccount ACTIVE_USER = UserAccount.builder()
protected final UserAccount ACTIVE_USER = Mockito.spy(UserAccount.builder()
.id(1L)
.username("test-user")
.password("1234")
.theme("dark")
.primaryCurrency(Currency.getInstance("EUR"))
.secret(Base32.random())
.roles(Collections.List(new Role("admin")))
.build();
.build());

@Inject
protected CurrentUserProvider currentUserProvider;
Expand Down Expand Up @@ -84,7 +84,7 @@ public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider ser
.build();

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

// initialize the event bus
Expand Down
Loading

0 comments on commit 193d3a2

Please sign in to comment.