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