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:
kevin-asprec
2026-03-05 10:52:07 +08:00
parent fe4d22f440
commit b7bbc50b2b
11 changed files with 1641 additions and 0 deletions

View File

@@ -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;

View 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);
}

View 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);
}

View 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);
}

View 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 });
}
}
);

View 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
View 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
View 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 });
}
}
);

View 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());
}
});
});

View 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 },
});
}
}

View 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;
}
}