Refactor Transaction to Expense

This commit is contained in:
Alexander Ungar 2023-03-31 18:08:41 +02:00
parent 7df93b4ee9
commit 27565e3eff
30 changed files with 246 additions and 265 deletions

View file

@ -4,8 +4,8 @@ info:
title: My Finance Pal
description: API of the personal finance budgeting app My Finance Pal
tags:
- name: transactions
description: Transaction of budgets
- name: expenses
description: Expenses of budgets
- name: budgets
description: Managing budgets
paths:
@ -48,7 +48,7 @@ paths:
get:
tags:
- budgets
description: Get a budget summary including all transactions for a given ID
description: Get a budget summary including all expenses for a given ID
operationId: getBudgetSummary
parameters:
- in: path
@ -70,7 +70,7 @@ paths:
delete:
tags:
- budgets
description: Delete a budget including all transactions
description: Delete a budget including all expenses
operationId: deleteBudget
parameters:
- in: path
@ -84,12 +84,12 @@ paths:
description: Budget successfully deleted
404:
description: Budget not found
/budgets/{budgetId}/transactions:
/budgets/{budgetId}/expenses:
post:
tags:
- transactions
description: Register a new transaction for a budget
operationId: createTransaction
- expenses
description: Register a new expense for a budget
operationId: createExpense
parameters:
- $ref: "#/components/parameters/BudgetIdParam"
requestBody:
@ -97,14 +97,14 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/NewTransaction"
$ref: "#/components/schemas/NewExpense"
responses:
201:
description: Transaction created
description: Expense created
content:
application/json:
schema:
$ref: "#/components/schemas/Transaction"
$ref: "#/components/schemas/Expense"
404:
description: Budget not found
@ -117,13 +117,13 @@ components:
description: The UUID of the budget
schema:
$ref: "#/components/schemas/BudgetId"
TransactionIdParam:
ExpenseIdParam:
in: path
name: transactionId
name: expenseId
required: true
description: The UUID of the transaction
description: The UUID of the expense
schema:
$ref: "#/components/schemas/TransactionId"
$ref: "#/components/schemas/ExpenseId"
schemas:
BudgetId:
type: string
@ -153,7 +153,7 @@ components:
spent:
type: number
minimum: 0
description: The summed up spent amount of all transaction of that budget
description: The summed up spent amount of all expenses of that budget
startDate:
type: string
format: date
@ -166,13 +166,13 @@ components:
example: 2023-04-30
BudgetSummary:
type: object
description: Summary of the whole budget including all transactions
description: Summary of the whole budget including all expenses
required:
- id
- name
- limit
- spent
- transactions
- expenses
properties:
id:
$ref: "#/components/schemas/BudgetId"
@ -199,11 +199,11 @@ components:
format: date
description: Date marking the end of the budget period
example: 2023-04-30
transactions:
expenses:
type: array
description: All transactions of the budget
description: All expenses of the budget
items:
$ref: "#/components/schemas/Transaction"
$ref: "#/components/schemas/Expense"
NewBudget:
type: object
description: Budget to be created
@ -230,14 +230,14 @@ components:
format: date
description: Date marking the end of the budget period
example: 2023-04-30
TransactionId:
ExpenseId:
type: string
format: uuid
description: Unique identifier of a transaction
description: Unique identifier of a expense
example: cfd3b0b7-bf5e-43d4-8341-940d5e07487d
Transaction:
Expense:
type: object
description: An expense or income related to a single budget
description: An expense related to a single budget
required:
- id
- description
@ -245,21 +245,21 @@ components:
- date
properties:
id:
$ref: "#/components/schemas/TransactionId"
$ref: "#/components/schemas/ExpenseId"
description:
type: string
description: Description of the transaction
description: Description of the expense
example: Healthy breakfast at Five Guys
amount:
type: number
description: Amount of money contained in the transaction
description: Amount of money contained in the expense
example: 23.94
date:
type: string
format: date-time
NewTransaction:
NewExpense:
type: object
description: A new transaction to be created
description: A new expense to be created
required:
- description
- amount
@ -267,11 +267,11 @@ components:
properties:
description:
type: string
description: Description of the transaction
description: Description of the expense
example: Healthy breakfast at Five Guys
amount:
type: number
description: Amount of money contained in the transaction
description: Amount of money contained in the expense
example: 23.94
date:
type: string

