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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
31
src/app/api/expenses/[id]/approve/route.ts
Normal file
31
src/app/api/expenses/[id]/approve/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
28
src/app/api/expenses/[id]/route.ts
Normal file
28
src/app/api/expenses/[id]/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
42
src/app/api/expenses/categories/[id]/route.ts
Normal file
42
src/app/api/expenses/categories/[id]/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
68
src/app/api/expenses/categories/route.ts
Normal file
68
src/app/api/expenses/categories/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
99
src/app/api/expenses/route.ts
Normal file
99
src/app/api/expenses/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
67
src/app/api/vendors/[id]/route.ts
vendored
Normal file
67
src/app/api/vendors/[id]/route.ts
vendored
Normal file
@@ -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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
68
src/app/api/vendors/route.ts
vendored
Normal file
68
src/app/api/vendors/route.ts
vendored
Normal file
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
469
src/lib/__tests__/expense-service.test.ts
Normal file
469
src/lib/__tests__/expense-service.test.ts
Normal file
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
537
src/lib/services/expense-service.ts
Normal file
537
src/lib/services/expense-service.ts
Normal file
@@ -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<string> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown> = {};
|
||||
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<string, Date> = {};
|
||||
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<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
149
src/lib/services/vendor-service.ts
Normal file
149
src/lib/services/vendor-service.ts
Normal file
@@ -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<string, unknown>,
|
||||
});
|
||||
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user