Skip to content

Commit

Permalink
Expose REST API for creating a classic plan
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed May 1, 2024
1 parent 46a7358 commit bd27db4
Show file tree
Hide file tree
Showing 13 changed files with 424 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreatePlanResource;
import io.myfinbox.rest.CreateClassicPlanResource;
import io.myfinbox.rest.PlanResource;
import io.myfinbox.shared.ApiFailureHandler;
import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase;
import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand;
import io.myfinbox.spendingplan.application.CreatePlanUseCase;
import io.myfinbox.spendingplan.application.PlanCommand;
import io.myfinbox.spendingplan.domain.Plan;
Expand All @@ -22,17 +25,25 @@
final class PlanController implements PlansApi {

private final CreatePlanUseCase createPlanUseCase;
private final ClassicPlanBuilderUseCase classicPlanBuilderUseCase;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@RequestBody CreatePlanResource resource) {
public ResponseEntity<?> create(@RequestBody PlanResource resource) {
return createPlanUseCase.create(toCommand(resource))
.fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id()))
.body(toCreatedResource(plan)));
.body(toResource(plan)));
}

private CreatePlanResource toCreatedResource(Plan plan) {
return new CreatePlanResource()
@PostMapping(path = "/classic", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> createClassic(@RequestBody CreateClassicPlanResource resource) {
return classicPlanBuilderUseCase.create(toCommand(resource))
.fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id()))
.body(toResource(plan)));
}

private PlanResource toResource(Plan plan) {
return new PlanResource()
.planId(plan.getId().id())
.name(plan.getName())
.creationTimestamp(plan.getCreationTimestamp().toString())
Expand All @@ -42,7 +53,7 @@ private CreatePlanResource toCreatedResource(Plan plan) {
.description(plan.getDescription());
}

private PlanCommand toCommand(CreatePlanResource resource) {
private PlanCommand toCommand(PlanResource resource) {
return PlanCommand.builder()
.accountId(resource.getAccountId())
.name(resource.getName())
Expand All @@ -51,4 +62,12 @@ private PlanCommand toCommand(CreatePlanResource resource) {
.description(resource.getDescription())
.build();
}

private CreateClassicPlanCommand toCommand(CreateClassicPlanResource resource) {
return CreateClassicPlanCommand.builder()
.accountId(resource.getAccountId())
.amount(resource.getAmount())
.currencyCode(resource.getCurrencyCode())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreatePlanResource;
import io.myfinbox.rest.CreateClassicPlanResource;
import io.myfinbox.rest.PlanResource;
import io.myfinbox.shared.ApiErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
Expand Down Expand Up @@ -28,7 +29,7 @@ public interface PlansApi {
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Spending plan created successfully",
headers = @Header(name = LOCATION, description = "Created spending plan source URI location", schema = @Schema(implementation = URI.class)),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = CreatePlanResource.class))),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = PlanResource.class))),
@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 = "Not found Failure",
Expand All @@ -40,6 +41,27 @@ public interface PlansApi {
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> create(@RequestBody(description = "Spending Plan Resource to be created", required = true) CreatePlanResource resource);
ResponseEntity<?> create(@RequestBody(description = "Spending Plan Resource to be created", required = true) PlanResource resource);

@Operation(summary = "Add a new classic spending plan in the MyFinBox",
description = "Operation to add a classic plan distribution: Necessities(55%), Long Term Savings(10%), Education(10%), Play(10%), Financial(10%), Give(5%).",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Spending plan created successfully",
headers = @Header(name = LOCATION, description = "Created spending plan source URI location", schema = @Schema(implementation = URI.class)),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = PlanResource.class))),
@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 = "Not found Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "Spending plan name already exists",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "422", description = "Field validation failures",
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<?> createClassic(@RequestBody(description = "Classic spending Plan Resource to be created", required = true) CreateClassicPlanResource resource);

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