View file

@ -7,8 +7,8 @@ import BudgetService from "./usecase/budget/budgetService";
import { errorHandler } from "./middleware/errorHandler";
import BudgetMongoRepository from "./repository/budget/mongo/budgetMongoRepository";
import BudgetSummaryMongoRepository from "./repository/budget/mongo/budgetSummaryMongoRepository";
import TransactionService from "./usecase/transaction/transactionService";
import TransactionMongoRepository from "./repository/transaction/mongo/TransactionMongoRepository";
import ExpenseService from "./usecase/expense/expenseService";
import ExpenseMongoRepository from "./repository/expense/mongo/ExpenseMongoRepository";
import * as OpenApiValidator from "express-openapi-validator";
import * as path from "path";
@ -34,8 +34,8 @@ const budgetUseCases = BudgetService(
BudgetSummaryMongoRepository(),
BudgetMongoRepository()
);
const transactionUseCases = TransactionService(TransactionMongoRepository());
app.use(ApiRouter(budgetUseCases, transactionUseCases));
const expenseUseCases = ExpenseService(ExpenseMongoRepository());
app.use(ApiRouter(budgetUseCases, expenseUseCases));
app.use(errorHandler);

View file

@ -1,6 +1,6 @@
import type UUID from "./uuid";
import type Limit from "./limit";
import { type Transaction } from "./transaction";
import { type Expense } from "./expense";
export type BudgetId = UUID;
@ -17,5 +17,5 @@ export interface Budget extends NewBudget {
}
export interface BudgetSummary extends Budget {
transactions: Transaction[];
expenses: Expense[];
}

13
src/domain/expense.ts Normal file
View file

@ -0,0 +1,13 @@
import type UUID from "./uuid";
export type ExpenseId = UUID;
export interface NewExpense {
description: string;
amount: number;
date: Date;
}
export interface Expense extends NewExpense {
id: ExpenseId;
}

View file

@ -1,13 +0,0 @@
import type UUID from "./uuid";
export type TransactionId = UUID;
export interface NewTransaction {
description: string;
amount: number;
date: Date;
}
export interface Transaction extends NewTransaction {
id: TransactionId;
}

View file

