diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1c84923..ef714dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -168,6 +168,19 @@ enum LocationType { SUBSCRIBER } +enum ExpenseStatus { + DRAFT + APPROVED + POSTED + VOIDED +} + +enum ExpensePaymentMethod { + CASH + BANK_TRANSFER + CHECK +} + // ============================================================================= // MODELS // ============================================================================= @@ -429,6 +442,10 @@ model User { technicianProfiles TechnicianProfile[] /// Stock movements performed/recorded by this user 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()) updatedAt DateTime @updatedAt @@ -953,3 +970,96 @@ model StockMovement { @@index([inventoryItemId]) @@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]) +} diff --git a/src/lib/accounting/chart-of-accounts.ts b/src/lib/accounting/chart-of-accounts.ts index 00db40b..0dde24b 100644 --- a/src/lib/accounting/chart-of-accounts.ts +++ b/src/lib/accounting/chart-of-accounts.ts @@ -276,6 +276,20 @@ export const ISP_CHART_OF_ACCOUNTS: COAAccountDefinition[] = [ normalBalance: "DEBIT", 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", name: "Other Expense", diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts index b903865..3485316 100644 --- a/src/lib/casl/permissions.ts +++ b/src/lib/casl/permissions.ts @@ -62,6 +62,10 @@ export function definePermissionsFor( can("update", "TechnicianProfile"); // Inventory management 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) can("read", "JobTypeRate"); // View financial reports (read-only) diff --git a/src/lib/casl/types.ts b/src/lib/casl/types.ts index 86bbd90..e63378d 100644 --- a/src/lib/casl/types.ts +++ b/src/lib/casl/types.ts @@ -20,6 +20,7 @@ export type AppSubjects = | "JobTypeRate" | "Inventory" | "Expense" + | "Vendor" | "Account" | "Report" | "all"; diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts index 45d3da7..3ebc4bc 100644 --- a/src/lib/tenant.ts +++ b/src/lib/tenant.ts @@ -210,6 +210,22 @@ export async function createTenant(input: CreateTenantInput): Promise