diff --git a/.gitignore b/.gitignore index c6fc8c7..fca72bd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ logs dist build +.zed diff --git a/eslint.config.mjs b/eslint.config.mjs index 827bfa3..15082f1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default antfu( semi: true, quotes: "double", }, + ignores: [".github/ISSUE_TEMPLATE"], }, { rules: { diff --git a/src/app.ts b/src/app.ts index 43d921b..9034d38 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,12 +3,13 @@ import { createApp } from "./common/lib/create-app.lib"; import { activityRouter } from "./modules/activities/activity.index"; import { authRouter } from "./modules/auth/auth.index"; import { categoryRouter } from "./modules/categories/category.index"; +import { expenseRouter } from "./modules/expenses/expense.index"; import { groupRouters } from "./modules/group/group.index"; import { healthCheckRouter } from "./modules/health-check/health-check.index"; export const app = createApp(); -const routesV1 = [authRouter, categoryRouter, groupRouters, activityRouter]; +const routesV1 = [authRouter, categoryRouter, groupRouters, activityRouter, expenseRouter]; configureOpenAPI(app); diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index 9c68651..18681e3 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -10,3 +10,4 @@ export const FORBIDDEN_ERROR_MESSAGE export const INTERNAL_SERVER_ERROR_MESSAGE = "Something went wrong, please try again later"; export const VALIDATION_ERROR_MESSAGE = "The validation error(s)"; +export const NOT_FOUND_ERROR_MESSAGE = "Requested resource not found"; diff --git a/src/db/schemas/expense.model.ts b/src/db/schemas/expense.model.ts index ed08070..e1d6067 100644 --- a/src/db/schemas/expense.model.ts +++ b/src/db/schemas/expense.model.ts @@ -22,19 +22,22 @@ const expenseModel = pgTable("expense", { .$defaultFn(() => Bun.randomUUIDv7()) .primaryKey() .notNull(), - payerId: varchar({ length: 60 }) .notNull() .references(() => userModel.id, { onDelete: "cascade", onUpdate: "cascade", }), - categoryId: varchar({ length: 60 }) + creatorId: varchar({ length: 60 }) .notNull() - .references(() => categoryModel.id, { + .references(() => userModel.id, { onDelete: "cascade", onUpdate: "cascade", }), + categoryId: varchar({ length: 60 }).references(() => categoryModel.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), groupId: varchar({ length: 60 }).references(() => groupModel.id, { onDelete: "cascade", onUpdate: "cascade", @@ -53,6 +56,8 @@ export const selectExpenseSchema = createSelectSchema(expenseModel, { id: schema => schema.id.describe("Unique identifier for the expense"), payerId: schema => schema.payerId.describe("Reference to the user who paid for the expense"), + creatorId: schema => + schema.creatorId.describe("Reference to the user who created the expense"), categoryId: schema => schema.categoryId.describe("Reference to the category of the expense"), groupId: schema => @@ -76,7 +81,7 @@ export const selectExpenseSchema = createSelectSchema(expenseModel, { export const insertExpenseSchema = createInsertSchema(expenseModel, { id: schema => schema.id.describe("Unique identifier for the expense"), payerId: schema => - schema.payerId.describe("Reference to the user who paid for the expense"), + schema.payerId.describe("Reference to the user who paid for the expense. (Note: For admin users, this field is required.)"), categoryId: schema => schema.categoryId.describe("Reference to the category of the expense"), groupId: schema => diff --git a/src/modules/expenses/expense.handlers.ts b/src/modules/expenses/expense.handlers.ts new file mode 100644 index 0000000..7555a12 --- /dev/null +++ b/src/modules/expenses/expense.handlers.ts @@ -0,0 +1,140 @@ +import { ActivityType, AuthRoles } from "@/common/enums"; +import { logActivity } from "@/common/helpers/activity-log.helper"; +import type { AppRouteHandler } from "@/common/lib/types"; +import { AUTHORIZATION_ERROR_MESSAGE } from "@/common/utils/constants"; +import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util"; + +import { getUserByIdRepository } from "../users/user.repository"; +import { createExpenseRepository, isCategoryValidToCreateExpense } from "./expense.repository"; +import type { TCreateExpenseRoute } from "./expense.routes"; + +export const createExpense: AppRouteHandler = async ( + c, +) => { + const logger = c.get("logger"); + const user = c.get("user"); + const payload = c.req.valid("json"); + + if (!user) { + logger.debug("createExpense: User is not authorized to create expense"); + return c.json( + { + success: false, + message: AUTHORIZATION_ERROR_MESSAGE, + }, + HTTPStatusCodes.UNAUTHORIZED, + ); + } + + const { payerId, categoryId } = payload; + + // check if payerId is valid + if (payerId) { + const payerUser = await getUserByIdRepository(payerId); + + if (!payerUser) { + logger.debug("createExpense: Payer not found"); + return c.json( + { + success: false, + message: "Payer not found", + }, + HTTPStatusCodes.NOT_FOUND, + ); + } + } + + if (categoryId) { + const { category, isValid } = await isCategoryValidToCreateExpense(categoryId, payerId, user); + + if (!category) { + logger.debug("createExpense: Category not found"); + return c.json( + { + success: false, + message: "Category not found", + }, + HTTPStatusCodes.NOT_FOUND, + ); + } + + if (!isValid) { + logger.debug("createExpense: Category does not belong to the user or the specified payer"); + return c.json( + { + success: false, + message: "Category does not belong to the user or the specified payer", + }, + HTTPStatusCodes.BAD_REQUEST, + ); + } + } + + let expense; + switch (user.role) { + case AuthRoles.USER: { + const expensePayload = { + ...payload, + payerId: payerId || user.id, + creatorId: user.id, + }; + + expense = await createExpenseRepository(expensePayload); + break; + } + case AuthRoles.ADMIN: { + // check if payerId exists + if (!payerId) { + logger.debug("createExpense: Missing payer Id to create expense"); + return c.json( + { + success: false, + message: "Missing payerId", + }, + HTTPStatusCodes.BAD_REQUEST, + ); + } + + const expensePayload = { + ...payload, + payerId, + creatorId: user.id, + }; + expense = await createExpenseRepository(expensePayload); + break; + } + } + + if (!expense) { + logger.error("Failed to create expense"); + return c.json( + { + success: false, + message: "Failed to create expense", + }, + HTTPStatusCodes.INTERNAL_SERVER_ERROR, + ); + } + + void logActivity({ + type: ActivityType.EXPENSE_ADDED, + metadata: { + action: "create", + resourceType: "expense", + actorId: user.id, + targetId: expense.id, + destinationId: expense.id, + actorName: user.fullName || "", + msg: "expense created", + }, + }); + logger.debug(`Expense created successfully`); + return c.json( + { + success: true, + message: "Expense created successfully", + data: expense, + }, + HTTPStatusCodes.CREATED, + ); +}; diff --git a/src/modules/expenses/expense.index.ts b/src/modules/expenses/expense.index.ts new file mode 100644 index 0000000..337339e --- /dev/null +++ b/src/modules/expenses/expense.index.ts @@ -0,0 +1,9 @@ +import { createRouter } from "@/common/lib/create-app.lib"; + +import * as handlers from "./expense.handlers"; +import * as routes from "./expense.routes"; + +export const expenseRouter = createRouter().openapi( + routes.createExpenseRoute, + handlers.createExpense, +); diff --git a/src/modules/expenses/expense.repository.ts b/src/modules/expenses/expense.repository.ts new file mode 100644 index 0000000..ff29278 --- /dev/null +++ b/src/modules/expenses/expense.repository.ts @@ -0,0 +1,40 @@ +import { AuthRoles } from "@/common/enums"; +import { db } from "@/db/adapter"; +import type { TInsertExpenseSchema } from "@/db/schemas/expense.model"; +import expenseModel from "@/db/schemas/expense.model"; +import type { TSelectUserSchema } from "@/db/schemas/user.model"; + +import { getCategoryRepository } from "../categories/category.repository"; + +type TExpensePayload = Omit; + +export async function createExpenseRepository( + expensePayload: TExpensePayload, +) { + const [expense] = await db + .insert(expenseModel) + .values(expensePayload) + .returning(); + + return expense; +} + +// 1. Admin create expense - category should either belong to payer or global. +// 2. User create self paid expense - category should either belong to user or global. +// 3. User create expense for another payer - category should belong to payer. +export async function isCategoryValidToCreateExpense(categoryId: string, payerId: string | undefined, user: TSelectUserSchema) { + const category = await getCategoryRepository(categoryId); + let isValid = false; + + if (!category) { + return { category: null, isValid }; + } + + if (user.role === AuthRoles.ADMIN) { + isValid = category.userId === null || category.userId === payerId; + } else { + isValid = payerId ? category.userId === null : (category.userId === null || category.userId === user.id); + } + + return { category, isValid }; +} diff --git a/src/modules/expenses/expense.routes.ts b/src/modules/expenses/expense.routes.ts new file mode 100644 index 0000000..1108366 --- /dev/null +++ b/src/modules/expenses/expense.routes.ts @@ -0,0 +1,87 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod"; + +import jsonContentRequired from "@/common/helpers/json-content-required.helper"; +import { jsonContent } from "@/common/helpers/json-content.helper"; +import { + authMiddleware, + requireAuth, +} from "@/common/middlewares/auth.middleware"; +import createErrorSchema from "@/common/schema/create-error.schema"; +import { AUTHORIZATION_ERROR_MESSAGE, INTERNAL_SERVER_ERROR_MESSAGE, NOT_FOUND_ERROR_MESSAGE, VALIDATION_ERROR_MESSAGE } from "@/common/utils/constants"; +import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util"; +import { + insertExpenseSchema, + selectExpenseSchema, +} from "@/db/schemas/expense.model"; + +const tags = ["Expenses"]; + +export const createExpenseRoute = createRoute({ + tags, + method: "post", + path: "/expenses", + middleware: [ + authMiddleware(), + requireAuth(), + ] as const, + request: { + body: jsonContentRequired( + insertExpenseSchema + .omit({ + id: true, + createdAt: true, + updatedAt: true, + creatorId: true, + }) + .partial({ + payerId: true, + }), + "Expense creation details", + ), + }, + responses: { + [HTTPStatusCodes.CREATED]: jsonContent( + z.object({ + success: z.boolean().default(true), + message: z.string(), + data: selectExpenseSchema, + }), + "Expense created successfully", + ), + [HTTPStatusCodes.BAD_REQUEST]: jsonContent( + z.object({ + success: z.boolean().default(false), + message: z.string(), + }), + VALIDATION_ERROR_MESSAGE, + ), + [HTTPStatusCodes.UNAUTHORIZED]: jsonContent( + z.object({ + success: z.boolean().default(false), + message: z.string(), + }), + AUTHORIZATION_ERROR_MESSAGE, + ), + [HTTPStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( + createErrorSchema(insertExpenseSchema), + VALIDATION_ERROR_MESSAGE, + ), + [HTTPStatusCodes.NOT_FOUND]: jsonContent( + z.object({ + success: z.boolean().default(false), + message: z.string(), + }), + NOT_FOUND_ERROR_MESSAGE, + ), + [HTTPStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + z.object({ + success: z.boolean().default(false), + message: z.string(), + }), + INTERNAL_SERVER_ERROR_MESSAGE, + ), + }, +}); + +export type TCreateExpenseRoute = typeof createExpenseRoute; diff --git a/src/modules/expenses/expense.test.ts b/src/modules/expenses/expense.test.ts new file mode 100644 index 0000000..5c4aee1 --- /dev/null +++ b/src/modules/expenses/expense.test.ts @@ -0,0 +1,303 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { testClient } from "hono/testing"; + +import { AuthRoles, SplitType } from "@/common/enums"; +import { createApp } from "@/common/lib/create-app.lib"; +import { AUTHORIZATION_ERROR_MESSAGE } from "@/common/utils/constants"; +import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util"; +import { createTestUser } from "@/common/utils/test.util"; +import env from "@/env"; + +import { createCategoryRepository } from "../categories/category.repository"; +import { expenseRouter } from "./expense.index"; + +if (env.NODE_ENV !== "test") { + throw new Error("NODE_ENV must be 'test'"); +} + +const expenseClient = testClient(createApp().route("/", expenseRouter)); + +describe("expenses", () => { + let testUser = { + id: "", + session: "", + }; + + let testUser2 = { + id: "", + session: "", + }; + + let adminUser = { + id: "", + session: "", + }; + + let testCategory = { + id: "", + }; + + let testCategory2 = { + id: "", + }; + + const expenseTestCommonFields = { + amount: 100, + currency: "USD", + splitType: SplitType.EVEN, + description: "expense description", + }; + + beforeAll(async () => { + testUser = await createTestUser({ + email: "testUser@sample.com", + password: "12345678", + role: AuthRoles.USER, + fullName: "Test User", + }); + + testUser2 = await createTestUser({ + email: "testUser2@sample.com", + password: "12345678", + role: AuthRoles.USER, + fullName: "Test User", + }); + + adminUser = await createTestUser({ + email: "adminUser@sample.com", + password: "12345678", + role: AuthRoles.ADMIN, + fullName: "Test Admin", + }); + + testCategory = await createCategoryRepository({ name: "Test category" }); + testCategory2 = await createCategoryRepository({ name: "Test category2" }, testUser2.id); + }); + + describe("POST /expenses", () => { + it("should create an expense as user", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + categoryId: testCategory.id, + }, + }, + { + headers: { + session: testUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.CREATED); + if (response.status === HTTPStatusCodes.CREATED) { + const json = await response.json(); + + expect(json.success).toBe(true); + expect(json.message).toBe("Expense created successfully"); + expect(json.data).toHaveProperty("amount", 100); + expect(json.data).toHaveProperty("currency", "USD"); + expect(json.data).toHaveProperty("splitType", SplitType.EVEN); + expect(json.data).toHaveProperty("categoryId", testCategory.id); + expect(json.data).toHaveProperty("payerId", testUser.id); + } + }); + + it("should create an expense as user with payerId", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + categoryId: testCategory.id, + payerId: testUser2.id, + }, + }, + { + headers: { + session: testUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.CREATED); + if (response.status === HTTPStatusCodes.CREATED) { + const json = await response.json(); + + expect(json.success).toBe(true); + expect(json.message).toBe("Expense created successfully"); + expect(json.data).toHaveProperty("amount", 100); + expect(json.data).toHaveProperty("currency", "USD"); + expect(json.data).toHaveProperty("splitType", SplitType.EVEN); + expect(json.data).toHaveProperty("categoryId", testCategory.id); + expect(json.data).toHaveProperty("payerId", testUser2.id); + } + }); + + it("should create an expense as admin", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + payerId: testUser.id, + categoryId: testCategory.id, + }, + }, + { + headers: { + session: adminUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.CREATED); + if (response.status === HTTPStatusCodes.CREATED) { + const json = await response.json(); + + expect(json.success).toBe(true); + expect(json.message).toBe("Expense created successfully"); + expect(json.data).toHaveProperty("amount", 100); + expect(json.data).toHaveProperty("currency", "USD"); + expect(json.data).toHaveProperty("splitType", SplitType.EVEN); + expect(json.data).toHaveProperty("creatorId", adminUser.id); + expect(json.data).toHaveProperty("categoryId", testCategory.id); + expect(json.data).toHaveProperty("payerId", testUser.id); + } + }); + + it("should return 400 when admin creates expense without providing payer id", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + }, + }, + { + headers: { + session: adminUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.BAD_REQUEST); + if (response.status === HTTPStatusCodes.BAD_REQUEST) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe("Missing payerId"); + } + }); + + it("should return 404 when payer not found", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + payerId: "invalid payer id", + }, + }, + { + headers: { + session: testUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.NOT_FOUND); + if (response.status === HTTPStatusCodes.NOT_FOUND) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe("Payer not found"); + } + }); + + it("should return 404 when category not found", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + categoryId: "invalid category id", + }, + }, + { + headers: { + session: testUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.NOT_FOUND); + if (response.status === HTTPStatusCodes.NOT_FOUND) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe("Category not found"); + } + }); + + it("should return 400 when category does not belongs to user", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + categoryId: testCategory2.id, + }, + }, + { + headers: { + session: testUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.BAD_REQUEST); + if (response.status === HTTPStatusCodes.BAD_REQUEST) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe("Category does not belong to the user or the specified payer"); + } + }); + + it("should return 400 when category does not belongs to payer", async () => { + const response = await expenseClient.expenses.$post( + { + json: { + ...expenseTestCommonFields, + payerId: testUser.id, + categoryId: testCategory2.id, + }, + }, + { + headers: { + session: adminUser.session, + }, + }, + ); + + expect(response.status).toBe(HTTPStatusCodes.BAD_REQUEST); + if (response.status === HTTPStatusCodes.BAD_REQUEST) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe("Category does not belong to the user or the specified payer"); + } + }); + + it("should return 401 when user is not logged in", async () => { + const response = await expenseClient.expenses.$post({ + json: { + ...expenseTestCommonFields, + }, + }); + + if (response.status === HTTPStatusCodes.UNAUTHORIZED) { + const json = await response.json(); + + expect(json.success).toBe(false); + expect(json.message).toBe(AUTHORIZATION_ERROR_MESSAGE); + } + }); + }); +});