feat(04-03): Vendor, ExpenseCategory, Expense schema + category seeding + COA additions
- Add ExpenseStatus and ExpensePaymentMethod enums - Add Vendor model (@@unique([tenantId, name])) - Add ExpenseCategory model with accountCode linking to COA - Add Expense model with approval workflow and JE reference - Add User relations: createdExpenses, approvedExpenses - Add COA accounts 5080 Fuel/Transportation, 5085 Rent Expense (31 total) - Seed 9 default expense categories in createTenant transaction - Add Vendor subject to CASL types, Expense/Vendor permissions for OFFICE_STAFF Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,19 @@ enum LocationType {
|
|||||||
SUBSCRIBER
|
SUBSCRIBER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ExpenseStatus {
|
||||||
|
DRAFT
|
||||||
|
APPROVED
|
||||||
|
POSTED
|
||||||
|
VOIDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExpensePaymentMethod {
|
||||||
|
CASH
|
||||||
|
BANK_TRANSFER
|
||||||
|
CHECK
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -429,6 +442,10 @@ model User {
|
|||||||
technicianProfiles TechnicianProfile[]
|
technicianProfiles TechnicianProfile[]
|
||||||
/// Stock movements performed/recorded by this user
|
/// Stock movements performed/recorded by this user
|
||||||
recordedMovements StockMovement[] @relation("MovementPerformedBy")
|
recordedMovements StockMovement[] @relation("MovementPerformedBy")
|
||||||
|
/// Expenses created by this user
|
||||||
|
createdExpenses Expense[] @relation("ExpenseCreatedBy")
|
||||||
|
/// Expenses approved by this user
|
||||||
|
approvedExpenses Expense[] @relation("ExpenseApprovedBy")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -953,3 +970,96 @@ model StockMovement {
|
|||||||
@@index([inventoryItemId])
|
@@index([inventoryItemId])
|
||||||
@@index([tenantId, movementType])
|
@@index([tenantId, movementType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Vendor represents an external supplier or service provider.
|
||||||
|
/// Vendors are optional on expenses but useful for tracking who was paid.
|
||||||
|
model Vendor {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
contactPerson String?
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
address String?
|
||||||
|
/// Freetext description of what this vendor provides
|
||||||
|
servicesProvided String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
expenses Expense[]
|
||||||
|
|
||||||
|
/// Vendor names must be unique within a tenant
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ExpenseCategory classifies expenses and links them to COA accounts.
|
||||||
|
/// Default categories are pre-seeded at tenant creation (isSystemCategory=true).
|
||||||
|
/// Admins can add custom categories; system categories cannot be deleted.
|
||||||
|
model ExpenseCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
/// Maps to a COA expense account code (e.g., "5040" for bandwidth)
|
||||||
|
accountCode String
|
||||||
|
/// True for pre-seeded categories — cannot be deleted by admin
|
||||||
|
isSystemCategory Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
expenses Expense[]
|
||||||
|
|
||||||
|
/// Category names must be unique within a tenant
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Expense tracks money spent by the ISP business.
|
||||||
|
/// Follows an optional approval workflow: DRAFT -> APPROVED -> POSTED (when enabled).
|
||||||
|
/// When approval is disabled, expenses go directly from DRAFT to POSTED.
|
||||||
|
/// Every POSTED expense creates a balanced journal entry (DR expense account, CR cash/bank).
|
||||||
|
model Expense {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
/// Auto-generated sequential identifier per tenant (e.g., "EXP-0001")
|
||||||
|
expenseNumber String
|
||||||
|
categoryId String
|
||||||
|
category ExpenseCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
vendorId String?
|
||||||
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||||
|
/// Total expense amount
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
/// When the expense occurred (economic date)
|
||||||
|
expenseDate DateTime
|
||||||
|
description String
|
||||||
|
paymentMethod ExpensePaymentMethod
|
||||||
|
status ExpenseStatus @default(DRAFT)
|
||||||
|
/// File path for receipt image/PDF
|
||||||
|
attachmentPath String?
|
||||||
|
/// Journal entry created when expense is posted (DR expense account, CR cash/bank)
|
||||||
|
journalEntryId String?
|
||||||
|
/// The user who recorded this expense
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation("ExpenseCreatedBy", fields: [createdById], references: [id])
|
||||||
|
/// The user who approved this expense (if approval workflow enabled)
|
||||||
|
approvedById String?
|
||||||
|
approvedBy User? @relation("ExpenseApprovedBy", fields: [approvedById], references: [id])
|
||||||
|
approvedAt DateTime?
|
||||||
|
postedAt DateTime?
|
||||||
|
voidedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// Expense numbers must be unique within a tenant
|
||||||
|
@@unique([tenantId, expenseNumber])
|
||||||
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, categoryId])
|
||||||
|
@@index([tenantId, vendorId])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,20 @@ export const ISP_CHART_OF_ACCOUNTS: COAAccountDefinition[] = [
|
|||||||
normalBalance: "DEBIT",
|
normalBalance: "DEBIT",
|
||||||
parentCode: "5000",
|
parentCode: "5000",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "5080",
|
||||||
|
name: "Fuel and Transportation",
|
||||||
|
accountType: "EXPENSE",
|
||||||
|
normalBalance: "DEBIT",
|
||||||
|
parentCode: "5000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "5085",
|
||||||
|
name: "Rent Expense",
|
||||||
|
accountType: "EXPENSE",
|
||||||
|
normalBalance: "DEBIT",
|
||||||
|
parentCode: "5000",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "5090",
|
code: "5090",
|
||||||
name: "Other Expense",
|
name: "Other Expense",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export function definePermissionsFor(
|
|||||||
can("update", "TechnicianProfile");
|
can("update", "TechnicianProfile");
|
||||||
// Inventory management
|
// Inventory management
|
||||||
can("manage", "Inventory");
|
can("manage", "Inventory");
|
||||||
|
// Expense management (record, list, view)
|
||||||
|
can("manage", "Expense");
|
||||||
|
// Vendor management (CRUD)
|
||||||
|
can("manage", "Vendor");
|
||||||
// Job type rates (read-only for office staff — admin configures rates)
|
// Job type rates (read-only for office staff — admin configures rates)
|
||||||
can("read", "JobTypeRate");
|
can("read", "JobTypeRate");
|
||||||
// View financial reports (read-only)
|
// View financial reports (read-only)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type AppSubjects =
|
|||||||
| "JobTypeRate"
|
| "JobTypeRate"
|
||||||
| "Inventory"
|
| "Inventory"
|
||||||
| "Expense"
|
| "Expense"
|
||||||
|
| "Vendor"
|
||||||
| "Account"
|
| "Account"
|
||||||
| "Report"
|
| "Report"
|
||||||
| "all";
|
| "all";
|
||||||
|
|||||||
@@ -210,6 +210,22 @@ export async function createTenant(input: CreateTenantInput): Promise<CreateTena
|
|||||||
];
|
];
|
||||||
await tx.ticketCategory.createMany({ data: defaultCategories });
|
await tx.ticketCategory.createMany({ data: defaultCategories });
|
||||||
|
|
||||||
|
// Seed default ISP expense categories for this tenant.
|
||||||
|
// Each category maps to a COA expense account for automatic JE posting.
|
||||||
|
// System categories (isSystemCategory=true) cannot be deleted by admins.
|
||||||
|
const defaultExpenseCategories = [
|
||||||
|
{ name: "Internet Bandwidth", accountCode: "5040", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Equipment & Supplies", accountCode: "5030", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Salary & Wages", accountCode: "5010", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Technician Compensation", accountCode: "5020", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Office Supplies", accountCode: "5050", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Utilities", accountCode: "5060", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Fuel & Transportation", accountCode: "5080", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Rent", accountCode: "5085", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
{ name: "Other", accountCode: "5090", isSystemCategory: true, tenantId: tenant.id },
|
||||||
|
];
|
||||||
|
await tx.expenseCategory.createMany({ data: defaultExpenseCategories });
|
||||||
|
|
||||||
return { tenant, user };
|
return { tenant, user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user