import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.domain.ClassicJarDistribution;
import io.myfinbox.spendingplan.domain.Plan;
import io.vavr.control.Either;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
class ClassicPlanBuilderService implements ClassicPlanBuilderUseCase {

static final String CLASSIC_SPENDING_PLAN = "My classic spending plan";
static final String CLASSIC_PLAN_DESCRIPTION = "My classic plan distribution: Necessities(55%), Long Term Savings(10%), " +
"Education(10%), Play(10%), Financial(10%), Give(5%).";

private final CreatePlanUseCase createPlanUseCase;
private final CreateJarUseCase createJarUseCase;

@Override
public Either<Failure, Plan> create(CreateClassicPlanCommand command) {
log.debug("Classic spending plan creation ...");

var planCommand = PlanCommand.builder()
.accountId(command.accountId())
.amount(command.amount())
.currencyCode(command.currencyCode())
.name(CLASSIC_SPENDING_PLAN)
.description(CLASSIC_PLAN_DESCRIPTION)
.build();

var planEither = createPlanUseCase.create(planCommand);
if (planEither.isLeft()) {
return planEither;
}

var jarCommands = Arrays.stream(ClassicJarDistribution.values())
.map(jar -> JarCommand.builder()
.name(jar.jarName())
.percentage(jar.percentage())
.description(jar.description())
.build())
.toList();

var planIdentifier = planEither.get().getId();
for (var jarCommand : jarCommands) {
var jarEither = createJarUseCase.create(planIdentifier.id(), jarCommand);
if (jarEither.isLeft()) {
return Either.left(jarEither.getLeft());
}
}

log.debug("Classic spending plan was created '{}' , with 6 jars.", CLASSIC_SPENDING_PLAN);
return Either.right(planEither.get());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.domain.Plan;
import io.vavr.control.Either;
import lombok.Builder;

import java.math.BigDecimal;
import java.util.UUID;

/**
* Use case interface for creating classic plans.
*/
public interface ClassicPlanBuilderUseCase {

/**
* Creates a classic plan based on the provided command.
*
* @param command The command containing information for creating the classic plan.
* @return Either a failure or the created plan.
*/
Either<Failure, Plan> create(CreateClassicPlanCommand command);

@Builder
record CreateClassicPlanCommand(UUID accountId,
BigDecimal amount,
String currencyCode) {
public static final String FIELD_ACCOUNT_ID = "accountId";
public static final String FIELD_AMOUNT = "amount";
public static final String FIELD_CURRENCY_CODE = "currencyCode";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.myfinbox.spendingplan.domain;

/**
* Enumeration representing the distribution of funds into classic jars.
* <a href="https://www.harveker.com/blog/6-step-money-managing-system/">source 1</a> and
* <a href="https://note.moneylover.me/get-a-millionaire-mind-set-with-6-jars-of-money-management-system/">source 2</a>
*/
public enum ClassicJarDistribution {

/**
* Represents necessities spending: rent, food, bills, etc.
*/
NECESSITIES("Necessities", 55,
"Necessities spending: Rent, Food, Bills etc."),

/**
* Represents long-term savings spending: big purchases, vacations, rainy day fund, unexpected medical expenses.
*/
LONG_TERM_SAVING("Long Term Savings", 10,
"Long Term Savings spending: Big Purchases, Vacations, Rainy Day Fund, Unexpected Medical Expenses."),

/**
* Represents education spending: coaching, mentoring, books, courses, etc.
*/
EDUCATION("Education", 10,
"Education spending: Coaching, Mentoring, Books, Courses, etc."),

/**
* Represents play spending: spoiling yourself & your family, leisure expenses, fun, etc.
*/
PLAY("Play", 10,
"Play spending: Spoiling yourself & your family, Leisure expenses, Fun, etc."),

/**
* Represents financial spending: stocks, mutual funds, passive income vehicles, real estate investing, any other investments.
*/
FINANCIAL("Financial", 10,
"Financial spending: Stocks, Mutual Funds, Passive income Vehicles, Real Estate investing, Any other investments."),

/**
* Represents give spending: charitable, donations.
*/
GIVE("Give", 5, "Give spending: Charitable, Donations.");

private final String jarName;
private final int percentage;
private final String description;

ClassicJarDistribution(String jarName, int percentage, String description) {
this.jarName = jarName;
this.percentage = percentage;
this.description = description;
}

public String jarName() {
return jarName;
}

public int percentage() {
return percentage;
}

public String description() {
return description;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
@Entity
@Table(name = "spending_jars")
@Getter
@ToString
@ToString(exclude = {"plan", "jarExpenseCategories"})
@EqualsAndHashCode(of = {"id", "name"})
@NoArgsConstructor(access = PACKAGE, force = true)
public class Jar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
@Entity
@Table(name = "spending_plans")
@Getter
@ToString
@ToString(exclude = "jars")
@EqualsAndHashCode(callSuper = false, of = {"id", "name", "account"})
@NoArgsConstructor(access = PACKAGE, force = true)
public class Plan extends AbstractAggregateRoot<Plan> {
Expand Down
20 changes: 19 additions & 1 deletion server/src/main/resources/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ components:
example: Base salary
description: Additional description attached to the income.

CreatePlanResource:
PlanResource:
type: object
properties:
planId:
Expand Down Expand Up @@ -272,3 +272,21 @@ components:
default: true
example: true
description: Flag indicating whether to add or remove the category.

CreateClassicPlanResource:
type: object
properties:
accountId:
type: string
format: uuid
example: 3b257779-a5db-4e87-9365-72c6f8d4977d
description: The ID of the account that submitted the spending plan.
amount:
type: number
example: 1000
description: The amount of the spending plan.
currencyCode:
type: string
example: MDL
pattern: '^[A-Z]{3}$'
description: The ISO 4217 currency code in uppercase (e.g., USD, EUR).
25 changes: 23 additions & 2 deletions server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.myfinbox.spendingplan.domain.JarExpenseCategory
import io.myfinbox.spendingplan.domain.Plan

import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand
import static io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand
import static io.myfinbox.spendingplan.application.ExpenseRecordTrackerUseCase.ExpenseModificationRecord

class DataSamples {
Expand Down Expand Up @@ -48,6 +49,12 @@ class DataSamples {
description : "My basic plan for tracking expenses",
]

static CREATE_CLASSIC_PLAN_RESOURCE = [
accountId : accountId,
amount : amount,
currencyCode: currency,
]

static PLAN_COMMAND = [
name : name,
accountId : accountId,
Expand All @@ -56,6 +63,12 @@ class DataSamples {
description : "My basic plan for tracking expenses",
]

static CLASSIC_PLAN_COMMAND = [
accountId : accountId,
amount : amount,
currencyCode: currency
]

static PLAN = [
id : [id: planId],
name : name,
Expand Down Expand Up @@ -112,9 +125,9 @@ class DataSamples {
]

static EXPENSE_RECORD = [
id: 1L,
id : 1L,
expenseId : [id: expenseId],
categoryId : [id: jarCategoryId],
categoryId : [id: jarCategoryId],
paymentType: "CASH",
amount : AMOUNT,
expenseDate: expenseDate,
Expand All @@ -133,10 +146,18 @@ class DataSamples {
JsonOutput.toJson(CREATE_PLAN_RESOURCE + map) as String
}

static newSampleClassicCreatePlanResource(map = [:]) {
JsonOutput.toJson(CREATE_CLASSIC_PLAN_RESOURCE + map) as String
}

static newSampleCreatePlanCommand(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(PLAN_COMMAND + map) as String, PlanCommand.class)
}

static newSampleClassicCreatePlanCommand(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(CLASSIC_PLAN_COMMAND + map) as String, CreateClassicPlanCommand.class)
}

static newSamplePlan(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(PLAN + map) as String, Plan.class)
}
Expand Down
Loading

0 comments on commit bd27db4

Please sign in to comment.