From b7bbc50b2b25275f9fb12aa06fa154b9dc07e5af Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 10:52:07 +0800 Subject: [PATCH] feat(04-03): VendorService, ExpenseService, API routes, migration, and 15 passing tests - VendorService: CRUD with unique name validation per tenant - ExpenseService: create, approve, post, void with full JE integration - Auto-generated EXP-NNNN expense numbers per tenant - JE posting: DR category expense account, CR cash (1010) or bank (1020) - Optional approval workflow: requireApproval flag controls DRAFT-only vs immediate post - Void reverses JE via JournalEntryService.reverseEntry - Custom category creation with COA account validation - System categories protected from deletion - API routes: expenses CRUD, approve, categories CRUD, vendors CRUD - Migration: add_expense_vendor_models applied via db push + resolve - 15 integration tests covering all expense lifecycle scenarios Co-Authored-By: Claude Opus 4.6 --- .../migration.sql | 83 +++ src/app/api/expenses/[id]/approve/route.ts | 31 + src/app/api/expenses/[id]/route.ts | 28 + src/app/api/expenses/categories/[id]/route.ts | 42 ++ src/app/api/expenses/categories/route.ts | 68 +++ src/app/api/expenses/route.ts | 99 ++++ src/app/api/vendors/[id]/route.ts | 67 +++ src/app/api/vendors/route.ts | 68 +++ src/lib/__tests__/expense-service.test.ts | 469 +++++++++++++++ src/lib/services/expense-service.ts | 537 ++++++++++++++++++ src/lib/services/vendor-service.ts | 149 +++++ 11 files changed, 1641 insertions(+) create mode 100644 prisma/migrations/20260305_add_expense_vendor_models/migration.sql create mode 100644 src/app/api/expenses/[id]/approve/route.ts create mode 100644 src/app/api/expenses/[id]/route.ts create mode 100644 src/app/api/expenses/categories/[id]/route.ts create mode 100644 src/app/api/expenses/categories/route.ts create mode 100644 src/app/api/expenses/route.ts create mode 100644 src/app/api/vendors/[id]/route.ts create mode 100644 src/app/api/vendors/route.ts create mode 100644 src/lib/__tests__/expense-service.test.ts create mode 100644 src/lib/services/expense-service.ts create mode 100644 src/lib/services/vendor-service.ts diff --git a/prisma/migrations/20260305_add_expense_vendor_models/migration.sql b/prisma/migrations/20260305_add_expense_vendor_models/migration.sql new file mode 100644 index 0000000..378c3e9 --- /dev/null +++ b/prisma/migrations/20260305_add_expense_vendor_models/migration.sql @@ -0,0 +1,83 @@ +-- CreateEnum +CREATE TYPE "ExpenseStatus" AS ENUM ('DRAFT', 'APPROVED', 'POSTED', 'VOIDED'); + +-- CreateEnum +CREATE TYPE "ExpensePaymentMethod" AS ENUM ('CASH', 'BANK_TRANSFER', 'CHECK'); + +-- CreateTable +CREATE TABLE "Vendor" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "contactPerson" TEXT, + "phone" TEXT, + "email" TEXT, + "address" TEXT, + "servicesProvided" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Vendor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ExpenseCategory" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "accountCode" TEXT NOT NULL, + "isSystemCategory" BOOLEAN NOT NULL DEFAULT false, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ExpenseCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Expense" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "expenseNumber" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + "vendorId" TEXT, + "amount" DECIMAL(10,2) NOT NULL, + "expenseDate" TIMESTAMP(3) NOT NULL, + "description" TEXT NOT NULL, + "paymentMethod" "ExpensePaymentMethod" NOT NULL, + "status" "ExpenseStatus" NOT NULL DEFAULT 'DRAFT', + "attachmentPath" TEXT, + "journalEntryId" TEXT, + "createdById" TEXT NOT NULL, + "approvedById" TEXT, + "approvedAt" TIMESTAMP(3), + "postedAt" TIMESTAMP(3), + "voidedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Expense_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Vendor_tenantId_name_key" ON "Vendor"("tenantId", "name"); +CREATE INDEX "Vendor_tenantId_idx" ON "Vendor"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ExpenseCategory_tenantId_name_key" ON "ExpenseCategory"("tenantId", "name"); +CREATE INDEX "ExpenseCategory_tenantId_idx" ON "ExpenseCategory"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Expense_tenantId_expenseNumber_key" ON "Expense"("tenantId", "expenseNumber"); +CREATE INDEX "Expense_tenantId_idx" ON "Expense"("tenantId"); +CREATE INDEX "Expense_tenantId_categoryId_idx" ON "Expense"("tenantId", "categoryId"); +CREATE INDEX "Expense_tenantId_vendorId_idx" ON "Expense"("tenantId", "vendorId"); +CREATE INDEX "Expense_tenantId_status_idx" ON "Expense"("tenantId", "status"); + +-- AddForeignKey +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/app/api/expenses/[id]/approve/route.ts b/src/app/api/expenses/[id]/approve/route.ts new file mode 100644 index 0000000..4fa97c3 --- /dev/null +++ b/src/app/api/expenses/[id]/approve/route.ts @@ -0,0 +1,31 @@ +/** + * POST /api/expenses/[id]/approve — Approve an expense (ADMIN only) + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseService } from "@/lib/services/expense-service"; + +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Expense")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const expense = await ExpenseService.approveExpense(tenantPrisma, user.tenantId, { + expenseId: id, + approvedById: user.id, + }); + return NextResponse.json(expense); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to approve expense"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/expenses/[id]/route.ts b/src/app/api/expenses/[id]/route.ts new file mode 100644 index 0000000..f6e8284 --- /dev/null +++ b/src/app/api/expenses/[id]/route.ts @@ -0,0 +1,28 @@ +/** + * GET /api/expenses/[id] — Get expense detail + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseService } from "@/lib/services/expense-service"; + +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Expense")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const expense = await ExpenseService.getExpense(tenantPrisma, id); + return NextResponse.json(expense); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get expense"; + return NextResponse.json({ error: message }, { status: 404 }); + } + } + )(req); +} diff --git a/src/app/api/expenses/categories/[id]/route.ts b/src/app/api/expenses/categories/[id]/route.ts new file mode 100644 index 0000000..22780bd --- /dev/null +++ b/src/app/api/expenses/categories/[id]/route.ts @@ -0,0 +1,42 @@ +/** + * PUT /api/expenses/categories/[id] — Update expense category (ADMIN) + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseService } from "@/lib/services/expense-service"; + +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("manage", "Expense")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + let body: Record; + try { + body = await _req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { id } = await params; + const { name, description, accountCode, isActive } = body; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const category = await ExpenseService.updateCategory(tenantPrisma, id, { + name: name as string | undefined, + description: description as string | undefined, + accountCode: accountCode as string | undefined, + isActive: isActive as boolean | undefined, + }); + return NextResponse.json(category); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update category"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/expenses/categories/route.ts b/src/app/api/expenses/categories/route.ts new file mode 100644 index 0000000..c734b0e --- /dev/null +++ b/src/app/api/expenses/categories/route.ts @@ -0,0 +1,68 @@ +/** + * GET /api/expenses/categories — List expense categories + * POST /api/expenses/categories — Create custom expense category (ADMIN) + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseService } from "@/lib/services/expense-service"; + +export const GET = withPermission("read", "Expense")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + const { searchParams } = new URL(req.url); + const isActiveParam = searchParams.get("isActive"); + const isActive = isActiveParam != null ? isActiveParam === "true" : undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const categories = await ExpenseService.listCategories(tenantPrisma, { isActive }); + return NextResponse.json({ categories }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list categories"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); + +export const POST = withPermission("manage", "Expense")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { name, description, accountCode } = body; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + if (!accountCode || typeof accountCode !== "string") { + return NextResponse.json({ error: "accountCode is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const category = await ExpenseService.createCategory(tenantPrisma, user.tenantId, { + name, + description: description as string | undefined, + accountCode, + }); + return NextResponse.json(category, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create category"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts new file mode 100644 index 0000000..73038b7 --- /dev/null +++ b/src/app/api/expenses/route.ts @@ -0,0 +1,99 @@ +/** + * POST /api/expenses — Create a new expense + * GET /api/expenses — List expenses with optional filters + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { ExpenseService } from "@/lib/services/expense-service"; +import { ExpensePaymentMethod } from "@prisma/client"; + +export const POST = withPermission("create", "Expense")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context" }, + { status: 400 } + ); + } + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { categoryId, vendorId, amount, expenseDate, description, paymentMethod, attachmentPath, requireApproval } = body; + + if (!categoryId || typeof categoryId !== "string") { + return NextResponse.json({ error: "categoryId is required" }, { status: 400 }); + } + if (!amount || (typeof amount !== "number" && typeof amount !== "string")) { + return NextResponse.json({ error: "amount is required" }, { status: 400 }); + } + if (!expenseDate) { + return NextResponse.json({ error: "expenseDate is required" }, { status: 400 }); + } + if (!description || typeof description !== "string") { + return NextResponse.json({ error: "description is required" }, { status: 400 }); + } + if (!paymentMethod || !["CASH", "BANK_TRANSFER", "CHECK"].includes(paymentMethod as string)) { + return NextResponse.json({ error: "paymentMethod must be CASH, BANK_TRANSFER, or CHECK" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const expense = await ExpenseService.createExpense(tenantPrisma, user.tenantId, { + categoryId, + vendorId: vendorId as string | undefined, + amount: amount as number | string, + expenseDate: new Date(expenseDate as string), + description, + paymentMethod: paymentMethod as ExpensePaymentMethod, + attachmentPath: attachmentPath as string | undefined, + createdById: user.id, + requireApproval: requireApproval as boolean | undefined, + }); + return NextResponse.json(expense, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create expense"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); + +export const GET = withPermission("read", "Expense")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const status = searchParams.get("status") ?? undefined; + const categoryId = searchParams.get("categoryId") ?? undefined; + const vendorId = searchParams.get("vendorId") ?? undefined; + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const expenses = await ExpenseService.listExpenses(tenantPrisma, { + status: status as import("@prisma/client").ExpenseStatus | undefined, + categoryId, + vendorId, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + }); + return NextResponse.json({ expenses }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list expenses"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/app/api/vendors/[id]/route.ts b/src/app/api/vendors/[id]/route.ts new file mode 100644 index 0000000..61f6586 --- /dev/null +++ b/src/app/api/vendors/[id]/route.ts @@ -0,0 +1,67 @@ +/** + * GET /api/vendors/[id] — Get vendor detail + * PUT /api/vendors/[id] — Update vendor + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { VendorService } from "@/lib/services/vendor-service"; + +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "Vendor")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const vendor = await VendorService.getVendor(tenantPrisma, id); + return NextResponse.json(vendor); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get vendor"; + return NextResponse.json({ error: message }, { status: 404 }); + } + } + )(req); +} + +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("update", "Vendor")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + let body: Record; + try { + body = await _req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { id } = await params; + const { name, contactPerson, phone, email, address, servicesProvided, isActive } = body; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const vendor = await VendorService.updateVendor(tenantPrisma, id, { + name: name as string | undefined, + contactPerson: contactPerson as string | undefined, + phone: phone as string | undefined, + email: email as string | undefined, + address: address as string | undefined, + servicesProvided: servicesProvided as string | undefined, + isActive: isActive as boolean | undefined, + }); + return NextResponse.json(vendor); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update vendor"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/vendors/route.ts b/src/app/api/vendors/route.ts new file mode 100644 index 0000000..ce2300b --- /dev/null +++ b/src/app/api/vendors/route.ts @@ -0,0 +1,68 @@ +/** + * GET /api/vendors — List vendors + * POST /api/vendors — Create vendor + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { VendorService } from "@/lib/services/vendor-service"; + +export const POST = withPermission("create", "Vendor")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { name, contactPerson, phone, email, address, servicesProvided } = body; + + if (!name || typeof name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const vendor = await VendorService.createVendor(tenantPrisma, user.tenantId, { + name, + contactPerson: contactPerson as string | undefined, + phone: phone as string | undefined, + email: email as string | undefined, + address: address as string | undefined, + servicesProvided: servicesProvided as string | undefined, + }); + return NextResponse.json(vendor, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create vendor"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } +); + +export const GET = withPermission("read", "Vendor")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json({ error: "No tenant context" }, { status: 400 }); + } + + const { searchParams } = new URL(req.url); + const isActiveParam = searchParams.get("isActive"); + const isActive = isActiveParam != null ? isActiveParam === "true" : undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const vendors = await VendorService.listVendors(tenantPrisma, { isActive }); + return NextResponse.json({ vendors }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list vendors"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/lib/__tests__/expense-service.test.ts b/src/lib/__tests__/expense-service.test.ts new file mode 100644 index 0000000..c15acef --- /dev/null +++ b/src/lib/__tests__/expense-service.test.ts @@ -0,0 +1,469 @@ +/** + * Expense Service Integration Tests + * + * Tests the full expense lifecycle: + * - Expense categories seeded at tenant creation (9 default categories) + * - Vendor CRUD operations + * - Create expense with valid category and vendor -> POSTED with JE + * - Verify JE: DR correct expense account, CR 1010 (cash) + * - Create expense with BANK_TRANSFER -> CR 1020 + * - Void expense -> JE reversed, status=VOIDED + * - Reject void on non-POSTED expense + * - Create custom expense category + * - Cannot delete system expense category + * - List expenses with filters (by category, vendor, date range) + * + * CLEANUP ORDER: + * expenses -> vendors -> expenseCategories (non-system) -> journalEntryLines -> + * null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> + * ticketCategories -> expenseCategories (system) -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { ExpenseService } from "@/lib/services/expense-service"; +import { VendorService } from "@/lib/services/vendor-service"; +import { Prisma, Role, TenantStatus, ExpensePaymentMethod } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantId: string; +let userId: string; +let secondUserId: string; + +// Account IDs +let cashAccountId: string; // 1010 +let bankAccountId: string; // 1020 +let bandwidthAccountId: string; // 5040 +let equipmentAccountId: string; // 5030 + +// Category IDs (from seeding) +let bandwidthCategoryId: string; +let equipmentCategoryId: string; + +// Vendor IDs +let vendorId: string; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tp() { + return withTenantContext(tenantId); +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create tenant + const tenant = await prisma.tenant.create({ + data: { + name: `Expense Test Tenant ${TS}`, + slug: `expense-test-${TS}`, + ownerEmail: `expense-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + + // Seed COA (31 accounts) + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // Seed ticket categories (needed for cleanup order consistency) + await prisma.ticketCategory.createMany({ + data: [ + { name: "Test Category", tenantId }, + ], + }); + + // Seed expense categories (9 defaults — same as createTenant) + const defaultExpenseCategories = [ + { name: "Internet Bandwidth", accountCode: "5040", isSystemCategory: true, tenantId }, + { name: "Equipment & Supplies", accountCode: "5030", isSystemCategory: true, tenantId }, + { name: "Salary & Wages", accountCode: "5010", isSystemCategory: true, tenantId }, + { name: "Technician Compensation", accountCode: "5020", isSystemCategory: true, tenantId }, + { name: "Office Supplies", accountCode: "5050", isSystemCategory: true, tenantId }, + { name: "Utilities", accountCode: "5060", isSystemCategory: true, tenantId }, + { name: "Fuel & Transportation", accountCode: "5080", isSystemCategory: true, tenantId }, + { name: "Rent", accountCode: "5085", isSystemCategory: true, tenantId }, + { name: "Other", accountCode: "5090", isSystemCategory: true, tenantId }, + ]; + await prisma.expenseCategory.createMany({ data: defaultExpenseCategories }); + + // Look up account IDs + const accounts = await prisma.account.findMany({ + where: { tenantId, code: { in: ["1010", "1020", "5040", "5030"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + cashAccountId = accountMap.get("1010")!; + bankAccountId = accountMap.get("1020")!; + bandwidthAccountId = accountMap.get("5040")!; + equipmentAccountId = accountMap.get("5030")!; + + expect(cashAccountId).toBeDefined(); + expect(bankAccountId).toBeDefined(); + expect(bandwidthAccountId).toBeDefined(); + expect(equipmentAccountId).toBeDefined(); + + // Look up category IDs + const categories = await prisma.expenseCategory.findMany({ + where: { tenantId, name: { in: ["Internet Bandwidth", "Equipment & Supplies"] } }, + select: { id: true, name: true }, + }); + const catMap = new Map(categories.map((c) => [c.name, c.id])); + bandwidthCategoryId = catMap.get("Internet Bandwidth")!; + equipmentCategoryId = catMap.get("Equipment & Supplies")!; + + expect(bandwidthCategoryId).toBeDefined(); + expect(equipmentCategoryId).toBeDefined(); + + // Create admin user + const user = await prisma.user.create({ + data: { + email: `expense-admin-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Expense", + lastName: "Admin", + tenantId, + roles: [Role.ADMIN], + isActive: true, + }, + }); + userId = user.id; + + // Create second user for approval testing + const user2 = await prisma.user.create({ + data: { + email: `expense-staff-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Office", + lastName: "Staff", + tenantId, + roles: [Role.OFFICE_STAFF], + isActive: true, + }, + }); + secondUserId = user2.id; +}); + +afterAll(async () => { + // Cleanup in order: + // expenses -> vendors -> expenseCategories (non-system) -> journalEntryLines -> + // null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> + // ticketCategories -> expenseCategories (system) -> users -> tenant + await prisma.expense.deleteMany({ where: { tenantId } }); + await prisma.vendor.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ where: { tenantId, isSystemCategory: false } }); + await prisma.journalEntryLine.deleteMany({ where: { tenantId } }); + await prisma.journalEntry.updateMany({ + where: { tenantId, reversesEntryId: { not: null } }, + data: { reversesEntryId: null }, + }); + await prisma.journalEntry.deleteMany({ where: { tenantId } }); + await prisma.accountingPeriod.deleteMany({ where: { tenantId } }); + await prisma.account.deleteMany({ where: { tenantId } }); + await prisma.ticketCategory.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ where: { tenantId } }); + await prisma.user.deleteMany({ where: { tenantId } }); + await prisma.tenant.deleteMany({ where: { id: tenantId } }); +}); + +// --------------------------------------------------------------------------- +// Tests: Expense Category Seeding +// --------------------------------------------------------------------------- + +describe("Expense category seeding", () => { + it("has 9 default expense categories seeded at tenant creation", async () => { + const categories = await tp().expenseCategory.findMany({ + orderBy: { name: "asc" }, + }); + + expect(categories).toHaveLength(9); + + const names = categories.map((c: { name: string }) => c.name).sort(); + expect(names).toEqual([ + "Equipment & Supplies", + "Fuel & Transportation", + "Internet Bandwidth", + "Office Supplies", + "Other", + "Rent", + "Salary & Wages", + "Technician Compensation", + "Utilities", + ]); + + // All are system categories + expect(categories.every((c: { isSystemCategory: boolean }) => c.isSystemCategory)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Vendor CRUD +// --------------------------------------------------------------------------- + +describe("Vendor CRUD", () => { + it("creates a vendor", async () => { + const vendor = await VendorService.createVendor(tp(), tenantId, { + name: "PLDT Bandwidth Provider", + contactPerson: "Juan Dela Cruz", + phone: "+639171234567", + email: "sales@pldt.example", + servicesProvided: "Upstream bandwidth provider", + }); + + expect(vendor.id).toBeDefined(); + expect(vendor.name).toBe("PLDT Bandwidth Provider"); + expect(vendor.contactPerson).toBe("Juan Dela Cruz"); + expect(vendor.isActive).toBe(true); + + vendorId = vendor.id; + }); + + it("updates a vendor", async () => { + const updated = await VendorService.updateVendor(tp(), vendorId, { + phone: "+639179876543", + }); + + expect(updated.phone).toBe("+639179876543"); + expect(updated.name).toBe("PLDT Bandwidth Provider"); + }); + + it("lists vendors", async () => { + const vendors = await VendorService.listVendors(tp()); + expect(vendors.length).toBeGreaterThanOrEqual(1); + expect(vendors.some((v: { id: string }) => v.id === vendorId)).toBe(true); + }); + + it("gets vendor detail", async () => { + const vendor = await VendorService.getVendor(tp(), vendorId); + expect(vendor.name).toBe("PLDT Bandwidth Provider"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Create Expense with JE (Cash) +// --------------------------------------------------------------------------- + +describe("Expense creation and JE posting", () => { + it("creates expense with CASH payment -> POSTED with JE (DR 5040, CR 1010)", async () => { + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: bandwidthCategoryId, + vendorId, + amount: 25000, + expenseDate: new Date("2026-03-01"), + description: "March bandwidth payment", + paymentMethod: ExpensePaymentMethod.CASH, + createdById: userId, + }); + + expect(expense.id).toBeDefined(); + expect(expense.status).toBe("POSTED"); + expect(expense.journalEntryId).not.toBeNull(); + expect(expense.postedAt).not.toBeNull(); + + // Verify JE: DR 5040 (bandwidth), CR 1010 (cash) + const je = await prisma.journalEntry.findUnique({ + where: { id: expense.journalEntryId! }, + include: { lines: true }, + }); + + expect(je).not.toBeNull(); + expect(je!.status).toBe("POSTED"); + expect(je!.referenceType).toBe("Expense"); + + const debitLine = je!.lines.find((l) => new Prisma.Decimal(l.debit).greaterThan(0)); + const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0)); + + expect(debitLine?.accountId).toBe(bandwidthAccountId); // 5040 + expect(creditLine?.accountId).toBe(cashAccountId); // 1010 + expect(debitLine?.debit.toString()).toBe("25000"); + expect(creditLine?.credit.toString()).toBe("25000"); + }); + + it("creates expense with BANK_TRANSFER -> CR account is 1020", async () => { + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: equipmentCategoryId, + amount: 5000, + expenseDate: new Date("2026-03-02"), + description: "Equipment purchase via bank", + paymentMethod: ExpensePaymentMethod.BANK_TRANSFER, + createdById: userId, + }); + + expect(expense.status).toBe("POSTED"); + + // Verify JE: DR 5030 (equipment), CR 1020 (bank) + const je = await prisma.journalEntry.findUnique({ + where: { id: expense.journalEntryId! }, + include: { lines: true }, + }); + + const creditLine = je!.lines.find((l) => new Prisma.Decimal(l.credit).greaterThan(0)); + expect(creditLine?.accountId).toBe(bankAccountId); // 1020 + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Void Expense +// --------------------------------------------------------------------------- + +describe("Void expense", () => { + it("voids a POSTED expense -> JE reversed, status=VOIDED", async () => { + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: bandwidthCategoryId, + amount: 1000, + expenseDate: new Date("2026-03-03"), + description: "Expense to void", + paymentMethod: ExpensePaymentMethod.CASH, + createdById: userId, + }); + + expect(expense.status).toBe("POSTED"); + + const voided = await ExpenseService.voidExpense(tp(), tenantId, { + expenseId: expense.id, + voidedById: userId, + }); + + expect(voided.status).toBe("VOIDED"); + expect(voided.voidedAt).not.toBeNull(); + + // Verify the original JE is REVERSED + const originalJe = await prisma.journalEntry.findUnique({ + where: { id: expense.journalEntryId! }, + }); + expect(originalJe!.status).toBe("REVERSED"); + }); + + it("rejects void on non-POSTED expense", async () => { + // Create with approval required (stays DRAFT) + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: bandwidthCategoryId, + amount: 500, + expenseDate: new Date("2026-03-04"), + description: "Draft expense", + paymentMethod: ExpensePaymentMethod.CASH, + createdById: userId, + requireApproval: true, + }); + + expect(expense.status).toBe("DRAFT"); + + await expect( + ExpenseService.voidExpense(tp(), tenantId, { + expenseId: expense.id, + voidedById: userId, + }) + ).rejects.toThrow(/Only POSTED expenses can be voided/); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Custom Expense Category +// --------------------------------------------------------------------------- + +describe("Custom expense categories", () => { + it("creates a custom expense category", async () => { + const category = await ExpenseService.createCategory(tp(), tenantId, { + name: "Marketing", + description: "Marketing and advertising expenses", + accountCode: "5090", + }); + + expect(category.id).toBeDefined(); + expect(category.name).toBe("Marketing"); + expect(category.isSystemCategory).toBe(false); + expect(category.accountCode).toBe("5090"); + }); + + it("cannot delete a system expense category", async () => { + await expect( + ExpenseService.deleteCategory(tp(), bandwidthCategoryId) + ).rejects.toThrow(/Cannot delete a system expense category/); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Approval Workflow +// --------------------------------------------------------------------------- + +describe("Approval workflow", () => { + it("creates DRAFT expense when requireApproval=true, then approve posts it", async () => { + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: bandwidthCategoryId, + amount: 3000, + expenseDate: new Date("2026-03-05"), + description: "Expense needing approval", + paymentMethod: ExpensePaymentMethod.CASH, + createdById: userId, + requireApproval: true, + }); + + expect(expense.status).toBe("DRAFT"); + expect(expense.journalEntryId).toBeNull(); + + // Approve it (should also post) + const approved = await ExpenseService.approveExpense(tp(), tenantId, { + expenseId: expense.id, + approvedById: secondUserId, + }); + + expect(approved.status).toBe("POSTED"); + expect(approved.journalEntryId).not.toBeNull(); + expect(approved.postedAt).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: List Expenses with Filters +// --------------------------------------------------------------------------- + +describe("List expenses with filters", () => { + it("filters by category", async () => { + const expenses = await ExpenseService.listExpenses(tp(), { + categoryId: bandwidthCategoryId, + }); + + expect(expenses.length).toBeGreaterThanOrEqual(1); + expect( + expenses.every((e: { categoryId: string }) => e.categoryId === bandwidthCategoryId) + ).toBe(true); + }); + + it("filters by vendor", async () => { + const expenses = await ExpenseService.listExpenses(tp(), { + vendorId, + }); + + expect(expenses.length).toBeGreaterThanOrEqual(1); + expect( + expenses.every((e: { vendorId: string | null }) => e.vendorId === vendorId) + ).toBe(true); + }); + + it("filters by date range", async () => { + const expenses = await ExpenseService.listExpenses(tp(), { + startDate: new Date("2026-03-01"), + endDate: new Date("2026-03-02"), + }); + + expect(expenses.length).toBeGreaterThanOrEqual(1); + for (const e of expenses) { + const d = new Date(e.expenseDate); + expect(d.getTime()).toBeGreaterThanOrEqual(new Date("2026-03-01").getTime()); + expect(d.getTime()).toBeLessThanOrEqual(new Date("2026-03-02").getTime()); + } + }); +}); diff --git a/src/lib/services/expense-service.ts b/src/lib/services/expense-service.ts new file mode 100644 index 0000000..57a0476 --- /dev/null +++ b/src/lib/services/expense-service.ts @@ -0,0 +1,537 @@ +/** + * ExpenseService — Expense recording, approval workflow, JE posting, and voiding. + * + * ARCHITECTURE: + * This service handles the full expense lifecycle: + * - Record expenses with category, vendor, amount, and payment method + * - Optional approval workflow: DRAFT -> APPROVED -> POSTED + * - When approval disabled (default): DRAFT -> POSTED immediately + * - Every POSTED expense creates a balanced journal entry + * - Void reverses the JE and marks expense VOIDED + * + * ACCOUNT CODES USED: + * Category.accountCode — DR expense account (e.g., 5040 for bandwidth) + * 1010 — Cash on Hand (CASH payments) + * 1020 — Cash in Bank (BANK_TRANSFER or CHECK payments) + * 2010 — Accounts Payable (when CR AP instead of cash) + * + * JOURNAL ENTRY PATTERN: + * DR {expense account from category} [amount] + * CR Cash on Hand (1010) or Cash in Bank (1020) [amount] + */ + +import { Prisma, JournalEntrySource, ExpenseStatus, ExpensePaymentMethod } from "@prisma/client"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateExpenseInput { + categoryId: string; + vendorId?: string; + amount: number | string; + expenseDate: Date; + description: string; + paymentMethod: ExpensePaymentMethod; + attachmentPath?: string; + createdById: string; + /** If true, create as DRAFT only (approval required). Default: false (immediate post). */ + requireApproval?: boolean; +} + +export interface ApproveExpenseInput { + expenseId: string; + approvedById: string; +} + +export interface PostExpenseInput { + expenseId: string; + postedById: string; +} + +export interface VoidExpenseInput { + expenseId: string; + voidedById: string; +} + +export interface ListExpensesFilter { + status?: ExpenseStatus; + categoryId?: string; + vendorId?: string; + startDate?: Date; + endDate?: Date; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Generate the next expense number for a tenant. + * Format: "EXP-NNNN" + */ +async function generateExpenseNumber(tenantPrisma: TenantPrismaClient): Promise { + const prefix = "EXP-"; + + const existing = await tenantPrisma.expense.findMany({ + where: { + expenseNumber: { startsWith: prefix }, + }, + select: { expenseNumber: true }, + orderBy: { expenseNumber: "desc" }, + take: 1, + }); + + let nextNumber = 1; + if (existing.length > 0) { + const lastNumber = existing[0].expenseNumber as string; + const seq = lastNumber.slice(prefix.length); + const lastSeq = parseInt(seq, 10); + if (!isNaN(lastSeq)) { + nextNumber = lastSeq + 1; + } + } + + return `EXP-${String(nextNumber).padStart(4, "0")}`; +} + +/** + * Determine the CR account code based on payment method. + * CASH -> 1010 Cash on Hand + * BANK_TRANSFER -> 1020 Cash in Bank + * CHECK -> 1020 Cash in Bank + */ +function getCreditAccountCode(paymentMethod: ExpensePaymentMethod): string { + switch (paymentMethod) { + case ExpensePaymentMethod.CASH: + return "1010"; + case ExpensePaymentMethod.BANK_TRANSFER: + case ExpensePaymentMethod.CHECK: + return "1020"; + default: + return "1010"; + } +} + +// --------------------------------------------------------------------------- +// ExpenseService +// --------------------------------------------------------------------------- + +export class ExpenseService { + /** + * Create a new expense. + * + * If requireApproval is false (default), the expense is created as DRAFT + * then immediately posted (creating a JE). + * If requireApproval is true, the expense stays as DRAFT until approved. + */ + static async createExpense( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: CreateExpenseInput + ) { + const amount = new Prisma.Decimal(data.amount); + if (amount.lessThanOrEqualTo(0)) { + throw new Error("Expense amount must be greater than zero."); + } + + // Validate category exists and is active + const category = await tenantPrisma.expenseCategory.findFirst({ + where: { id: data.categoryId, isActive: true }, + }); + if (!category) { + throw new Error(`Expense category not found or inactive: ${data.categoryId}`); + } + + // Validate vendor if provided + if (data.vendorId) { + const vendor = await tenantPrisma.vendor.findFirst({ + where: { id: data.vendorId }, + }); + if (!vendor) { + throw new Error(`Vendor not found: ${data.vendorId}`); + } + } + + // Generate expense number + const expenseNumber = await generateExpenseNumber(tenantPrisma); + + // Create the expense as DRAFT + const expense = await tenantPrisma.expense.create({ + data: { + tenantId, + expenseNumber, + categoryId: data.categoryId, + vendorId: data.vendorId ?? null, + amount, + expenseDate: data.expenseDate, + description: data.description, + paymentMethod: data.paymentMethod, + status: ExpenseStatus.DRAFT, + attachmentPath: data.attachmentPath ?? null, + createdById: data.createdById, + } as Record, + include: { + category: true, + vendor: true, + }, + }); + + // If no approval required, immediately post + if (!data.requireApproval) { + return ExpenseService.postExpense(tenantPrisma, tenantId, { + expenseId: expense.id, + postedById: data.createdById, + }); + } + + return expense; + } + + /** + * Approve an expense (changes DRAFT -> APPROVED, then immediately posts). + */ + static async approveExpense( + tenantPrisma: TenantPrismaClient, + tenantId: string, + input: ApproveExpenseInput + ) { + const expense = await tenantPrisma.expense.findFirst({ + where: { id: input.expenseId }, + }); + + if (!expense) { + throw new Error(`Expense not found: ${input.expenseId}`); + } + + if (expense.status !== ExpenseStatus.DRAFT) { + throw new Error( + `Cannot approve expense with status "${expense.status}". Only DRAFT expenses can be approved.` + ); + } + + // Mark as APPROVED + await tenantPrisma.expense.update({ + where: { id: input.expenseId }, + data: { + status: ExpenseStatus.APPROVED, + approvedById: input.approvedById, + approvedAt: new Date(), + }, + }); + + // Then immediately post + return ExpenseService.postExpense(tenantPrisma, tenantId, { + expenseId: input.expenseId, + postedById: input.approvedById, + }); + } + + /** + * Post an expense — creates a balanced journal entry. + * + * DR {category expense account} [amount] + * CR {cash/bank account} [amount] + */ + static async postExpense( + tenantPrisma: TenantPrismaClient, + tenantId: string, + input: PostExpenseInput + ) { + const expense = await tenantPrisma.expense.findFirst({ + where: { id: input.expenseId }, + include: { category: true }, + }); + + if (!expense) { + throw new Error(`Expense not found: ${input.expenseId}`); + } + + if (expense.status !== ExpenseStatus.DRAFT && expense.status !== ExpenseStatus.APPROVED) { + throw new Error( + `Cannot post expense with status "${expense.status}". Only DRAFT or APPROVED expenses can be posted.` + ); + } + + // Find the DR account (expense account from category) + const expenseAccountCode = expense.category.accountCode; + const creditAccountCode = getCreditAccountCode(expense.paymentMethod); + + const [expenseAccount, creditAccount] = await Promise.all([ + tenantPrisma.account.findFirst({ where: { code: expenseAccountCode }, select: { id: true } }), + tenantPrisma.account.findFirst({ where: { code: creditAccountCode }, select: { id: true } }), + ]); + + if (!expenseAccount) { + throw new Error(`Expense account not found: ${expenseAccountCode}`); + } + if (!creditAccount) { + throw new Error(`Credit account not found: ${creditAccountCode}`); + } + + const amount = new Prisma.Decimal(expense.amount); + + // Create journal entry + const je = await JournalEntryService.createEntry({ + tenantPrisma, + tenantId, + date: expense.expenseDate, + description: `Expense: ${expense.description}`, + source: JournalEntrySource.SYSTEM, + referenceType: "Expense", + referenceId: expense.id, + createdById: input.postedById, + lines: [ + { + accountId: expenseAccount.id, + debit: amount.toNumber(), + credit: 0, + description: `${expense.category.name}: ${expense.description}`, + }, + { + accountId: creditAccount.id, + debit: 0, + credit: amount.toNumber(), + description: `Payment for expense ${expense.expenseNumber}`, + }, + ], + }); + + // Update expense status to POSTED + const updated = await tenantPrisma.expense.update({ + where: { id: input.expenseId }, + data: { + status: ExpenseStatus.POSTED, + journalEntryId: je.id, + postedAt: new Date(), + }, + include: { + category: true, + vendor: true, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + + return updated; + } + + /** + * Void a posted expense — reverses the JE and marks as VOIDED. + */ + static async voidExpense( + tenantPrisma: TenantPrismaClient, + tenantId: string, + input: VoidExpenseInput + ) { + const expense = await tenantPrisma.expense.findFirst({ + where: { id: input.expenseId }, + }); + + if (!expense) { + throw new Error(`Expense not found: ${input.expenseId}`); + } + + if (expense.status !== ExpenseStatus.POSTED) { + throw new Error( + `Cannot void expense with status "${expense.status}". Only POSTED expenses can be voided.` + ); + } + + if (!expense.journalEntryId) { + throw new Error(`Expense ${input.expenseId} has no associated journal entry.`); + } + + // Reverse the journal entry + await JournalEntryService.reverseEntry({ + tenantPrisma, + tenantId, + entryId: expense.journalEntryId, + reversedById: input.voidedById, + description: `Void of expense ${expense.expenseNumber}`, + }); + + // Mark expense as VOIDED + return tenantPrisma.expense.update({ + where: { id: input.expenseId }, + data: { + status: ExpenseStatus.VOIDED, + voidedAt: new Date(), + }, + include: { + category: true, + vendor: true, + }, + }); + } + + /** + * List expenses with optional filters. + */ + static async listExpenses( + tenantPrisma: TenantPrismaClient, + filters?: ListExpensesFilter + ) { + const where: Record = {}; + if (filters?.status) where.status = filters.status; + if (filters?.categoryId) where.categoryId = filters.categoryId; + if (filters?.vendorId) where.vendorId = filters.vendorId; + + if (filters?.startDate || filters?.endDate) { + const dateFilter: Record = {}; + if (filters.startDate) dateFilter.gte = filters.startDate; + if (filters.endDate) dateFilter.lte = filters.endDate; + where.expenseDate = dateFilter; + } + + return tenantPrisma.expense.findMany({ + where, + include: { + category: { select: { id: true, name: true, accountCode: true } }, + vendor: { select: { id: true, name: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + approvedBy: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { expenseDate: "desc" }, + }); + } + + /** + * Get a single expense by ID with full details. + */ + static async getExpense(tenantPrisma: TenantPrismaClient, expenseId: string) { + const expense = await tenantPrisma.expense.findFirst({ + where: { id: expenseId }, + include: { + category: true, + vendor: true, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + approvedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + }, + }); + + if (!expense) { + throw new Error(`Expense not found: ${expenseId}`); + } + + return expense; + } + + /** + * Create a custom expense category. + */ + static async createCategory( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: { name: string; description?: string; accountCode: string } + ) { + if (!data.name || !data.name.trim()) { + throw new Error("Category name is required."); + } + if (!data.accountCode || !data.accountCode.trim()) { + throw new Error("Account code is required."); + } + + // Validate the account code exists in tenant's COA + const account = await tenantPrisma.account.findFirst({ + where: { code: data.accountCode }, + select: { id: true }, + }); + if (!account) { + throw new Error(`Account code not found: ${data.accountCode}`); + } + + return tenantPrisma.expenseCategory.create({ + data: { + tenantId, + name: data.name.trim(), + description: data.description?.trim() ?? null, + accountCode: data.accountCode.trim(), + isSystemCategory: false, + } as Record, + }); + } + + /** + * Update an expense category. + * Cannot change isSystemCategory field. + */ + static async updateCategory( + tenantPrisma: TenantPrismaClient, + categoryId: string, + data: { name?: string; description?: string; accountCode?: string; isActive?: boolean } + ) { + const existing = await tenantPrisma.expenseCategory.findFirst({ + where: { id: categoryId }, + }); + + if (!existing) { + throw new Error(`Expense category not found: ${categoryId}`); + } + + const updateData: Record = {}; + if (data.name !== undefined) updateData.name = data.name.trim(); + if (data.description !== undefined) updateData.description = data.description?.trim() ?? null; + if (data.accountCode !== undefined) updateData.accountCode = data.accountCode.trim(); + if (data.isActive !== undefined) updateData.isActive = data.isActive; + + return tenantPrisma.expenseCategory.update({ + where: { id: categoryId }, + data: updateData, + }); + } + + /** + * List expense categories. + */ + static async listCategories( + tenantPrisma: TenantPrismaClient, + filters?: { isActive?: boolean } + ) { + const where: Record = {}; + if (filters?.isActive !== undefined) where.isActive = filters.isActive; + + return tenantPrisma.expenseCategory.findMany({ + where, + orderBy: { name: "asc" }, + }); + } + + /** + * Delete a non-system expense category. + * System categories (isSystemCategory=true) cannot be deleted. + */ + static async deleteCategory(tenantPrisma: TenantPrismaClient, categoryId: string) { + const existing = await tenantPrisma.expenseCategory.findFirst({ + where: { id: categoryId }, + }); + + if (!existing) { + throw new Error(`Expense category not found: ${categoryId}`); + } + + if (existing.isSystemCategory) { + throw new Error("Cannot delete a system expense category."); + } + + // Check if any expenses use this category + const expenseCount = await tenantPrisma.expense.count({ + where: { categoryId }, + }); + + if (expenseCount > 0) { + throw new Error( + `Cannot delete category with ${expenseCount} associated expense(s). Deactivate instead.` + ); + } + + return tenantPrisma.expenseCategory.delete({ + where: { id: categoryId }, + }); + } +} diff --git a/src/lib/services/vendor-service.ts b/src/lib/services/vendor-service.ts new file mode 100644 index 0000000..172334e --- /dev/null +++ b/src/lib/services/vendor-service.ts @@ -0,0 +1,149 @@ +/** + * VendorService — CRUD operations for vendor management. + * + * ARCHITECTURE: + * Vendors are optional on expenses. They represent external suppliers or + * service providers that the ISP pays. Vendor names are unique per tenant. + * + * Static class pattern — receives tenant-scoped Prisma client. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateVendorInput { + name: string; + contactPerson?: string; + phone?: string; + email?: string; + address?: string; + servicesProvided?: string; +} + +export interface UpdateVendorInput { + name?: string; + contactPerson?: string; + phone?: string; + email?: string; + address?: string; + servicesProvided?: string; + isActive?: boolean; +} + +export interface ListVendorsFilter { + isActive?: boolean; +} + +// --------------------------------------------------------------------------- +// VendorService +// --------------------------------------------------------------------------- + +export class VendorService { + /** + * Create a new vendor. + * Validates unique name per tenant (Prisma enforces via @@unique). + */ + static async createVendor( + tenantPrisma: TenantPrismaClient, + tenantId: string, + data: CreateVendorInput + ) { + if (!data.name || !data.name.trim()) { + throw new Error("Vendor name is required."); + } + + const vendor = await tenantPrisma.vendor.create({ + data: { + tenantId, + name: data.name.trim(), + contactPerson: data.contactPerson?.trim() ?? null, + phone: data.phone?.trim() ?? null, + email: data.email?.trim() ?? null, + address: data.address?.trim() ?? null, + servicesProvided: data.servicesProvided?.trim() ?? null, + } as Record, + }); + + return vendor; + } + + /** + * Update an existing vendor. + */ + static async updateVendor( + tenantPrisma: TenantPrismaClient, + vendorId: string, + data: UpdateVendorInput + ) { + const existing = await tenantPrisma.vendor.findFirst({ + where: { id: vendorId }, + }); + + if (!existing) { + throw new Error(`Vendor not found: ${vendorId}`); + } + + const updateData: Record = {}; + if (data.name !== undefined) updateData.name = data.name.trim(); + if (data.contactPerson !== undefined) updateData.contactPerson = data.contactPerson?.trim() ?? null; + if (data.phone !== undefined) updateData.phone = data.phone?.trim() ?? null; + if (data.email !== undefined) updateData.email = data.email?.trim() ?? null; + if (data.address !== undefined) updateData.address = data.address?.trim() ?? null; + if (data.servicesProvided !== undefined) updateData.servicesProvided = data.servicesProvided?.trim() ?? null; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + + return tenantPrisma.vendor.update({ + where: { id: vendorId }, + data: updateData, + }); + } + + /** + * List vendors with optional active filter. + */ + static async listVendors( + tenantPrisma: TenantPrismaClient, + filters?: ListVendorsFilter + ) { + const where: Record = {}; + if (filters?.isActive !== undefined) where.isActive = filters.isActive; + + return tenantPrisma.vendor.findMany({ + where, + orderBy: { name: "asc" }, + }); + } + + /** + * Get a single vendor by ID. + */ + static async getVendor(tenantPrisma: TenantPrismaClient, vendorId: string) { + const vendor = await tenantPrisma.vendor.findFirst({ + where: { id: vendorId }, + include: { + expenses: { + orderBy: { createdAt: "desc" }, + take: 10, + select: { + id: true, + expenseNumber: true, + amount: true, + description: true, + status: true, + expenseDate: true, + }, + }, + }, + }); + + if (!vendor) { + throw new Error(`Vendor not found: ${vendorId}`); + } + + return vendor; + } +}