@ -1,4 +1,4 @@
import type TransactionEntity from "../../transaction/entity/transactionEntity";
import type ExpenseEntity from "../../expense/entity/expenseEntity";
interface BudgetSummaryEntity {
id: string;
@ -7,7 +7,7 @@ interface BudgetSummaryEntity {
spent: number;
startDate?: Date;
endDate?: Date;
transactions: TransactionEntity[];
expenses: ExpenseEntity[];
}
export default BudgetSummaryEntity;

View file

@ -3,7 +3,7 @@ import type BudgetSummaryEntity from "./budgetSummaryEntity";
import UUID from "../../../domain/uuid";
import Limit from "../../../domain/limit";
import type BudgetEntity from "./budgetEntity";
import { TransactionEntityConverter } from "../../transaction/entity/converters";
import { ExpenseEntityConverter } from "../../expense/entity/converters";
export const BudgetEntityConverter = {
toDomain: (entity: BudgetEntity): Budget => ({
@ -24,7 +24,7 @@ export const BudgetSummaryEntityConverter = {
spent: domain.spent,
startDate: domain.startDate,
endDate: domain.endDate,
transactions: domain.transactions.map(TransactionEntityConverter.toEntity),
expenses: domain.expenses.map(ExpenseEntityConverter.toEntity),
}),
toDomain: (entity: BudgetSummaryEntity): BudgetSummary => ({
id: new UUID(entity.id),
@ -33,6 +33,6 @@ export const BudgetSummaryEntityConverter = {
spent: entity.spent,
startDate: entity.startDate,
endDate: entity.endDate,
transactions: entity.transactions.map(TransactionEntityConverter.toDomain),
expenses: entity.expenses.map(ExpenseEntityConverter.toDomain),
}),
};

View file

@ -3,7 +3,7 @@ import { BudgetEntityConverter } from "../entity/converters";
import { BudgetSummaryModel } from "./models";
export const findBudgets: BudgetRepository["findAll"] = async () => {
const found = await BudgetSummaryModel.find().select("-transactions");
const found = await BudgetSummaryModel.find().select("-expenses");
return found.map(BudgetEntityConverter.toDomain);
};

View file

@ -1,6 +1,6 @@
import mongoose, { Schema } from "mongoose";
import type BudgetSummaryEntity from "../../entity/budgetSummaryEntity";
import transactionSchema from "../../../transaction/mongo/schema/transactionSchema";
import expenseSchema from "../../../expense/mongo/schema/expenseSchema";
const Types = Schema.Types;
@ -12,7 +12,7 @@ const budgetSummarySchema = new mongoose.Schema<BudgetSummaryEntity>(
spent: Types.Number,
startDate: Types.Date,
endDate: Types.Date,
transactions: [transactionSchema],
expenses: [expenseSchema],
},
{ strict: true, timestamps: true, versionKey: false }
);

View file

@ -1,15 +1,15 @@
import { type Transaction } from "../../../domain/transaction";
import { type Expense } from "../../../domain/expense";
import UUID from "../../../domain/uuid";
import type TransactionEntity from "./transactionEntity";
import type ExpenseEntity from "./expenseEntity";
export const TransactionEntityConverter = {
toEntity: (domain: Transaction): TransactionEntity => ({
export const ExpenseEntityConverter = {
toEntity: (domain: Expense): ExpenseEntity => ({
id: domain.id.value,
description: domain.description,
amount: domain.amount,
date: domain.date,
}),
toDomain: (entity: TransactionEntity): Transaction => ({
toDomain: (entity: ExpenseEntity): Expense => ({
id: new UUID(entity.id),
description: entity.description,
amount: entity.amount,

View file

@ -1,8 +1,8 @@
interface TransactionEntity {
interface ExpenseEntity {
id: string;
description: string;
amount: number;
date: Date;
}
export default TransactionEntity;
export default ExpenseEntity;

View file

@ -0,0 +1,13 @@
import { type Expense } from "../../domain/expense";
import { type BudgetId } from "../../domain/budget";
interface ExpenseRepository {
findAllForBudget: (budgetId: BudgetId) => Promise<Expense[] | undefined>;
insert: (
budgetId: BudgetId,
spent: number,
expense: Expense
) => Promise<Expense | undefined>;
}
export default ExpenseRepository;

View file

@ -0,0 +1,44 @@
import type ExpenseRepository from "../expenseRepository";
import { ExpenseEntityConverter } from "../entity/converters";
import { BudgetSummaryModel } from "../../budget/mongo/models";
import type BudgetSummaryEntity from "../../budget/entity/budgetSummaryEntity";
export const findAllExpensesForBudget: ExpenseRepository["findAllForBudget"] =
async (budgetId) => {
const budgetExpenses: Pick<BudgetSummaryEntity, "expenses"> | null =
await BudgetSummaryModel.findOne({
id: budgetId.value,
}).select("expenses");
if (budgetExpenses !== null) {
return budgetExpenses.expenses.map(ExpenseEntityConverter.toDomain);
}
};
export const insertExpense: ExpenseRepository["insert"] = async (
budgetId,
spent,
expense
) => {
const expenseEntity = ExpenseEntityConverter.toEntity(expense);
const updatedSummary = await BudgetSummaryModel.findOneAndUpdate(
{ id: budgetId.value },
{
$set: { spent },
$push: { expenses: expenseEntity },
},
{ returnDocument: "after" }
);
const updatedExpense = updatedSummary?.expenses.find(
(t) => t.id === expense.id.value
);
if (updatedExpense !== undefined) {
return ExpenseEntityConverter.toDomain(updatedExpense);
}
};
const ExpenseMongoRepository = (): ExpenseRepository => ({
findAllForBudget: findAllExpensesForBudget,
insert: insertExpense,
});
export default ExpenseMongoRepository;

View file

@ -1,9 +1,9 @@
import mongoose, { Schema } from "mongoose";
import type TransactionEntity from "../../entity/transactionEntity";
import type ExpenseEntity from "../../entity/expenseEntity";
const Types = Schema.Types;
const transactionSchema = new mongoose.Schema<TransactionEntity>(
const expenseSchema = new mongoose.Schema<ExpenseEntity>(
{
id: Types.UUID,
description: Types.String,
@ -13,4 +13,4 @@ const transactionSchema = new mongoose.Schema<TransactionEntity>(
{ strict: true, _id: false }
);
export default transactionSchema;
export default expenseSchema;

View file

@ -1,46 +0,0 @@
import type TransactionRepository from "../transactionRepository";
import { TransactionEntityConverter } from "../entity/converters";
import { BudgetSummaryModel } from "../../budget/mongo/models";
import type BudgetSummaryEntity from "../../budget/entity/budgetSummaryEntity";
export const findAllTransactionsForBudget: TransactionRepository["findAllForBudget"] =
async (budgetId) => {
const budgetTransactions: Pick<BudgetSummaryEntity, "transactions"> | null =
await BudgetSummaryModel.findOne({
id: budgetId.value,
}).select("transactions");
if (budgetTransactions !== null) {
return budgetTransactions.transactions.map(
TransactionEntityConverter.toDomain
);
}
};
export const insertTransaction: TransactionRepository["insert"] = async (
budgetId,
spent,
transaction
) => {
const transactionEntity = TransactionEntityConverter.toEntity(transaction);
const updatedSummary = await BudgetSummaryModel.findOneAndUpdate(
{ id: budgetId.value },
{
$set: { spent },
$push: { transactions: transactionEntity },
},
{ returnDocument: "after" }
);
const updatedTransaction = updatedSummary?.transactions.find(
(t) => t.id === transaction.id.value
);
if (updatedTransaction !== undefined) {
return TransactionEntityConverter.toDomain(updatedTransaction);
}
};
const TransactionMongoRepository = (): TransactionRepository => ({
findAllForBudget: findAllTransactionsForBudget,
insert: insertTransaction,
});
export default TransactionMongoRepository;

View file

@ -1,13 +0,0 @@
import { type Transaction } from "../../domain/transaction";
import { type BudgetId } from "../../domain/budget";
interface TransactionRepository {
findAllForBudget: (budgetId: BudgetId) => Promise<Transaction[] | undefined>;
insert: (
budgetId: BudgetId,
spent: number,
transaction: Transaction
) => Promise<Transaction | undefined>;
}
export default TransactionRepository;

View file

@ -5,7 +5,7 @@ const apiPaths: Record<keyof operations, keyof paths> = {
getBudgets: "/budgets",
getBudgetSummary: "/budgets/{budgetId}/summary",
deleteBudget: "/budgets/{budgetId}",
createTransaction: "/budgets/{budgetId}/transactions",
createExpense: "/budgets/{budgetId}/expenses",
};
export default apiPaths;

View file

@ -1,16 +1,16 @@
import { Router } from "express";
import BudgetRouter from "./budget/budgetRouter";
import type BudgetUseCases from "../usecase/budget/budgetUseCases";
import TransactionRouter from "./transaction/transactionRouter";
import type TransactionUseCases from "../usecase/transaction/transactionUseCases";
import ExpenseRouter from "./expense/expenseRouter";
import type ExpenseUseCases from "../usecase/expense/expenseUseCases";
const ApiRouter = (
budgetUseCases: BudgetUseCases,
transactionUseCases: TransactionUseCases
expenseUseCases: ExpenseUseCases
): Router => {
const router = Router();
router.use(BudgetRouter(budgetUseCases));
router.use(TransactionRouter(transactionUseCases));
router.use(ExpenseRouter(expenseUseCases));
return router;
};

View file

@ -10,7 +10,7 @@ import {
type BudgetSummaryDto,
type NewBudgetDto,
} from "./budget";
import { TransactionDtoConverter } from "../../transaction/dto/converters";
import { ExpenseDtoConverter } from "../../expense/dto/converters";
export const NewBudgetDtoConverter = {
toDomain: (dto: NewBudgetDto): NewBudget => ({
@ -40,6 +40,6 @@ export const BudgetSummaryDtoConverter = {
name: domain.name,
startDate: toIsoDate(domain.startDate),
endDate: toIsoDate(domain.endDate),
transactions: domain.transactions.map(TransactionDtoConverter.toDto),
expenses: domain.expenses.map(ExpenseDtoConverter.toDto),
}),
};

View file

@ -0,0 +1,19 @@
import { type NewExpenseDto, type ExpenseDto } from "./expense";
import { type NewExpense, type Expense } from "../../../domain/expense";
export const NewExpenseDtoConverter = {
toDomain: (dto: NewExpenseDto): NewExpense => ({
description: dto.description,
date: new Date(dto.date),
amount: dto.amount,
}),
};
export const ExpenseDtoConverter = {
toDto: (domain: Expense): ExpenseDto => ({
id: domain.id.value,
amount: domain.amount,
description: domain.description,
date: domain.date.toISOString(),
}),
};

View file

@ -0,0 +1,4 @@
import { type components } from "../../../../generated/api";
export type NewExpenseDto = components["schemas"]["NewExpense"];
export type ExpenseDto = components["schemas"]["Expense"];

View file

@ -0,0 +1,39 @@
import type ExpenseUseCases from "../../usecase/expense/expenseUseCases";
import { type Request, type Response, Router } from "express";
import { NewExpenseDtoConverter, ExpenseDtoConverter } from "./dto/converters";
import UUID from "../../domain/uuid";
import { StatusCodes } from "http-status-codes";
import toExpressPath from "../toExpressPath";
import apiPaths from "../apiPaths";
import asyncHandler from "express-async-handler";
export const createExpense =
(addExpenseToBudget: ExpenseUseCases["addToBudget"]) =>
async (req: Request, res: Response): Promise<void> => {
const newExpenseDto = req.body;
const budgetId: string = req.params.budgetId;
const newExpense = NewExpenseDtoConverter.toDomain(newExpenseDto);
const expense = await addExpenseToBudget(
new UUID(budgetId),
newExpense
);
if (expense === undefined) {
res.sendStatus(StatusCodes.NOT_FOUND);
} else {
res
.status(StatusCodes.CREATED)
.json(ExpenseDtoConverter.toDto(expense));
}
};
const ExpenseRouter = (expenseUseCases: ExpenseUseCases): Router => {
const router = Router();
router.post(
toExpressPath(apiPaths.createExpense),
asyncHandler(createExpense(expenseUseCases.addToBudget))
);
return router;
};
export default ExpenseRouter;

View file

@ -1,22 +0,0 @@
import { type NewTransactionDto, type TransactionDto } from "./transaction";
import {
type NewTransaction,
type Transaction,
} from "../../../domain/transaction";
export const NewTransactionDtoConverter = {
toDomain: (dto: NewTransactionDto): NewTransaction => ({
description: dto.description,
date: new Date(dto.date),
amount: dto.amount,
}),
};
export const TransactionDtoConverter = {
toDto: (domain: Transaction): TransactionDto => ({
id: domain.id.value,
amount: domain.amount,
description: domain.description,
date: domain.date.toISOString(),
}),
};

View file

@ -1,4 +0,0 @@
import { type components } from "../../../../generated/api";
export type NewTransactionDto = components["schemas"]["NewTransaction"];
export type TransactionDto = components["schemas"]["Transaction"];

View file

@ -1,47 +0,0 @@
import type TransactionUseCases from "../../usecase/transaction/transactionUseCases";
import { type Request, type Response, Router } from "express";
import {
NewTransactionDtoConverter,
TransactionDtoConverter,
} from "./dto/converters";
import UUID from "../../domain/uuid";
import { StatusCodes } from "http-status-codes";
import toExpressPath from "../toExpressPath";
import apiPaths from "../apiPaths";
import asyncHandler from "express-async-handler";
export const createTransaction =
(addTransactionToBudget: TransactionUseCases["addNewTransactionToBudget"]) =>
async (req: Request, res: Response): Promise<void> => {
const newTransactionDto = req.body;
const budgetId: string = req.params.budgetId;
const newTransaction =
NewTransactionDtoConverter.toDomain(newTransactionDto);
const transaction = await addTransactionToBudget(
new UUID(budgetId),
newTransaction
);
if (transaction === undefined) {
res.sendStatus(StatusCodes.NOT_FOUND);
} else {
res
.status(StatusCodes.CREATED)
.json(TransactionDtoConverter.toDto(transaction));
}
};
const TransactionRouter = (
transactionUseCases: TransactionUseCases
): Router => {
const router = Router();
router.post(
toExpressPath(apiPaths.createTransaction),
asyncHandler(
createTransaction(transactionUseCases.addNewTransactionToBudget)
)
);
return router;
};
export default TransactionRouter;

View file

@ -11,7 +11,7 @@ export const createBudget: (
...newBudget,
id: new UUID(),
spent: 0,
transactions: [],
expenses: [],
};
return await insertBudget(budgetSummary);
};

View file

@ -0,0 +1,39 @@
import type ExpenseRepository from "../../repository/expense/expenseRepository";
import type ExpenseUseCases from "./expenseUseCases";
import { type Expense } from "../../domain/expense";
import UUID from "../../domain/uuid";
import currency from "currency.js";
const addNewExpenseToBudget: (
findAll: ExpenseRepository["findAllForBudget"],
insert: ExpenseRepository["insert"]
) => ExpenseUseCases["addToBudget"] =
(findAll, insert) => async (budgetId, newExpense) => {
const existingExpenses = await findAll(budgetId);
if (existingExpenses === undefined) {
return;
}
const expense: Expense = {
...newExpense,
id: new UUID(),
};
const updatedSpent = calculateSpent(existingExpenses, expense);
return await insert(budgetId, updatedSpent, expense);
};
const calculateSpent = (expenses: Expense[], newExpense: Expense): number =>
expenses.reduce(
(prev, curr) => prev.add(curr.amount),
currency(newExpense.amount)
).value;
const ExpenseService: (
expenseRepository: ExpenseRepository
) => ExpenseUseCases = (expenseRepository) => ({
addToBudget: addNewExpenseToBudget(
expenseRepository.findAllForBudget,
expenseRepository.insert
),
});
export default ExpenseService;

View file

@ -0,0 +1,11 @@
import { type BudgetId } from "../../domain/budget";
import { type NewExpense, type Expense } from "../../domain/expense";
interface ExpenseUseCases {
addToBudget: (
budgetId: BudgetId,
newExpense: NewExpense
) => Promise<Expense | undefined>;
}
export default ExpenseUseCases;

View file

@ -1,42 +0,0 @@
import type TransactionRepository from "../../repository/transaction/transactionRepository";
import type TransactionUseCases from "./transactionUseCases";
import { type Transaction } from "../../domain/transaction";
import UUID from "../../domain/uuid";
import currency from "currency.js";
const addNewTransactionToBudget: (
findAll: TransactionRepository["findAllForBudget"],
insert: TransactionRepository["insert"]
) => TransactionUseCases["addNewTransactionToBudget"] =
(findAll, insert) => async (budgetId, newTransaction) => {
const existingTransactions = await findAll(budgetId);
if (existingTransactions === undefined) {
return;
}
const transaction: Transaction = {
...newTransaction,
id: new UUID(),
};
const updatedSpent = calculateSpent(existingTransactions, transaction);
return await insert(budgetId, updatedSpent, transaction);
};
const calculateSpent = (
transactions: Transaction[],
newTransaction: Transaction
): number =>
transactions.reduce(
(prev, curr) => prev.add(curr.amount),
currency(newTransaction.amount)
).value;
const TransactionService: (
transactionRepo: TransactionRepository
) => TransactionUseCases = (transactionRepo) => ({
addNewTransactionToBudget: addNewTransactionToBudget(
transactionRepo.findAllForBudget,
transactionRepo.insert
),
});
export default TransactionService;

View file

@ -1,14 +0,0 @@
import { type BudgetId } from "../../domain/budget";
import {
type NewTransaction,
type Transaction,
} from "../../domain/transaction";
interface TransactionUseCases {
addNewTransactionToBudget: (
budgetId: BudgetId,
newTransaction: NewTransaction
) => Promise<Transaction | undefined>;
}
export default TransactionUseCases;