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