Skip to content

Commit

Permalink
Add modification (add/remove) expense categories to plan jar
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Apr 20, 2024
1 parent a1b1750 commit bfe2a18
Show file tree
Hide file tree
Showing 18 changed files with 633 additions and 35 deletions.
2 changes: 1 addition & 1 deletion server/src/main/java/io/myfinbox/income/domain/Income.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
@Table(name = "incomes")
@Getter
@ToString
@EqualsAndHashCode
@EqualsAndHashCode(callSuper = false)
@NoArgsConstructor(access = PRIVATE, force = true) // JPA compliant
public class Income extends AbstractAggregateRoot<Income> {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreateJarResource;
import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.shared.ApiFailureHandler;
import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase;
import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove;
import io.myfinbox.spendingplan.application.CreateJarUseCase;
import io.myfinbox.spendingplan.application.JarCommand;
import io.myfinbox.spendingplan.domain.Jar;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Objects;
import java.util.UUID;

import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.ResponseEntity.created;
import static org.springframework.http.ResponseEntity.ok;
import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest;

@RestController
Expand All @@ -21,6 +27,7 @@
final class JarController implements JarsApi {

private final CreateJarUseCase createJarUseCase;
private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(path = "/{planId}/jars", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
Expand All @@ -30,6 +37,14 @@ public ResponseEntity<?> create(@PathVariable UUID planId, @RequestBody CreateJa
.body(toResource(jar)));
}

@PutMapping(path = "/{planId}/jars/{jarId}/expense-categories", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> modifyExpenseCategories(@PathVariable UUID planId,
@PathVariable UUID jarId,
@RequestBody JarCategoryModificationResource resource) {
return addOrRemoveJarCategoryUseCase.addOrRemove(planId, jarId, toCommand(resource))
.fold(apiFailureHandler::handle, ok -> ok().build());
}

private JarCommand toCommand(CreateJarResource resource) {
return JarCommand.builder()
.name(resource.getName())
Expand All @@ -48,4 +63,13 @@ private CreateJarResource toResource(Jar jar) {
.percentage(jar.getPercentage().value())
.description(jar.getDescription());
}

private JarCategoriesCommand toCommand(JarCategoryModificationResource resource) {
var categoryToAdds = resource.getCategories()
.stream()
.filter(Objects::nonNull) // avoid null categoryToAdd
.map(categoryToAdd -> new JarCategoryToAddOrRemove(categoryToAdd.getCategoryId(), categoryToAdd.getToAdd()))
.toList();
return new JarCategoriesCommand(categoryToAdds);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreateJarResource;
import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.shared.ApiErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -45,4 +46,29 @@ public interface JarsApi {
})
ResponseEntity<?> create(@Parameter(in = PATH, description = "Spending plan ID to be added new jar", required = true) UUID planId,
@RequestBody(description = "Jar Resource to be created", required = true) CreateJarResource resource);

@Operation(
summary = "Modify Expense Categories for Spending Plan Jar",
description = "Add or remove provided categories to a spending plan jar in MyFinBox. This endpoint modifies the categories based on the 'toAdd' flag.",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG}
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Success: Categories modified successfully",
content = @Content),
@ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Spending plan or jar not found",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "422",
description = "Request schema validation failure. At least one category must be provided, null categories not allowed, or duplicate categories provided",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> modifyExpenseCategories(
@Parameter(in = PATH, description = "ID of the spending plan containing the jar to modify categories", required = true) UUID planId,
@Parameter(in = PATH, description = "ID of the spending jar to modify categories", required = true) UUID jarId,
@RequestBody(description = "Resource containing categories to add or remove", required = true) JarCategoryModificationResource resource);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.domain.*;
import io.vavr.control.Either;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

import static io.myfinbox.spendingplan.domain.JarExpenseCategory.CategoryIdentifier;
import static io.myfinbox.spendingplan.domain.Plan.PlanIdentifier;
import static java.util.Objects.isNull;

@Service
@Transactional
@RequiredArgsConstructor
class AddOrRemoveJarCategoryService implements AddOrRemoveJarCategoryUseCase {

static final String VALIDATION_CREATE_FAILURE_MESSAGE = "Failed to validate the request to add or remove categories to plan jar.";
static final String PLAN_NOT_FOUND_MESSAGE = "Spending plan was not found.";
static final String JAR_NOT_FOUND_MESSAGE = "Spending jar was not found.";
static final String PLAN_JAR_NOT_FOUND_MESSAGE = "Spending plan jar was not found.";

private final CategoriesJarCommandValidator validator = new CategoriesJarCommandValidator();

private final Jars jars;
private final JarExpenseCategories jarExpenseCategories;

@Override
public Either<Failure, List<JarExpenseCategory>> addOrRemove(UUID planId, UUID jarId, JarCategoriesCommand command) {
var either = validate(planId, jarId, command);
if (either.isLeft()) {
return either;
}

// require plan jar to exist
var jar = jars.findByIdAndPlanId(new JarIdentifier(jarId), new PlanIdentifier(planId));
if (jar.isEmpty()) {
return Either.left(Failure.ofNotFound(PLAN_JAR_NOT_FOUND_MESSAGE));
}

// select unchecked and delete
command.categories().stream()
.filter(jarCategoryToAddOrRemove -> !jarCategoryToAddOrRemove.toAdd())
.map(JarCategoryToAddOrRemove::categoryId)
.forEach(category ->
jarExpenseCategories.deleteByJarIdAndCategoryId(new JarIdentifier(jarId), new CategoryIdentifier(category)));

// select checked and create if not exists
var toCreateCategories = filterToCreate(jar.get(), command);

jarExpenseCategories.saveAll(toCreateCategories);

return Either.right(toCreateCategories);
}

private Either<Failure, List<JarExpenseCategory>> validate(UUID planId, UUID jarId, JarCategoriesCommand command) {
// schema validation
var failure = validator.validate(command.categories());
if (failure.isInvalid()) {
return Either.left(Failure.ofValidation(VALIDATION_CREATE_FAILURE_MESSAGE, List.of(failure.getError())));
}

// validate non null plan id
if (isNull(planId)) {
return Either.left(Failure.ofNotFound(PLAN_NOT_FOUND_MESSAGE));
}

// validate non null jar id
if (isNull(jarId)) {
return Either.left(Failure.ofNotFound(JAR_NOT_FOUND_MESSAGE));
}

return Either.right(null);
}

private List<JarExpenseCategory> filterToCreate(Jar jar, JarCategoriesCommand command) {
return command.categories().stream()
.filter(JarCategoryToAddOrRemove::toAdd)
.map(JarCategoryToAddOrRemove::categoryId)
.filter(category -> !existsByJarIdAndCategoryId(jar.getId(), category))
.map(category -> JarExpenseCategory.builder()
.categoryId(new CategoryIdentifier(category))
.jar(jar)
.build())
.toList();
}

private boolean existsByJarIdAndCategoryId(JarIdentifier jarId, UUID category) {
return jarExpenseCategories.existsByJarIdAndCategoryId(jarId, new CategoryIdentifier(category));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.domain.JarExpenseCategory;
import io.vavr.control.Either;

import java.util.List;
import java.util.UUID;

import static java.util.Objects.isNull;

/**
* Interface representing a use case for adding or removing categories to/from a plan jar.
*/
public interface AddOrRemoveJarCategoryUseCase {

/**
* Adds or removes categories to/from a specified jar within a spending plan jar.
*
* @param planId The ID of the spending plan containing the jar.
* @param jarId The ID of the jar to which categories will be added or removed.
* @param command The command containing information about the categories to add or remove.
* @return An {@link Either} containing either a {@link Failure} if the operation fails
* or a list of added {@link JarExpenseCategory} after the operation.
*/
Either<Failure, List<JarExpenseCategory>> addOrRemove(UUID planId, UUID jarId, JarCategoriesCommand command);

record JarCategoriesCommand(List<JarCategoryToAddOrRemove> categories) {

public static final String CATEGORIES_JAR_FIELD = "categories";
}

record JarCategoryToAddOrRemove(UUID categoryId, Boolean toAdd) {

/**
* Gets whether the category is checked or not.
* Null is treated as true.
*
* @return True if the category is checked, false otherwise.
*/
public Boolean toAdd() { // null treated as true
return isNull(toAdd) || toAdd;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.shared.Failure;
import io.vavr.control.Validation;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import static io.myfinbox.shared.Failure.FieldViolation;
import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand.CATEGORIES_JAR_FIELD;
import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove;
import static io.vavr.API.Invalid;
import static io.vavr.API.Valid;
import static java.util.Objects.isNull;

final class CategoriesJarCommandValidator {

Validation<FieldViolation, List<JarCategoryToAddOrRemove>> validate(List<JarCategoryToAddOrRemove> categories) {
if (isNull(categories) || categories.isEmpty()) {
return Invalid(Failure.FieldViolation.builder()
.field(CATEGORIES_JAR_FIELD)
.message("At least one category must be provided.")
.rejectedValue(categories)
.build());
}

var anyNull = categories.stream()
.map(JarCategoryToAddOrRemove::categoryId)
.anyMatch(Objects::isNull);

if (anyNull) {
return Invalid(Failure.FieldViolation.builder()
.field(CATEGORIES_JAR_FIELD)
.message("Null categoryId not allowed.")
.rejectedValue(categories)
.build());
}

var categoryCount = categories.stream()
.collect(Collectors.groupingBy(JarCategoryToAddOrRemove::categoryId));

var anyDuplicate = categoryCount.values().stream()
.map(List::size)
.anyMatch(size -> size > 1);

if (anyDuplicate) {
return Invalid(Failure.FieldViolation.builder()
.field(CATEGORIES_JAR_FIELD)
.message("Duplicate category ids provided.")
.rejectedValue(categories)
.build());
}

return Valid(categories);
}
}
35 changes: 12 additions & 23 deletions server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.myfinbox.spendingplan.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.hypersistence.utils.hibernate.type.money.MonetaryAmountType;
import jakarta.persistence.*;
import lombok.*;
Expand All @@ -11,25 +10,31 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

import static io.myfinbox.shared.Guards.*;
import static lombok.AccessLevel.PRIVATE;
import static jakarta.persistence.CascadeType.ALL;
import static lombok.AccessLevel.PACKAGE;

@Entity
@Table(name = "spending_jars")
@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor(access = PRIVATE, force = true)
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = PACKAGE, force = true)
public class Jar {

public static final int MAX_NAME_LENGTH = 255;

@EmbeddedId
private final JarIdentifier id;

private final Instant creationTimestamp;

private String name;
private String description;

@Embedded
@AttributeOverride(name = "value", column = @Column(name = "percentage"))
private Percentage percentage;
Expand All @@ -39,13 +44,13 @@ public class Jar {
@CompositeType(MonetaryAmountType.class)
private MonetaryAmount amountToReach;

private String name;
private String description;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id", referencedColumnName = "id", nullable = false)
private Plan plan;

@OneToMany(mappedBy = "jar", cascade = ALL, orphanRemoval = true)
private List<JarExpenseCategory> jarExpenseCategories;

@Builder
public Jar(Percentage percentage,
MonetaryAmount amountToReach,
Expand Down Expand Up @@ -74,31 +79,15 @@ private void setAmountToReach(MonetaryAmount amountToReach) {
this.amountToReach = greaterThanZero(amountToReach, "amountToReach must be greater than 0.");
}

@JsonIgnore
public BigDecimal getAmountToReachAsNumber() {
return amountToReach.getNumber().numberValue(BigDecimal.class)
.setScale(2, RoundingMode.HALF_UP);
}

@JsonIgnore
public String getCurrencyCode() {
return amountToReach.getCurrency().getCurrencyCode();
}

@Embeddable
public record JarIdentifier(UUID id) implements Serializable {

public JarIdentifier {
notNull(id, "id cannot be null");
}

@Override
public String toString() {
return id.toString();
}
}

@Embeddable
public record Percentage(Integer value) implements Serializable {

public Percentage {
Expand Down
Loading

0 comments on commit bfe2a18

Please sign in to comment.