merge: develop into main

This commit is contained in:
kevin-asprec
2026-04-13 13:04:32 +08:00
157 changed files with 9219 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.env
*.tsbuildinfo

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops
JWT_SECRET=your-secret-key
PORT=3001

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
.env.*.local
*.tsbuildinfo

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS builder
WORKDIR /app
ENV NODE_ENV=development
COPY package.json package-lock.json* ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
RUN npm install
COPY packages/shared/ ./packages/shared/
COPY packages/db/ ./packages/db/
COPY nest-cli.json ./
COPY tsconfig.json ./
COPY src/ ./src/
RUN cd packages/shared && npx tsc --outDir dist --declaration
RUN cd packages/db && npx prisma generate
RUN npx -p @nestjs/cli nest build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/index.js';require('fs').writeFileSync('./packages/shared/package.json',JSON.stringify(p,null,2))"
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
COPY --from=builder /app/packages/db/package.json ./packages/db/
COPY --from=builder /app/packages/db/src ./packages/db/src
ENV NODE_ENV=production
EXPOSE 3001
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && cd /app && node dist/main"]

8
nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "fiberops-api",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"start:prod": "node dist/main",
"db:generate": "cd packages/db && npx prisma generate",
"db:migrate": "cd packages/db && npx prisma migrate dev",
"db:migrate:deploy": "cd packages/db && npx prisma migrate deploy",
"db:seed": "cd packages/db && npx prisma db seed",
"db:studio": "cd packages/db && npx prisma studio"
},
"dependencies": {
"@fiberops/db": "workspace:*",
"@fiberops/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/passport-jwt": "^4.0.1",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
}

28
packages/db/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@fiberops/db",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"generate": "prisma generate",
"migrate:dev": "prisma migrate dev",
"migrate:deploy": "prisma migrate deploy",
"seed": "tsx prisma/seed.ts",
"studio": "prisma studio",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.5.0"
},
"devDependencies": {
"prisma": "^6.5.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,361 @@
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"settings" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "areas" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "areas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plans" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"speedDown" INTEGER NOT NULL,
"speedUp" INTEGER NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"billingCycle" INTEGER NOT NULL DEFAULT 30,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "clients" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" TEXT NOT NULL,
"areaId" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"installedAt" TIMESTAMP(3),
"activatedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoices" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"balance" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"periodStart" TIMESTAMP(3),
"periodEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"invoiceId" TEXT,
"collectedById" TEXT,
"amount" DECIMAL(10,2) NOT NULL,
"method" TEXT NOT NULL,
"referenceNo" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "remittances" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"collectorId" TEXT NOT NULL,
"confirmedById" TEXT,
"totalAmount" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmedAt" TIMESTAMP(3),
"notes" TEXT,
CONSTRAINT "remittances_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"clientId" TEXT,
"createdById" TEXT NOT NULL,
"assigneeId" TEXT,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"title" TEXT NOT NULL,
"description" TEXT,
"priority" TEXT NOT NULL DEFAULT 'normal',
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT,
"clientId" TEXT,
"type" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE INDEX "users_tenantId_idx" ON "users"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_role_key" ON "user_roles"("userId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_userId_idx" ON "refresh_tokens"("userId");
-- CreateIndex
CREATE INDEX "refresh_tokens_expiresAt_idx" ON "refresh_tokens"("expiresAt");
-- CreateIndex
CREATE INDEX "areas_tenantId_idx" ON "areas"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "areas_tenantId_name_key" ON "areas"("tenantId", "name");
-- CreateIndex
CREATE INDEX "plans_tenantId_idx" ON "plans"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "plans_tenantId_name_key" ON "plans"("tenantId", "name");
-- CreateIndex
CREATE INDEX "clients_tenantId_idx" ON "clients"("tenantId");
-- CreateIndex
CREATE INDEX "clients_tenantId_status_idx" ON "clients"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "clients_tenantId_accountNumber_key" ON "clients"("tenantId", "accountNumber");
-- CreateIndex
CREATE INDEX "subscriptions_tenantId_idx" ON "subscriptions"("tenantId");
-- CreateIndex
CREATE INDEX "subscriptions_tenantId_status_idx" ON "subscriptions"("tenantId", "status");
-- CreateIndex
CREATE INDEX "subscriptions_clientId_idx" ON "subscriptions"("clientId");
-- CreateIndex
CREATE INDEX "invoices_tenantId_idx" ON "invoices"("tenantId");
-- CreateIndex
CREATE INDEX "invoices_tenantId_status_idx" ON "invoices"("tenantId", "status");
-- CreateIndex
CREATE INDEX "invoices_clientId_idx" ON "invoices"("clientId");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_tenantId_number_key" ON "invoices"("tenantId", "number");
-- CreateIndex
CREATE INDEX "payments_tenantId_idx" ON "payments"("tenantId");
-- CreateIndex
CREATE INDEX "payments_clientId_idx" ON "payments"("clientId");
-- CreateIndex
CREATE INDEX "payments_invoiceId_idx" ON "payments"("invoiceId");
-- CreateIndex
CREATE INDEX "remittances_tenantId_idx" ON "remittances"("tenantId");
-- CreateIndex
CREATE INDEX "remittances_collectorId_idx" ON "remittances"("collectorId");
-- CreateIndex
CREATE INDEX "tickets_tenantId_idx" ON "tickets"("tenantId");
-- CreateIndex
CREATE INDEX "tickets_tenantId_type_status_idx" ON "tickets"("tenantId", "type", "status");
-- CreateIndex
CREATE INDEX "tickets_clientId_idx" ON "tickets"("clientId");
-- CreateIndex
CREATE INDEX "tickets_assigneeId_idx" ON "tickets"("assigneeId");
-- CreateIndex
CREATE INDEX "notifications_tenantId_idx" ON "notifications"("tenantId");
-- CreateIndex
CREATE INDEX "notifications_userId_isRead_idx" ON "notifications"("userId", "isRead");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "areas" ADD CONSTRAINT "areas_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "plans" ADD CONSTRAINT "plans_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "clients" ADD CONSTRAINT "clients_areaId_fkey" FOREIGN KEY ("areaId") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_collectedById_fkey" FOREIGN KEY ("collectedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remittances" ADD CONSTRAINT "remittances_collectorId_fkey" FOREIGN KEY ("collectorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remittances" ADD CONSTRAINT "remittances_confirmedById_fkey" FOREIGN KEY ("confirmedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"details" JSONB NOT NULL DEFAULT '{}',
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "audit_logs_tenantId_idx" ON "audit_logs"("tenantId");
-- CreateIndex
CREATE INDEX "audit_logs_tenantId_entity_idx" ON "audit_logs"("tenantId", "entity");
-- CreateIndex
CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId");

View File

@@ -0,0 +1,135 @@
-- CreateTable
CREATE TABLE "employees" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"position" TEXT NOT NULL,
"department" TEXT,
"employeeNo" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"hireDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"terminatedAt" TIMESTAMP(3),
"salary" DECIMAL(10,2),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employees_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "expenses" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"approvedById" TEXT,
"category" TEXT NOT NULL,
"description" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"receiptUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"expenseDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"approvedAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "expenses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "company_accounts" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"accountNo" TEXT,
"balance" DECIMAL(12,2) NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "company_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fund_transfers" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"fromAccountId" TEXT NOT NULL,
"toAccountId" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"description" TEXT,
"transferredBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fund_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assets" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"serialNumber" TEXT,
"purchaseDate" TIMESTAMP(3),
"purchasePrice" DECIMAL(10,2),
"assignedToId" TEXT,
"status" TEXT NOT NULL DEFAULT 'available',
"location" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "assets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "employees_tenantId_idx" ON "employees"("tenantId");
-- CreateIndex
CREATE INDEX "employees_tenantId_status_idx" ON "employees"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "employees_tenantId_employeeNo_key" ON "employees"("tenantId", "employeeNo");
-- CreateIndex
CREATE INDEX "expenses_tenantId_idx" ON "expenses"("tenantId");
-- CreateIndex
CREATE INDEX "expenses_tenantId_status_idx" ON "expenses"("tenantId", "status");
-- CreateIndex
CREATE INDEX "expenses_tenantId_category_idx" ON "expenses"("tenantId", "category");
-- CreateIndex
CREATE INDEX "company_accounts_tenantId_idx" ON "company_accounts"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "company_accounts_tenantId_name_key" ON "company_accounts"("tenantId", "name");
-- CreateIndex
CREATE INDEX "fund_transfers_tenantId_idx" ON "fund_transfers"("tenantId");
-- CreateIndex
CREATE INDEX "assets_tenantId_idx" ON "assets"("tenantId");
-- CreateIndex
CREATE INDEX "assets_tenantId_status_idx" ON "assets"("tenantId", "status");
-- CreateIndex
CREATE INDEX "assets_tenantId_category_idx" ON "assets"("tenantId", "category");
-- AddForeignKey
ALTER TABLE "fund_transfers" ADD CONSTRAINT "fund_transfers_fromAccountId_fkey" FOREIGN KEY ("fromAccountId") REFERENCES "company_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fund_transfers" ADD CONSTRAINT "fund_transfers_toAccountId_fkey" FOREIGN KEY ("toAccountId") REFERENCES "company_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assets" ADD CONSTRAINT "assets_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "billing_settings" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"autoGenerate" BOOLEAN NOT NULL DEFAULT true,
"gracePeriodDays" INTEGER NOT NULL DEFAULT 7,
"dueDateOffsetDays" INTEGER NOT NULL DEFAULT 15,
"lateFeePercent" DECIMAL(5,2) NOT NULL DEFAULT 0,
"invoicePrefix" TEXT NOT NULL DEFAULT 'INV',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chart_of_accounts" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"parentId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"balance" DECIMAL(14,2) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "chart_of_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "journal_entries" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"entryDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"description" TEXT NOT NULL,
"reference" TEXT,
"sourceType" TEXT,
"sourceId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "journal_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "journal_lines" (
"id" TEXT NOT NULL,
"journalEntryId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"debit" DECIMAL(14,2) NOT NULL DEFAULT 0,
"credit" DECIMAL(14,2) NOT NULL DEFAULT 0,
CONSTRAINT "journal_lines_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "billing_settings_tenantId_key" ON "billing_settings"("tenantId");
-- CreateIndex
CREATE INDEX "chart_of_accounts_tenantId_idx" ON "chart_of_accounts"("tenantId");
-- CreateIndex
CREATE INDEX "chart_of_accounts_tenantId_type_idx" ON "chart_of_accounts"("tenantId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "chart_of_accounts_tenantId_code_key" ON "chart_of_accounts"("tenantId", "code");
-- CreateIndex
CREATE INDEX "journal_entries_tenantId_idx" ON "journal_entries"("tenantId");
-- CreateIndex
CREATE INDEX "journal_entries_tenantId_sourceType_sourceId_idx" ON "journal_entries"("tenantId", "sourceType", "sourceId");
-- CreateIndex
CREATE INDEX "journal_lines_journalEntryId_idx" ON "journal_lines"("journalEntryId");
-- CreateIndex
CREATE INDEX "journal_lines_accountId_idx" ON "journal_lines"("accountId");
-- AddForeignKey
ALTER TABLE "chart_of_accounts" ADD CONSTRAINT "chart_of_accounts_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "chart_of_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "journal_lines" ADD CONSTRAINT "journal_lines_journalEntryId_fkey" FOREIGN KEY ("journalEntryId") REFERENCES "journal_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "journal_lines" ADD CONSTRAINT "journal_lines_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "chart_of_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "company_accounts" ADD COLUMN "chartOfAccountId" TEXT,
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "remittance_payments" (
"id" TEXT NOT NULL,
"remittanceId" TEXT NOT NULL,
"paymentId" TEXT NOT NULL,
CONSTRAINT "remittance_payments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "remittance_payments_remittanceId_paymentId_key" ON "remittance_payments"("remittanceId", "paymentId");
-- AddForeignKey
ALTER TABLE "remittance_payments" ADD CONSTRAINT "remittance_payments_remittanceId_fkey" FOREIGN KEY ("remittanceId") REFERENCES "remittances"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,183 @@
-- Sync schema drift: soft-delete columns, mustChangePassword, RBAC tables,
-- recurring expenses, and payroll tables.
-- Uses IF NOT EXISTS / IF NOT EXISTS for idempotency (safe on fresh and db-pushed databases).
-- ── Soft-delete columns ──
ALTER TABLE "tenants" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "subscriptions" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "payments" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "remittances" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "tickets" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "areas" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "plans" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "expenses" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "assets" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "company_accounts" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
ALTER TABLE "chart_of_accounts" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
-- Indexes for deletedAt
CREATE INDEX IF NOT EXISTS "tenants_deletedAt_idx" ON "tenants"("deletedAt");
CREATE INDEX IF NOT EXISTS "users_deletedAt_idx" ON "users"("deletedAt");
CREATE INDEX IF NOT EXISTS "subscriptions_deletedAt_idx" ON "subscriptions"("deletedAt");
CREATE INDEX IF NOT EXISTS "invoices_deletedAt_idx" ON "invoices"("deletedAt");
CREATE INDEX IF NOT EXISTS "payments_deletedAt_idx" ON "payments"("deletedAt");
CREATE INDEX IF NOT EXISTS "remittances_deletedAt_idx" ON "remittances"("deletedAt");
CREATE INDEX IF NOT EXISTS "tickets_deletedAt_idx" ON "tickets"("deletedAt");
CREATE INDEX IF NOT EXISTS "areas_deletedAt_idx" ON "areas"("deletedAt");
CREATE INDEX IF NOT EXISTS "plans_deletedAt_idx" ON "plans"("deletedAt");
CREATE INDEX IF NOT EXISTS "clients_deletedAt_idx" ON "clients"("deletedAt");
CREATE INDEX IF NOT EXISTS "employees_deletedAt_idx" ON "employees"("deletedAt");
CREATE INDEX IF NOT EXISTS "expenses_deletedAt_idx" ON "expenses"("deletedAt");
CREATE INDEX IF NOT EXISTS "assets_deletedAt_idx" ON "assets"("deletedAt");
CREATE INDEX IF NOT EXISTS "company_accounts_deletedAt_idx" ON "company_accounts"("deletedAt");
CREATE INDEX IF NOT EXISTS "chart_of_accounts_deletedAt_idx" ON "chart_of_accounts"("deletedAt");
-- ── RBAC: tenant_roles ──
CREATE TABLE IF NOT EXISTS "tenant_roles" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "tenant_roles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "tenant_roles_tenantId_slug_key" ON "tenant_roles"("tenantId", "slug");
CREATE INDEX IF NOT EXISTS "tenant_roles_tenantId_idx" ON "tenant_roles"("tenantId");
CREATE INDEX IF NOT EXISTS "tenant_roles_deletedAt_idx" ON "tenant_roles"("deletedAt");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenant_roles_tenantId_fkey') THEN
ALTER TABLE "tenant_roles" ADD CONSTRAINT "tenant_roles_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── RBAC: role_permissions ──
CREATE TABLE IF NOT EXISTS "role_permissions" (
"id" TEXT NOT NULL,
"tenantRoleId" TEXT NOT NULL,
"module" TEXT NOT NULL,
"canView" BOOLEAN NOT NULL DEFAULT false,
"canCreate" BOOLEAN NOT NULL DEFAULT false,
"canUpdate" BOOLEAN NOT NULL DEFAULT false,
"canArchive" BOOLEAN NOT NULL DEFAULT false,
"canApprove" BOOLEAN NOT NULL DEFAULT false,
"canExport" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "role_permissions_tenantRoleId_module_key" ON "role_permissions"("tenantRoleId", "module");
CREATE INDEX IF NOT EXISTS "role_permissions_tenantRoleId_idx" ON "role_permissions"("tenantRoleId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'role_permissions_tenantRoleId_fkey') THEN
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_tenantRoleId_fkey"
FOREIGN KEY ("tenantRoleId") REFERENCES "tenant_roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── RBAC: user_tenant_roles ──
CREATE TABLE IF NOT EXISTS "user_tenant_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantRoleId" TEXT NOT NULL,
CONSTRAINT "user_tenant_roles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "user_tenant_roles_userId_tenantRoleId_key" ON "user_tenant_roles"("userId", "tenantRoleId");
CREATE INDEX IF NOT EXISTS "user_tenant_roles_userId_idx" ON "user_tenant_roles"("userId");
CREATE INDEX IF NOT EXISTS "user_tenant_roles_tenantRoleId_idx" ON "user_tenant_roles"("tenantRoleId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_tenant_roles_userId_fkey') THEN
ALTER TABLE "user_tenant_roles" ADD CONSTRAINT "user_tenant_roles_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_tenant_roles_tenantRoleId_fkey') THEN
ALTER TABLE "user_tenant_roles" ADD CONSTRAINT "user_tenant_roles_tenantRoleId_fkey"
FOREIGN KEY ("tenantRoleId") REFERENCES "tenant_roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── Recurring Expenses ──
CREATE TABLE IF NOT EXISTS "recurring_expenses" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"description" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"frequency" TEXT NOT NULL DEFAULT 'monthly',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"nextRunDate" TIMESTAMP(3) NOT NULL,
"lastRunDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "recurring_expenses_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "recurring_expenses_tenantId_idx" ON "recurring_expenses"("tenantId");
CREATE INDEX IF NOT EXISTS "recurring_expenses_deletedAt_idx" ON "recurring_expenses"("deletedAt");
-- ── Payroll Runs ──
CREATE TABLE IF NOT EXISTS "payroll_runs" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"totalAmount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"processedBy" TEXT,
"processedAt" TIMESTAMP(3),
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "payroll_runs_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "payroll_runs_tenantId_period_key" ON "payroll_runs"("tenantId", "period");
CREATE INDEX IF NOT EXISTS "payroll_runs_tenantId_idx" ON "payroll_runs"("tenantId");
CREATE INDEX IF NOT EXISTS "payroll_runs_deletedAt_idx" ON "payroll_runs"("deletedAt");
-- ── Payslips ──
CREATE TABLE IF NOT EXISTS "payslips" (
"id" TEXT NOT NULL,
"payrollRunId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"baseSalary" DECIMAL(10,2) NOT NULL,
"deductions" DECIMAL(10,2) NOT NULL DEFAULT 0,
"bonuses" DECIMAL(10,2) NOT NULL DEFAULT 0,
"netPay" DECIMAL(10,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
CONSTRAINT "payslips_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "payslips_payrollRunId_employeeId_key" ON "payslips"("payrollRunId", "employeeId");
CREATE INDEX IF NOT EXISTS "payslips_payrollRunId_idx" ON "payslips"("payrollRunId");
CREATE INDEX IF NOT EXISTS "payslips_employeeId_idx" ON "payslips"("employeeId");
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payslips_payrollRunId_fkey') THEN
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_payrollRunId_fkey"
FOREIGN KEY ("payrollRunId") REFERENCES "payroll_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payslips_employeeId_fkey') THEN
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION;
ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,682 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Multi-Tenant Foundation ────────────────────────────────────────
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
users User[]
tenantRoles TenantRole[]
areas Area[]
plans Plan[]
clients Client[]
subscriptions Subscription[]
invoices Invoice[]
payments Payment[]
tickets Ticket[]
notifications Notification[]
@@index([deletedAt])
@@map("tenants")
}
// ─── Auth & Users ───────────────────────────────────────────────────
model User {
id String @id @default(uuid())
tenantId String? // null for super_admin (platform-level user)
email String
password String
firstName String
lastName String
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
roles UserRole[]
tenantRoles UserTenantRole[]
assignedTickets Ticket[] @relation("TicketAssignee")
createdTickets Ticket[] @relation("TicketCreator")
payments Payment[]
remittances Remittance[] @relation("RemittanceCollector")
confirmedRemittances Remittance[] @relation("RemittanceConfirmer")
@@unique([tenantId, email])
@@index([tenantId])
@@index([deletedAt])
@@map("users")
}
model UserRole {
id String @id @default(uuid())
userId String
role String // super_admin (platform-level only, legacy compat)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@map("user_roles")
}
// ─── Tenant-Scoped Role Management ─────────────────────────────────
model TenantRole {
id String @id @default(uuid())
tenantId String
name String
slug String
description String?
isSystem Boolean @default(false) // system roles can't be deleted
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
permissions RolePermission[]
users UserTenantRole[]
@@unique([tenantId, slug])
@@index([tenantId])
@@index([deletedAt])
@@map("tenant_roles")
}
model RolePermission {
id String @id @default(uuid())
tenantRoleId String
module String // clients, subscriptions, invoices, payments, tickets, employees, expenses, assets, payroll, accounts, accounting, reports, settings, users, areas, plans, dashboard, fund_transfers
canView Boolean @default(false)
canCreate Boolean @default(false)
canUpdate Boolean @default(false)
canArchive Boolean @default(false)
canApprove Boolean @default(false)
canExport Boolean @default(false)
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
@@unique([tenantRoleId, module])
@@index([tenantRoleId])
@@map("role_permissions")
}
model UserTenantRole {
id String @id @default(uuid())
userId String
tenantRoleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenantRole TenantRole @relation(fields: [tenantRoleId], references: [id], onDelete: Cascade)
@@unique([userId, tenantRoleId])
@@index([userId])
@@index([tenantRoleId])
@@map("user_tenant_roles")
}
model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@map("refresh_tokens")
}
// ─── Area & Zone Management ─────────────────────────────────────────
model Area {
id String @id @default(uuid())
tenantId String
name String
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
clients Client[]
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("areas")
}
// ─── Plan / Package Management ──────────────────────────────────────
model Plan {
id String @id @default(uuid())
tenantId String
name String
description String?
speedDown Int // Mbps download
speedUp Int // Mbps upload
price Decimal @db.Decimal(10, 2)
billingCycle Int @default(30) // days
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
subscriptions Subscription[]
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("plans")
}
// ─── Client Profiling ───────────────────────────────────────────────
model Client {
id String @id @default(uuid())
tenantId String
accountNumber String
firstName String
lastName String
email String?
phone String?
address String
areaId String?
latitude Float?
longitude Float?
status String @default("active") // active, inactive, suspended
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
area Area? @relation(fields: [areaId], references: [id])
subscriptions Subscription[]
invoices Invoice[]
payments Payment[]
tickets Ticket[]
@@unique([tenantId, accountNumber])
@@index([tenantId])
@@index([tenantId, status])
@@index([deletedAt])
@@map("clients")
}
// ─── Subscription Management ────────────────────────────────────────
model Subscription {
id String @id @default(uuid())
tenantId String
clientId String
planId String
type String // prepaid, postpaid
status String @default("pending") // pending, active, suspended, cancelled, expired
startDate DateTime?
endDate DateTime?
installedAt DateTime?
activatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
plan Plan @relation(fields: [planId], references: [id])
@@index([tenantId])
@@index([tenantId, status])
@@index([clientId])
@@index([deletedAt])
@@map("subscriptions")
}
// ─── Billing & Invoicing ────────────────────────────────────────────
model Invoice {
id String @id @default(uuid())
tenantId String
clientId String
number String
amount Decimal @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2) // remaining unpaid
status String @default("draft") // draft, sent, partial, paid, overdue, void
dueDate DateTime
paidAt DateTime?
periodStart DateTime?
periodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
payments Payment[]
@@unique([tenantId, number])
@@index([tenantId])
@@index([tenantId, status])
@@index([clientId])
@@index([deletedAt])
@@map("invoices")
}
// ─── Payment & Collection ───────────────────────────────────────────
model Payment {
id String @id @default(uuid())
tenantId String
clientId String
invoiceId String?
collectedById String?
amount Decimal @db.Decimal(10, 2)
method String // gcash, maya, cash, bank_transfer
referenceNo String?
notes String?
createdAt DateTime @default(now())
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
invoice Invoice? @relation(fields: [invoiceId], references: [id])
collectedBy User? @relation(fields: [collectedById], references: [id])
@@index([tenantId])
@@index([clientId])
@@index([invoiceId])
@@index([deletedAt])
@@map("payments")
}
model Remittance {
id String @id @default(uuid())
tenantId String
collectorId String
confirmedById String?
totalAmount Decimal @db.Decimal(10, 2)
status String @default("pending") // pending, confirmed, rejected
submittedAt DateTime @default(now())
confirmedAt DateTime?
notes String?
deletedAt DateTime?
collector User @relation("RemittanceCollector", fields: [collectorId], references: [id])
confirmedBy User? @relation("RemittanceConfirmer", fields: [confirmedById], references: [id])
payments RemittancePayment[]
@@index([tenantId])
@@index([collectorId])
@@index([deletedAt])
@@map("remittances")
}
model RemittancePayment {
id String @id @default(uuid())
remittanceId String
paymentId String
remittance Remittance @relation(fields: [remittanceId], references: [id], onDelete: Cascade)
@@unique([remittanceId, paymentId])
@@map("remittance_payments")
}
// ─── Tickets (Work Orders) ──────────────────────────────────────────
model Ticket {
id String @id @default(uuid())
tenantId String
clientId String?
createdById String
assigneeId String?
type String // installation, activation, support, maintenance
status String @default("open") // open, in_progress, resolved, cancelled
title String
description String?
priority String @default("normal") // low, normal, high, urgent
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
client Client? @relation(fields: [clientId], references: [id])
createdBy User @relation("TicketCreator", fields: [createdById], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
@@index([tenantId])
@@index([tenantId, type, status])
@@index([clientId])
@@index([assigneeId])
@@index([deletedAt])
@@map("tickets")
}
// ─── Notifications ──────────────────────────────────────────────────
model Notification {
id String @id @default(uuid())
tenantId String
userId String?
clientId String?
type String // sms, in_app
channel String // billing_reminder, payment_confirmation, ticket_update
title String
message String
isRead Boolean @default(false)
sentAt DateTime?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([userId, isRead])
@@map("notifications")
}
// ─── Audit Log (append-only, no soft delete) ────────────────────────
model AuditLog {
id String @id @default(uuid())
tenantId String
userId String
action String
entity String
entityId String
details Json @default("{}")
ipAddress String?
createdAt DateTime @default(now())
@@index([tenantId])
@@index([tenantId, entity])
@@index([userId])
@@map("audit_logs")
}
// ─── Employee Management ────────────────────────────────────────────
model Employee {
id String @id @default(uuid())
tenantId String
userId String? @unique // Linked user account (optional, 1:1)
firstName String
lastName String
email String?
phone String?
position String
department String?
employeeNo String
status String @default("active") // active, on_leave, terminated
hireDate DateTime @default(now())
terminatedAt DateTime?
salary Decimal? @db.Decimal(10, 2)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assets Asset[] @relation("AssetAssignee")
payslips Payslip[]
@@unique([tenantId, employeeNo])
@@index([tenantId])
@@index([tenantId, status])
@@index([deletedAt])
@@map("employees")
}
// ─── Payroll ────────────────────────────────────────────────────────
model PayrollRun {
id String @id @default(uuid())
tenantId String
period String
status String @default("draft") // draft, processing, completed
totalAmount Decimal @default(0) @db.Decimal(12, 2)
processedBy String?
processedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
payslips Payslip[]
@@unique([tenantId, period])
@@index([tenantId])
@@index([deletedAt])
@@map("payroll_runs")
}
model Payslip {
id String @id @default(uuid())
payrollRunId String
employeeId String
baseSalary Decimal @db.Decimal(10, 2)
deductions Decimal @default(0) @db.Decimal(10, 2)
bonuses Decimal @default(0) @db.Decimal(10, 2)
netPay Decimal @db.Decimal(10, 2)
status String @default("pending") // pending, paid
notes String?
payrollRun PayrollRun @relation(fields: [payrollRunId], references: [id], onDelete: Cascade)
employee Employee @relation(fields: [employeeId], references: [id])
@@unique([payrollRunId, employeeId])
@@index([payrollRunId])
@@index([employeeId])
@@map("payslips")
}
// ─── Recurring Expenses ─────────────────────────────────────────────
model RecurringExpense {
id String @id @default(uuid())
tenantId String
category String
description String
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly") // monthly, quarterly, yearly
isActive Boolean @default(true)
nextRunDate DateTime
lastRunDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([tenantId])
@@index([deletedAt])
@@map("recurring_expenses")
}
// ─── Expense Management ─────────────────────────────────────────────
model Expense {
id String @id @default(uuid())
tenantId String
createdById String
approvedById String?
category String
description String
amount Decimal @db.Decimal(10, 2)
receiptUrl String?
status String @default("pending") // pending, approved, rejected
expenseDate DateTime @default(now())
approvedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, category])
@@index([deletedAt])
@@map("expenses")
}
// ─── Company Accounts & Fund Transfers ──────────────────────────────
model CompanyAccount {
id String @id @default(uuid())
tenantId String
name String
type String // bank, e_wallet, cash
accountNo String?
balance Decimal @default(0) @db.Decimal(12, 2)
isActive Boolean @default(true)
isSystem Boolean @default(false)
chartOfAccountId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
outgoing FundTransfer[] @relation("TransferFrom")
incoming FundTransfer[] @relation("TransferTo")
@@unique([tenantId, name])
@@index([tenantId])
@@index([deletedAt])
@@map("company_accounts")
}
model FundTransfer {
id String @id @default(uuid())
tenantId String
fromAccountId String
toAccountId String
amount Decimal @db.Decimal(12, 2)
description String?
transferredBy String
createdAt DateTime @default(now())
fromAccount CompanyAccount @relation("TransferFrom", fields: [fromAccountId], references: [id])
toAccount CompanyAccount @relation("TransferTo", fields: [toAccountId], references: [id])
@@index([tenantId])
@@map("fund_transfers")
}
// ─── Asset Management ───────────────────────────────────────────────
model Asset {
id String @id @default(uuid())
tenantId String
name String
category String // router, olt, cable, tool, vehicle, computer, other
serialNumber String?
purchaseDate DateTime?
purchasePrice Decimal? @db.Decimal(10, 2)
assignedToId String?
status String @default("available") // available, in_use, maintenance, retired
location String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
assignedTo Employee? @relation("AssetAssignee", fields: [assignedToId], references: [id])
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, category])
@@index([deletedAt])
@@map("assets")
}
// ─── Billing Settings ───────────────────────────────────────────────
model BillingSetting {
id String @id @default(uuid())
tenantId String @unique
autoGenerate Boolean @default(true)
gracePeriodDays Int @default(7)
dueDateOffsetDays Int @default(15)
lateFeePercent Decimal @default(0) @db.Decimal(5, 2)
invoicePrefix String @default("INV")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("billing_settings")
}
// ─── Chart of Accounts ──────────────────────────────────────────────
model ChartOfAccount {
id String @id @default(uuid())
tenantId String
code String
name String
type String // asset, liability, equity, revenue, expense
parentId String?
isActive Boolean @default(true)
isSystem Boolean @default(false)
balance Decimal @default(0) @db.Decimal(14, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
parent ChartOfAccount? @relation("AccountTree", fields: [parentId], references: [id])
children ChartOfAccount[] @relation("AccountTree")
journalLines JournalLine[]
@@unique([tenantId, code])
@@index([tenantId])
@@index([tenantId, type])
@@index([deletedAt])
@@map("chart_of_accounts")
}
// ─── Journal Entries (append-only, no soft delete) ──────────────────
model JournalEntry {
id String @id @default(uuid())
tenantId String
entryDate DateTime @default(now())
description String
reference String?
sourceType String?
sourceId String?
createdById String?
createdAt DateTime @default(now())
lines JournalLine[]
@@index([tenantId])
@@index([tenantId, sourceType, sourceId])
@@map("journal_entries")
}
model JournalLine {
id String @id @default(uuid())
journalEntryId String
accountId String
debit Decimal @default(0) @db.Decimal(14, 2)
credit Decimal @default(0) @db.Decimal(14, 2)
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
account ChartOfAccount @relation(fields: [accountId], references: [id])
@@index([journalEntryId])
@@index([accountId])
@@map("journal_lines")
}

603
packages/db/prisma/seed.ts Normal file
View File

@@ -0,0 +1,603 @@
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as bcrypt from 'bcrypt';
// Default permission matrices for system roles
const MODULES = [
'dashboard', 'clients', 'subscriptions', 'invoices', 'payments', 'tickets',
'employees', 'payroll', 'expenses', 'assets', 'accounts', 'fund_transfers',
'accounting', 'reports', 'areas', 'plans', 'settings', 'users',
] as const;
type Module = (typeof MODULES)[number];
interface PermRow { module: Module; canView: boolean; canCreate: boolean; canUpdate: boolean; canArchive: boolean; canApprove: boolean; canExport: boolean; }
const DEFAULT_ROLES: { name: string; slug: string; description: string; perms: PermRow[] }[] = [
{
name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules',
perms: MODULES.map((m) => ({ module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: true, canExport: true })),
},
{
name: 'Manager', slug: 'manager', description: 'Operational management with approval rights',
perms: MODULES.map((m) => {
const noAccess: Module[] = ['users'];
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
if (noAccess.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: m === 'accounting' };
return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(m), canExport: true };
}),
},
{
name: 'Technician', slug: 'technician', description: 'Field operations: tickets, payments, client/invoice viewing',
perms: MODULES.map((m) => {
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
const fullAccess: Module[] = ['tickets', 'payments'];
if (viewOnly.includes(m)) return { module: m, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (fullAccess.includes(m)) return { module: m, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
},
{
name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing',
perms: MODULES.map((m) => {
const canWrite: Module[] = ['payments'];
const canViewMods: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
if (!canViewMods.includes(m)) return { module: m, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
return { module: m, canView: true, canCreate: canWrite.includes(m), canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
},
];
const prisma = new PrismaClient();
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
async function main() {
console.log('Seeding database...');
// Clean existing data (order matters for FK constraints)
await prisma.journalLine.deleteMany();
await prisma.journalEntry.deleteMany();
await prisma.chartOfAccount.deleteMany();
await prisma.billingSetting.deleteMany();
await prisma.fundTransfer.deleteMany();
await prisma.companyAccount.deleteMany();
await prisma.asset.deleteMany();
await prisma.expense.deleteMany();
await prisma.payslip.deleteMany();
await prisma.payrollRun.deleteMany();
await prisma.recurringExpense.deleteMany();
await prisma.employee.deleteMany();
await prisma.notification.deleteMany();
await prisma.remittancePayment.deleteMany();
await prisma.remittance.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.payment.deleteMany();
await prisma.invoice.deleteMany();
await prisma.ticket.deleteMany();
await prisma.subscription.deleteMany();
await prisma.client.deleteMany();
await prisma.plan.deleteMany();
await prisma.area.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.userTenantRole.deleteMany();
await prisma.rolePermission.deleteMany();
await prisma.tenantRole.deleteMany();
await prisma.userRole.deleteMany();
await prisma.user.deleteMany();
await prisma.tenant.deleteMany();
// ─── Tenant ────────────────────────────────────────────
const tenant = await prisma.tenant.create({
data: {
id: randomUUID(),
name: 'FiberNet Philippines',
slug: 'fibernet-ph',
settings: { companyName: 'FiberNet Philippines Inc.', currency: 'PHP', timezone: 'Asia/Manila' },
},
});
console.log(`Tenant: ${tenant.name}`);
// ─── Super Admin (platform-level, no tenant) ───────────
const superAdmin = await prisma.user.create({
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
});
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
// ─── Tenant Users ─────────────────────────────────────
const users: Record<string, any> = {};
const usersByEmail: Record<string, any> = {};
const userDefs = [
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
];
for (const u of userDefs) {
const user = await prisma.user.create({
data: { tenantId: tenant.id, email: u.email, password: await hashPassword('admin123!'), firstName: u.first, lastName: u.last },
});
await prisma.userRole.create({ data: { userId: user.id, role: u.role } });
users[u.role] = user;
usersByEmail[u.email] = user;
console.log(`User: ${u.email} (${u.role})`);
}
// ─── Default Tenant Roles ──────────────────────────────
const tenantRoles: Record<string, any> = {};
for (const def of DEFAULT_ROLES) {
const role = await prisma.tenantRole.create({
data: {
tenantId: tenant.id,
name: def.name,
slug: def.slug,
description: def.description,
isSystem: true,
permissions: {
create: def.perms.map((p) => ({
module: p.module,
canView: p.canView,
canCreate: p.canCreate,
canUpdate: p.canUpdate,
canArchive: p.canArchive,
canApprove: p.canApprove,
canExport: p.canExport,
})),
},
},
});
tenantRoles[def.slug] = role;
}
console.log(`Tenant Roles: ${DEFAULT_ROLES.length} (${DEFAULT_ROLES.map((r) => r.slug).join(', ')})`);
// ─── Assign Tenant Roles to Users ─────────────────────
// Map of old role names to new tenant role slugs
const userRoleMap: Record<string, string> = {
tenant_admin: 'tenant_admin',
manager: 'manager',
technician: 'technician',
viewer: 'collector', // viewer user gets collector role for demo
};
// Assign roles from the map
for (const [oldRole, newRoleSlug] of Object.entries(userRoleMap)) {
if (users[oldRole] && tenantRoles[newRoleSlug]) {
await prisma.userTenantRole.create({
data: { userId: users[oldRole].id, tenantRoleId: tenantRoles[newRoleSlug].id },
});
}
}
// Additional assignments:
// - Assign technician users (collector, tech, tech2) to technician role
// - Assign viewer user to collector role
const technicianUsers = ['collector', 'tech', 'tech2'];
for (const email of technicianUsers) {
const userEmail = `${email}@demo-isp.com`;
const user = usersByEmail[userEmail];
if (user) {
const existing = await prisma.userTenantRole.findFirst({ where: { userId: user.id } });
if (!existing) {
await prisma.userTenantRole.create({
data: { userId: user.id, tenantRoleId: tenantRoles['technician'].id },
});
}
}
}
const viewerUserEmail = 'viewer@demo-isp.com';
const viewerUser = usersByEmail[viewerUserEmail];
if (viewerUser) {
const existing = await prisma.userTenantRole.findFirst({ where: { userId: viewerUser.id } });
if (!existing) {
await prisma.userTenantRole.create({
data: { userId: viewerUser.id, tenantRoleId: tenantRoles['collector'].id },
});
}
}
console.log('User-TenantRole assignments complete');
// ─── Areas ─────────────────────────────────────────────
const areas = await Promise.all([
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
prisma.area.create({ data: { tenantId: tenant.id, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
]);
console.log(`Areas: ${areas.length}`);
// ─── Plans ─────────────────────────────────────────────
const plans = await Promise.all([
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
prisma.plan.create({ data: { tenantId: tenant.id, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
]);
console.log(`Plans: ${plans.length}`);
// ─── Clients (20 clients across various areas/plans) ──
// Area center coordinates (Lipa City, Batangas area)
const areaCoords: [number, number][] = [
[14.0785, 121.1760], // Barangay 1 - Centro
[14.0820, 121.1800], // Barangay 2 - Poblacion
[14.0850, 121.1700], // Barangay 3 - San Isidro
[14.0750, 121.1720], // Barangay 4 - Riverside
[14.0900, 121.1780], // Barangay 5 - Hilltop
];
const clientDefs = [
{ first: 'Juan', last: 'Dela Cruz', phone: '09171234567', email: 'juan@email.com', address: '123 Rizal St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
{ first: 'Maria', last: 'Santos', phone: '09181234567', email: 'maria@email.com', address: '456 Mabini St, Centro', area: 0, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
{ first: 'Jose', last: 'Garcia', phone: '09191234567', email: 'jose@email.com', address: '789 Bonifacio St, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
{ first: 'Ana', last: 'Reyes', phone: '09201234567', email: 'ana@email.com', address: '12 Luna St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
{ first: 'Pedro', last: 'Aquino', phone: '09211234567', email: null, address: '34 Del Pilar St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: -0.002 },
{ first: 'Rosa', last: 'Mendoza', phone: '09221234567', email: 'rosa@email.com', address: '56 Quezon Ave, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: -0.003, lngOff: 0.001 },
{ first: 'Carlos', last: 'Bautista', phone: '09231234567', email: null, address: '78 Magsaysay Blvd, Riverside', area: 3, plan: 1, type: 'postpaid', latOff: 0.001, lngOff: 0.002 },
{ first: 'Elena', last: 'Villanueva', phone: '09241234567', email: 'elena@email.com', address: '90 Roxas St, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.002, lngOff: -0.003 },
{ first: 'Roberto', last: 'Tan', phone: '09251234567', email: 'roberto@email.com', address: '11 Laurel St, Hilltop', area: 4, plan: 4, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
{ first: 'Carmen', last: 'Lim', phone: '09261234567', email: 'carmen@email.com', address: '22 Osmena Ave, Hilltop', area: 4, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
{ first: 'Miguel', last: 'Ramos', phone: '09271234567', email: null, address: '33 Aguinaldo St, Centro', area: 0, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.001 },
{ first: 'Isabel', last: 'Torres', phone: '09281234567', email: 'isabel@email.com', address: '44 Andres Blvd, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.002, lngOff: 0.003 },
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
];
const clients: any[] = [];
let invoiceCount = 0;
const now = new Date();
for (let i = 0; i < clientDefs.length; i++) {
const c = clientDefs[i];
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
const plan = plans[c.plan];
const client = await prisma.client.create({
data: {
tenantId: tenant.id,
accountNumber,
firstName: c.first,
lastName: c.last,
phone: c.phone,
email: c.email,
address: c.address,
areaId: areas[c.area].id,
latitude: areaCoords[c.area][0] + c.latOff,
longitude: areaCoords[c.area][1] + c.lngOff,
},
});
clients.push(client);
// Create subscription
const installedAt = new Date(now);
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
const activatedAt = new Date(installedAt);
activatedAt.setDate(activatedAt.getDate() + 2);
await prisma.subscription.create({
data: {
tenantId: tenant.id,
clientId: client.id,
planId: plan.id,
type: c.type,
status: 'active',
installedAt,
activatedAt,
startDate: activatedAt,
},
});
// Create resolved installation + activation tickets
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'installation',
title: `Installation for ${c.first} ${c.last}`,
description: `Installation at ${c.address}`,
status: 'resolved',
priority: 'high',
resolvedAt: new Date(installedAt.getTime() + 86400000),
},
});
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: client.id,
createdById: users.tenant_admin.id,
assigneeId: users.technician.id,
type: 'activation',
title: `Activation for ${c.first} ${c.last}`,
status: 'resolved',
priority: 'high',
resolvedAt: activatedAt,
},
});
// Create 2 invoices per client
for (let m = 0; m < 2; m++) {
invoiceCount++;
const periodStart = new Date(activatedAt);
periodStart.setMonth(periodStart.getMonth() + m);
const periodEnd = new Date(periodStart);
periodEnd.setDate(periodEnd.getDate() + 30);
const dueDate = new Date(periodStart);
dueDate.setDate(dueDate.getDate() + 15);
const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance
const balance = isPaid ? 0 : Number(plan.price);
const invoice = await prisma.invoice.create({
data: {
tenantId: tenant.id,
clientId: client.id,
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
amount: plan.price,
balance,
status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
dueDate,
paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
periodStart,
periodEnd,
},
});
// Create payment for paid invoices
if (isPaid) {
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
const method = methods[Math.floor(Math.random() * methods.length)];
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
await prisma.payment.create({
data: {
tenantId: tenant.id,
clientId: client.id,
invoiceId: invoice.id,
collectedById: users.technician.id,
amount: plan.price,
method,
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
createdAt: paidDate,
},
});
}
}
}
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
// ─── Open support tickets ──────────────────────────────
const supportTickets = [
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high' },
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal' },
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent' },
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low' },
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' },
];
for (const t of supportTickets) {
await prisma.ticket.create({
data: {
tenantId: tenant.id,
clientId: clients[t.clientIdx].id,
createdById: users.tenant_admin.id,
type: 'support',
title: t.title,
description: t.desc,
priority: t.priority,
status: 'open',
},
});
}
console.log(`Support tickets: ${supportTickets.length}`);
// ─── Employees ─────────────────────────────────────────
const empDefs = [
{ first: 'Pedro', last: 'Cruz', position: 'Senior Technician', dept: 'Operations', salary: 18000 },
{ first: 'Jose', last: 'Garcia', position: 'Field Technician', dept: 'Operations', salary: 15000 },
{ first: 'Maria', last: 'Reyes', position: 'Operations Manager', dept: 'Management', salary: 30000 },
{ first: 'Juan', last: 'Santos', position: 'Collection Officer', dept: 'Finance', salary: 16000 },
{ first: 'Ana', last: 'De Leon', position: 'Billing Clerk', dept: 'Finance', salary: 14000 },
{ first: 'Luis', last: 'Mercado', position: 'Network Engineer', dept: 'Technical', salary: 25000 },
{ first: 'Sofia', last: 'Pascual', position: 'Customer Service', dept: 'Support', salary: 14000 },
];
const employees: any[] = [];
for (let i = 0; i < empDefs.length; i++) {
const e = empDefs[i];
const emp = await prisma.employee.create({
data: { tenantId: tenant.id, employeeNo: `E-${String(i + 1).padStart(4, '0')}`, firstName: e.first, lastName: e.last, position: e.position, department: e.dept, salary: e.salary },
});
employees.push(emp);
}
console.log(`Employees: ${employees.length}`);
// ─── Expenses ──────────────────────────────────────────
const expDefs = [
{ cat: 'utilities', desc: 'Electricity bill - March 2026', amount: 12500, status: 'approved', days: -15 },
{ cat: 'utilities', desc: 'Internet backbone ISP bill', amount: 35000, status: 'approved', days: -10 },
{ cat: 'supplies', desc: 'Fiber optic cables (500m)', amount: 8500, status: 'approved', days: -8 },
{ cat: 'maintenance', desc: 'OLT maintenance and cleaning', amount: 3500, status: 'approved', days: -5 },
{ cat: 'transport', desc: 'Fuel for service vehicles', amount: 4200, status: 'approved', days: -3 },
{ cat: 'equipment', desc: '10x Mikrotik hEX S routers', amount: 28000, status: 'approved', days: -2 },
{ cat: 'supplies', desc: 'Office supplies and printer ink', amount: 2100, status: 'pending', days: -1 },
{ cat: 'maintenance', desc: 'Generator repair', amount: 7800, status: 'pending', days: 0 },
{ cat: 'transport', desc: 'Technician transport allowance - April', amount: 6000, status: 'pending', days: 0 },
];
for (const e of expDefs) {
const expDate = new Date();
expDate.setDate(expDate.getDate() + e.days);
await prisma.expense.create({
data: {
tenantId: tenant.id,
createdById: users.manager.id,
approvedById: e.status === 'approved' ? users.tenant_admin.id : null,
category: e.cat,
description: e.desc,
amount: e.amount,
status: e.status,
expenseDate: expDate,
approvedAt: e.status === 'approved' ? expDate : null,
},
});
}
console.log(`Expenses: ${expDefs.length}`);
// ─── Company Accounts ──────────────────────────────────
// Company accounts will be created after CoA so we can link them
console.log('Company accounts: deferred to after CoA');
// ─── Fund Transfers ────────────────────────────────────
// Fund transfers deferred to after company accounts
console.log('Fund transfers: deferred');
// ─── Assets ────────────────────────────────────────────
const assetDefs = [
{ name: 'Huawei MA5608T OLT', cat: 'olt', serial: 'HW-OLT-001', price: 85000, status: 'in_use', loc: 'Main Office' },
{ name: 'Mikrotik CCR1009', cat: 'router', serial: 'MK-CCR-001', price: 32000, status: 'in_use', loc: 'Main Office' },
{ name: 'Mikrotik hEX S #1', cat: 'router', serial: 'MK-HEX-001', price: 2800, status: 'in_use', empIdx: 0 },
{ name: 'Mikrotik hEX S #2', cat: 'router', serial: 'MK-HEX-002', price: 2800, status: 'in_use', empIdx: 1 },
{ name: 'Mikrotik hEX S #3', cat: 'router', serial: 'MK-HEX-003', price: 2800, status: 'available', loc: 'Warehouse' },
{ name: 'OTDR Tester', cat: 'tool', serial: 'OTDR-001', price: 45000, status: 'in_use', empIdx: 0 },
{ name: 'Fiber Splicer', cat: 'tool', serial: 'FS-001', price: 65000, status: 'in_use', empIdx: 5 },
{ name: 'Honda XRM 125 (Field)', cat: 'vehicle', serial: 'MV-2024-001', price: 68000, status: 'in_use', empIdx: 0 },
{ name: 'Honda Wave 110 (Field)', cat: 'vehicle', serial: 'MV-2024-002', price: 55000, status: 'in_use', empIdx: 1 },
{ name: 'Dell Latitude 5540', cat: 'computer', serial: 'DELL-LAP-001', price: 48000, status: 'in_use', empIdx: 2 },
{ name: 'Fiber Cable Spool 1km', cat: 'cable', price: 12000, status: 'available', loc: 'Warehouse' },
{ name: 'Fiber Cable Spool 500m', cat: 'cable', price: 6500, status: 'available', loc: 'Warehouse' },
{ name: 'UPS 1500VA', cat: 'other', serial: 'UPS-001', price: 8500, status: 'in_use', loc: 'Main Office' },
{ name: 'Old Mikrotik RB750', cat: 'router', serial: 'MK-OLD-001', price: 1500, status: 'retired' },
];
for (const a of assetDefs) {
await prisma.asset.create({
data: {
tenantId: tenant.id,
name: a.name,
category: a.cat,
serialNumber: a.serial || null,
purchasePrice: a.price,
status: a.status,
location: a.loc || null,
assignedToId: a.empIdx !== undefined ? employees[a.empIdx].id : null,
},
});
}
console.log(`Assets: ${assetDefs.length}`);
// ─── Billing Settings ──────────────────────────────────
await prisma.billingSetting.create({
data: { tenantId: tenant.id, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
});
console.log('Billing settings created');
// ─── Chart of Accounts (auto-seeded by API, but seed defaults) ──
const coaDefs = [
{ code: '1000', name: 'Assets', type: 'asset', sys: true },
{ code: '1010', name: 'Cash on Hand', type: 'asset', sys: true },
{ code: '1020', name: 'GCash Business', type: 'asset', sys: true },
{ code: '1030', name: 'Maya Business', type: 'asset', sys: true },
{ code: '1040', name: 'Bank Account', type: 'asset', sys: true },
{ code: '1100', name: 'Accounts Receivable', type: 'asset', sys: true },
{ code: '1200', name: 'Equipment', type: 'asset', sys: true },
{ code: '2000', name: 'Liabilities', type: 'liability', sys: true },
{ code: '2010', name: 'Accounts Payable', type: 'liability', sys: true },
{ code: '3000', name: 'Equity', type: 'equity', sys: true },
{ code: '3010', name: "Owner's Equity", type: 'equity', sys: true },
{ code: '3020', name: 'Retained Earnings', type: 'equity', sys: true },
{ code: '4000', name: 'Revenue', type: 'revenue', sys: true },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', sys: true },
{ code: '4020', name: 'Installation Fees', type: 'revenue', sys: true },
{ code: '5000', name: 'Expenses', type: 'expense', sys: true },
{ code: '5010', name: 'Utilities Expense', type: 'expense', sys: true },
{ code: '5020', name: 'Salaries Expense', type: 'expense', sys: true },
{ code: '5030', name: 'Maintenance Expense', type: 'expense', sys: true },
{ code: '5040', name: 'Transport Expense', type: 'expense', sys: true },
{ code: '5050', name: 'Supplies Expense', type: 'expense', sys: true },
{ code: '5060', name: 'Equipment Expense', type: 'expense', sys: true },
];
for (const a of coaDefs) {
await prisma.chartOfAccount.create({
data: { tenantId: tenant.id, code: a.code, name: a.name, type: a.type, isSystem: a.sys },
});
}
console.log(`Chart of Accounts: ${coaDefs.length}`);
// ─── Custodial CoA per user ────────────────────────────
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
let custodialCode = 1500;
for (const u of userDefs) {
const user = users[u.role];
for (const m of methods) {
await prisma.chartOfAccount.create({
data: { tenantId: tenant.id, code: String(custodialCode++), name: `${u.first} ${u.last} - ${m}`, type: 'asset', isSystem: false },
});
}
}
console.log(`Custodial CoA accounts: ${userDefs.length * 4}`);
// ─── Company Accounts (linked to CoA) ──────────────────
const coa1010 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1010' } });
const coa1020 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1020' } });
const coa1030 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1030' } });
const coa1040 = await prisma.chartOfAccount.findFirst({ where: { tenantId: tenant.id, code: '1040' } });
const accts = await Promise.all([
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Cash on Hand', type: 'cash', balance: 15000, isSystem: true, chartOfAccountId: coa1010?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'GCash Business', type: 'e_wallet', accountNo: '09171234567', balance: 42500, chartOfAccountId: coa1020?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'Maya Business', type: 'e_wallet', accountNo: '09181234567', balance: 18200, chartOfAccountId: coa1030?.id } }),
prisma.companyAccount.create({ data: { tenantId: tenant.id, name: 'BDO Savings', type: 'bank', accountNo: '0012-3456-7890', balance: 285000, chartOfAccountId: coa1040?.id } }),
]);
console.log(`Company accounts: ${accts.length} (linked to CoA)`);
// ─── Fund Transfers ────────────────────────────────────
await prisma.fundTransfer.create({
data: { tenantId: tenant.id, fromAccountId: accts[1].id, toAccountId: accts[3].id, amount: 20000, description: 'GCash to BDO weekly transfer', transferredBy: users.tenant_admin.id },
});
await prisma.fundTransfer.create({
data: { tenantId: tenant.id, fromAccountId: accts[3].id, toAccountId: accts[0].id, amount: 5000, description: 'Petty cash replenishment', transferredBy: users.tenant_admin.id },
});
console.log('Fund transfers: 2');
// ─── Remittances ───────────────────────────────────────
await prisma.remittance.create({
data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() },
});
await prisma.remittance.create({
data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' },
});
console.log('Remittances: 2');
console.log('\n✅ Seed completed successfully!');
console.log(`\n📊 Summary:`);
console.log(` Tenant: ${tenant.name}`);
console.log(` Users: ${userDefs.length}`);
console.log(` Areas: ${areas.length}`);
console.log(` Plans: ${plans.length}`);
console.log(` Clients: ${clients.length} (with active subscriptions)`);
console.log(` Invoices: ${invoiceCount * 2} (paid + unpaid)`);
console.log(` Employees: ${empDefs.length}`);
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
console.log(` Assets: ${assetDefs.length}`);
console.log(` Company Accounts: ${accts.length}`);
console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
console.log(`🌐 Portal: C-000001 / 09171234567`);
}
main()
.catch((e) => { console.error('Seed failed:', e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });

19
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export { PrismaClient };
export * from '@prisma/client';

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src", "prisma"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,16 @@
export { Role, ALL_ROLES, satisfiesRole } from './roles';
export {
Permission, getPermissionsForRoles,
MODULES, MODULE_LABELS, ACTIONS, ACTION_LABELS, MODULE_ACTIONS,
DEFAULT_ROLE_PERMISSIONS,
} from './permissions';
export type { Module, Action, PermissionRow, PermissionString } from './permissions';
export {
SubscriptionStatus,
SubscriptionType,
InvoiceStatus,
TicketType,
TicketStatus,
PaymentMethod,
AccountStatus,
} from './statuses';

View File

@@ -0,0 +1,224 @@
import { Role } from './roles';
/**
* All modules available in the permission matrix.
* Each module can have: view, create, update, archive, approve, export actions.
*/
export const MODULES = [
'dashboard',
'clients',
'subscriptions',
'invoices',
'payments',
'tickets',
'employees',
'payroll',
'expenses',
'assets',
'accounts',
'fund_transfers',
'accounting',
'reports',
'areas',
'plans',
'settings',
'users',
] as const;
export type Module = (typeof MODULES)[number];
export const MODULE_LABELS: Record<Module, string> = {
dashboard: 'Dashboard',
clients: 'Clients',
subscriptions: 'Subscriptions',
invoices: 'Invoices',
payments: 'Payments',
tickets: 'Tickets',
employees: 'Employees',
payroll: 'Payroll',
expenses: 'Expenses',
assets: 'Assets',
accounts: 'Company Accounts',
fund_transfers: 'Fund Transfers',
accounting: 'Accounting',
reports: 'Reports',
areas: 'Areas',
plans: 'Plans',
settings: 'Settings',
users: 'Users',
};
export const ACTIONS = ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'] as const;
export type Action = (typeof ACTIONS)[number];
export const ACTION_LABELS: Record<Action, string> = {
canView: 'View',
canCreate: 'Create',
canUpdate: 'Update',
canArchive: 'Archive',
canApprove: 'Approve',
canExport: 'Export',
};
/**
* Defines which actions are applicable per module.
* Only these checkboxes should be shown/enforced in the matrix.
*/
export const MODULE_ACTIONS: Record<Module, readonly Action[]> = {
dashboard: ['canView'],
clients: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
subscriptions: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
invoices: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
payments: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
tickets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
employees: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
payroll: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
expenses: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
assets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
accounts: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
fund_transfers: ['canView', 'canCreate', 'canApprove', 'canExport'],
accounting: ['canView', 'canExport'],
reports: ['canView', 'canExport'],
areas: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
plans: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
settings: ['canView', 'canUpdate'],
users: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
};
/**
* Permission matrix type — one row per module with boolean actions.
*/
export interface PermissionRow {
module: Module;
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canArchive: boolean;
canApprove: boolean;
canExport: boolean;
}
/**
* Default permission matrices for system roles seeded per tenant.
*/
function allTrue(modules: readonly Module[], actions: readonly Action[]): PermissionRow[] {
return MODULES.map((mod) => ({
module: mod,
canView: actions.includes('canView') && modules.includes(mod),
canCreate: actions.includes('canCreate') && modules.includes(mod),
canUpdate: actions.includes('canUpdate') && modules.includes(mod),
canArchive: actions.includes('canArchive') && modules.includes(mod),
canApprove: actions.includes('canApprove') && modules.includes(mod),
canExport: actions.includes('canExport') && modules.includes(mod),
}));
}
const ALL_MODULES = [...MODULES] as Module[];
export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionRow[]> = {
tenant_admin: MODULES.map((mod) => ({
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: true,
canExport: true,
})),
manager: MODULES.map((mod) => {
const noAccess: Module[] = ['users'];
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
if (noAccess.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: mod === 'accounting' };
return {
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(mod),
canExport: true,
};
}),
technician: MODULES.map((mod) => {
// Technicians can create/update tickets and record payments in the field.
// They can VIEW clients, subscriptions, invoices for context but creating/updating
// those requires manager-level API access (@Roles('manager') on POST/PATCH).
// Assets require manager via class-level @Roles('manager').
// Subscriptions require manager for all endpoints.
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
const fullAccess: Module[] = ['tickets', 'payments'];
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (fullAccess.includes(mod)) return { module: mod, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
collector: MODULES.map((mod) => {
const canWrite: Module[] = ['payments'];
const canView: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
if (!canView.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
return {
module: mod,
canView: true,
canCreate: canWrite.includes(mod),
canUpdate: false,
canArchive: false,
canApprove: false,
canExport: false,
};
}),
};
// ─── Legacy support (for super_admin which uses old UserRole system) ───
export const Permission = {
READ_DASHBOARD: 'read:dashboard',
READ_REPORTS: 'read:reports',
READ_CLIENTS: 'read:clients',
WRITE_CLIENTS: 'write:clients',
DELETE_CLIENTS: 'delete:clients',
READ_SUBSCRIPTIONS: 'read:subscriptions',
WRITE_SUBSCRIPTIONS: 'write:subscriptions',
READ_INVOICES: 'read:invoices',
WRITE_INVOICES: 'write:invoices',
VOID_INVOICES: 'void:invoices',
READ_PAYMENTS: 'read:payments',
WRITE_PAYMENTS: 'write:payments',
APPROVE_REMITTANCES: 'approve:remittances',
READ_TICKETS: 'read:tickets',
WRITE_TICKETS: 'write:tickets',
ASSIGN_TICKETS: 'assign:tickets',
READ_EMPLOYEES: 'read:employees',
WRITE_EMPLOYEES: 'write:employees',
READ_PAYROLL: 'read:payroll',
WRITE_PAYROLL: 'write:payroll',
READ_EXPENSES: 'read:expenses',
WRITE_EXPENSES: 'write:expenses',
APPROVE_EXPENSES: 'approve:expenses',
READ_ASSETS: 'read:assets',
WRITE_ASSETS: 'write:assets',
READ_ACCOUNTS: 'read:accounts',
WRITE_ACCOUNTS: 'write:accounts',
TRANSFER_ACCOUNTS: 'transfer:accounts',
READ_ACCOUNTING: 'read:accounting',
WRITE_ACCOUNTING: 'write:accounting',
READ_SETTINGS: 'read:settings',
WRITE_SETTINGS: 'write:settings',
MANAGE_USERS: 'manage:users',
MANAGE_TENANTS: 'manage:tenants',
} as const;
export type PermissionString = (typeof Permission)[keyof typeof Permission];
/**
* Get permissions for super_admin (all permissions).
*/
export function getPermissionsForRoles(roles: string[]): string[] {
if (roles.includes(Role.SUPER_ADMIN)) {
return Object.values(Permission);
}
// For tenant users, permissions come from TenantRole → RolePermission in DB
return [];
}

View File

@@ -0,0 +1,36 @@
export const Role = {
SUPER_ADMIN: 'super_admin',
TENANT_ADMIN: 'tenant_admin',
MANAGER: 'manager',
TECHNICIAN: 'technician',
VIEWER: 'viewer',
} as const;
export type Role = (typeof Role)[keyof typeof Role];
export const ALL_ROLES: readonly Role[] = Object.values(Role);
/**
* Numeric hierarchy level per role.
* Higher number = more powerful role.
* Used for hierarchy-aware authorization checks.
*/
const ROLE_LEVEL: Record<string, number> = {
[Role.SUPER_ADMIN]: 100,
[Role.TENANT_ADMIN]: 80,
[Role.MANAGER]: 60,
[Role.TECHNICIAN]: 40,
[Role.VIEWER]: 20,
};
/**
* Check if any of the user's roles satisfies the required role level.
* A higher-level role always satisfies a lower-level requirement.
*
* Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40).
*/
export function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
const requiredLevel = ROLE_LEVEL[requiredRole];
if (requiredLevel === undefined) return false;
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
}

View File

@@ -0,0 +1,67 @@
export const SubscriptionStatus = {
PENDING: 'pending',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
} as const;
export type SubscriptionStatus =
(typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
export const SubscriptionType = {
PREPAID: 'prepaid',
POSTPAID: 'postpaid',
} as const;
export type SubscriptionType =
(typeof SubscriptionType)[keyof typeof SubscriptionType];
export const InvoiceStatus = {
DRAFT: 'draft',
SENT: 'sent',
PARTIAL: 'partial',
PAID: 'paid',
OVERDUE: 'overdue',
VOID: 'void',
} as const;
export type InvoiceStatus =
(typeof InvoiceStatus)[keyof typeof InvoiceStatus];
export const TicketType = {
INSTALLATION: 'installation',
ACTIVATION: 'activation',
SUPPORT: 'support',
MAINTENANCE: 'maintenance',
} as const;
export type TicketType = (typeof TicketType)[keyof typeof TicketType];
export const TicketStatus = {
OPEN: 'open',
IN_PROGRESS: 'in_progress',
RESOLVED: 'resolved',
CANCELLED: 'cancelled',
} as const;
export type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
export const PaymentMethod = {
GCASH: 'gcash',
MAYA: 'maya',
CASH: 'cash',
BANK_TRANSFER: 'bank_transfer',
} as const;
export type PaymentMethod =
(typeof PaymentMethod)[keyof typeof PaymentMethod];
export const AccountStatus = {
ACTIVE: 'active',
INACTIVE: 'inactive',
SUSPENDED: 'suspended',
} as const;
export type AccountStatus =
(typeof AccountStatus)[keyof typeof AccountStatus];

View File

@@ -0,0 +1,3 @@
export * from './constants';
export * from './schemas';
export * from './types';

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const registerTenantSchema = z.object({
tenantName: z.string().min(2, 'Tenant name must be at least 2 characters'),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-z0-9-]+$/,
'Slug must contain only lowercase letters, numbers, and hyphens',
),
adminEmail: z.string().email('Invalid email address'),
adminPassword: z.string().min(8, 'Password must be at least 8 characters'),
adminFirstName: z.string().min(1, 'First name is required'),
adminLastName: z.string().min(1, 'Last name is required'),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterTenantInput = z.infer<typeof registerTenantSchema>;

View File

@@ -0,0 +1,5 @@
export { loginSchema, registerTenantSchema } from './auth';
export type { LoginInput, RegisterTenantInput } from './auth';
export { createUserSchema, updateUserSchema } from './user';
export type { CreateUserInput, UpdateUserInput } from './user';

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { Role } from '../constants/roles';
const roleEnum = z.enum([
Role.SUPER_ADMIN,
Role.TENANT_ADMIN,
Role.MANAGER,
Role.TECHNICIAN,
Role.VIEWER,
]);
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
roles: z.array(roleEnum).min(1, 'At least one role is required'),
});
export const updateUserSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
roles: z.array(roleEnum).min(1).optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

View File

@@ -0,0 +1,36 @@
export interface ApiResponse<T = unknown> {
success: boolean;
data: T | null;
error: string | null;
meta?: PaginationMeta;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PaginationQuery {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface JwtPayload {
sub: string;
tenantId?: string | null;
roles: string[];
permissions: string[];
iat?: number;
exp?: number;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
mustChangePassword?: boolean;
}

View File

@@ -0,0 +1,7 @@
export type {
ApiResponse,
PaginationMeta,
PaginationQuery,
JwtPayload,
TokenResponse,
} from './api';

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,47 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AccountService } from './account.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import { TransferFundsDto } from './dto/transfer-funds.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('accounts')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload) {
return this.accountService.findAll(user.tenantId);
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
return this.accountService.create(user.tenantId, dto);
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountService.update(user.tenantId, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.accountService.remove(user.tenantId, id);
}
@Post('transfer')
async transfer(@CurrentUser() user: CurrentUserPayload, @Body() dto: TransferFundsDto) {
return this.accountService.transfer(user.tenantId, user.sub, dto);
}
@Get('transfers')
async getTransfers(@CurrentUser() user: CurrentUserPayload) {
return this.accountService.getTransfers(user.tenantId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { AccountingModule } from '../accounting/accounting.module';
@Module({
imports: [AccountingModule],
controllers: [AccountController],
providers: [AccountService],
exports: [AccountService],
})
export class AccountModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { JournalService } from '../accounting/journal.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import { TransferFundsDto } from './dto/transfer-funds.dto';
@Injectable()
export class AccountService {
constructor(
private readonly prisma: PrismaService,
private readonly journal: JournalService,
) {}
async findAll(tenantId: string) {
return this.prisma.companyAccount.findMany({
where: { tenantId },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
}
async create(tenantId: string, dto: CreateAccountDto) {
// Auto-generate a CoA code for this account
const existingCoa = await this.prisma.chartOfAccount.findMany({
where: { tenantId, type: 'asset', code: { startsWith: '10' } },
orderBy: { code: 'desc' },
take: 1,
});
const lastCode = existingCoa[0]?.code || '1040';
const nextCode = String(parseInt(lastCode) + 10);
// Create CoA entry first
const coa = await this.prisma.chartOfAccount.create({
data: { tenantId, code: nextCode, name: dto.name, type: 'asset', isSystem: false },
});
// Create company account linked to CoA
return this.prisma.companyAccount.create({
data: {
tenantId,
name: dto.name,
type: dto.type,
accountNo: dto.accountNo,
balance: dto.initialBalance || 0,
chartOfAccountId: coa.id,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAccountDto) {
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem && dto.name && dto.name !== account.name) {
throw new ForbiddenException('Cannot rename system account');
}
const updated = await this.prisma.companyAccount.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.accountNo !== undefined && { accountNo: dto.accountNo }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
// Sync CoA name if changed
if (dto.name && account.chartOfAccountId) {
await this.prisma.chartOfAccount.update({
where: { id: account.chartOfAccountId },
data: { name: dto.name },
}).catch(() => {});
}
return updated;
}
async remove(tenantId: string, id: string) {
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem) throw new ForbiddenException('Cannot delete system account (Cash on Hand)');
if (Number(account.balance) > 0) throw new BadRequestException('Transfer funds out before deleting');
// Delete linked CoA if exists
if (account.chartOfAccountId) {
await this.prisma.chartOfAccount.delete({ where: { id: account.chartOfAccountId } }).catch(() => {});
}
await this.prisma.companyAccount.delete({ where: { id } });
return { deleted: true };
}
async transfer(tenantId: string, transferredBy: string, dto: TransferFundsDto) {
const from = await this.prisma.companyAccount.findFirst({ where: { id: dto.fromAccountId, tenantId } });
const to = await this.prisma.companyAccount.findFirst({ where: { id: dto.toAccountId, tenantId } });
if (!from || !to) throw new NotFoundException('Account not found');
if (from.id === to.id) throw new BadRequestException('Cannot transfer to same account');
if (Number(from.balance) < dto.amount) throw new BadRequestException('Insufficient balance');
const [transfer] = await this.prisma.$transaction([
this.prisma.fundTransfer.create({
data: { tenantId, fromAccountId: dto.fromAccountId, toAccountId: dto.toAccountId, amount: dto.amount, description: dto.description, transferredBy },
}),
this.prisma.companyAccount.update({ where: { id: from.id }, data: { balance: { decrement: dto.amount } } }),
this.prisma.companyAccount.update({ where: { id: to.id }, data: { balance: { increment: dto.amount } } }),
]);
// Journal entry for fund transfer: DR destination CoA, CR source CoA
if (from.chartOfAccountId && to.chartOfAccountId) {
const fromCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: from.chartOfAccountId } });
const toCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: to.chartOfAccountId } });
if (fromCoa && toCoa) {
this.journal.createEntry(
tenantId,
`Fund transfer: ${from.name}${to.name}${dto.description ? `${dto.description}` : ''}`,
[
{ accountCode: toCoa.code, debit: dto.amount },
{ accountCode: fromCoa.code, credit: dto.amount },
],
{ reference: `TRF-${transfer.id.slice(0, 8)}`, sourceType: 'transfer', sourceId: transfer.id },
).catch(() => {});
}
}
return transfer;
}
async getTransfers(tenantId: string) {
return this.prisma.fundTransfer.findMany({
where: { tenantId },
include: {
fromAccount: { select: { name: true, type: true } },
toAccount: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
export class CreateAccountDto {
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['bank', 'e_wallet', 'cash']) type: string;
@IsOptional() @IsString() accountNo?: string;
@IsOptional() @IsNumber() initialBalance?: number;
}

View File

@@ -0,0 +1,8 @@
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID } from 'class-validator';
export class TransferFundsDto {
@IsUUID() fromAccountId: string;
@IsUUID() toAccountId: string;
@IsNumber() @IsPositive() amount: number;
@IsOptional() @IsString() description?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, IsOptional, IsBoolean, MinLength } from 'class-validator';
export class UpdateAccountDto {
@IsOptional() @IsString() @MinLength(2) name?: string;
@IsOptional() @IsString() accountNo?: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Post, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AccountingService } from './accounting.service';
import { CreateAccountDto } from './dto/create-coa.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('accounting')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class AccountingController {
constructor(private readonly accountingService: AccountingService) {}
@Get('chart-of-accounts')
async getCoA(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getChartOfAccounts(user.tenantId);
}
@Post('chart-of-accounts')
async createAccount(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
return this.accountingService.createAccount(user.tenantId, dto);
}
@Delete('chart-of-accounts/:id')
async deleteAccount(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.accountingService.deleteAccount(user.tenantId, id);
}
@Get('general-ledger')
async getLedger(@CurrentUser() user: CurrentUserPayload, @Query('accountId') accountId?: string) {
return this.accountingService.getGeneralLedger(user.tenantId, accountId);
}
@Get('trial-balance')
async getTrialBalance(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getTrialBalance(user.tenantId);
}
@Get('overview')
async getOverview(@CurrentUser() user: CurrentUserPayload) {
return this.accountingService.getAccountingOverview(user.tenantId);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AccountingController } from './accounting.controller';
import { AccountingService } from './accounting.service';
import { JournalService } from './journal.service';
@Module({
controllers: [AccountingController],
providers: [AccountingService, JournalService],
exports: [AccountingService, JournalService],
})
export class AccountingModule {}

View File

@@ -0,0 +1,209 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAccountDto } from './dto/create-coa.dto';
// Default Chart of Accounts for new tenants
const DEFAULT_COA = [
{ code: '1000', name: 'Assets', type: 'asset', isSystem: true },
{ code: '1010', name: 'Cash on Hand', type: 'asset', isSystem: true },
{ code: '1020', name: 'GCash Business', type: 'asset', isSystem: true },
{ code: '1030', name: 'Maya Business', type: 'asset', isSystem: true },
{ code: '1040', name: 'Bank Account', type: 'asset', isSystem: true },
{ code: '1100', name: 'Accounts Receivable', type: 'asset', isSystem: true },
{ code: '1200', name: 'Equipment', type: 'asset', isSystem: true },
{ code: '2000', name: 'Liabilities', type: 'liability', isSystem: true },
{ code: '2010', name: 'Accounts Payable', type: 'liability', isSystem: true },
{ code: '3000', name: 'Equity', type: 'equity', isSystem: true },
{ code: '3010', name: 'Owner\'s Equity', type: 'equity', isSystem: true },
{ code: '3020', name: 'Retained Earnings', type: 'equity', isSystem: true },
{ code: '4000', name: 'Revenue', type: 'revenue', isSystem: true },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', isSystem: true },
{ code: '4020', name: 'Installation Fees', type: 'revenue', isSystem: true },
{ code: '5000', name: 'Expenses', type: 'expense', isSystem: true },
{ code: '5010', name: 'Utilities Expense', type: 'expense', isSystem: true },
{ code: '5020', name: 'Salaries Expense', type: 'expense', isSystem: true },
{ code: '5030', name: 'Maintenance Expense', type: 'expense', isSystem: true },
{ code: '5040', name: 'Transport Expense', type: 'expense', isSystem: true },
{ code: '5050', name: 'Supplies Expense', type: 'expense', isSystem: true },
{ code: '5060', name: 'Equipment Expense', type: 'expense', isSystem: true },
];
@Injectable()
export class AccountingService {
constructor(private readonly prisma: PrismaService) {}
async getChartOfAccounts(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
orderBy: { code: 'asc' },
});
// Auto-seed if empty
if (accounts.length === 0) {
await this.seedDefaultAccounts(tenantId);
return this.prisma.chartOfAccount.findMany({ where: { tenantId }, orderBy: { code: 'asc' } });
}
return accounts;
}
async seedDefaultAccounts(tenantId: string) {
for (const acc of DEFAULT_COA) {
await this.prisma.chartOfAccount.upsert({
where: { tenantId_code: { tenantId, code: acc.code } },
create: { tenantId, ...acc },
update: {},
});
}
}
async createAccount(tenantId: string, dto: CreateAccountDto) {
const existing = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: dto.code },
});
if (existing) throw new ConflictException('Account code already exists');
return this.prisma.chartOfAccount.create({
data: { tenantId, code: dto.code, name: dto.name, type: dto.type, parentId: dto.parentId },
});
}
async deleteAccount(tenantId: string, id: string) {
const account = await this.prisma.chartOfAccount.findFirst({ where: { id, tenantId } });
if (!account) throw new NotFoundException('Account not found');
if (account.isSystem) throw new BadRequestException('Cannot delete system account');
const hasEntries = await this.prisma.journalLine.count({ where: { accountId: id } });
if (hasEntries > 0) throw new BadRequestException('Cannot delete account with journal entries');
await this.prisma.chartOfAccount.delete({ where: { id } });
return { deleted: true };
}
async getGeneralLedger(tenantId: string, accountId?: string) {
const where: any = {};
if (accountId) {
where.accountId = accountId;
} else {
where.journalEntry = { tenantId };
}
return this.prisma.journalLine.findMany({
where,
include: {
account: { select: { code: true, name: true, type: true } },
journalEntry: { select: { entryDate: true, description: true, reference: true } },
},
orderBy: { journalEntry: { entryDate: 'desc' } },
take: 100,
});
}
async getTrialBalance(tenantId: string) {
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: { select: { debit: true, credit: true } },
},
orderBy: { code: 'asc' },
});
return accounts.map((a) => {
const totalDebit = a.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = a.journalLines.reduce((s, l) => s + Number(l.credit), 0);
return {
id: a.id,
code: a.code,
name: a.name,
type: a.type,
debit: totalDebit,
credit: totalCredit,
balance: totalDebit - totalCredit,
};
}).filter((a) => a.debit > 0 || a.credit > 0);
}
async getAccountingOverview(tenantId: string) {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// All accounts with their full journal lines
const accounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId },
include: {
journalLines: {
select: { debit: true, credit: true, journalEntry: { select: { entryDate: true } } },
},
},
orderBy: { code: 'asc' },
});
// Compute balances by type (from all journal entries)
const byType: Record<string, { debit: number; credit: number; balance: number }> = {};
const cashAccounts: { code: string; name: string; balance: number }[] = [];
for (const acc of accounts) {
const totalDebit = acc.journalLines.reduce((s, l) => s + Number(l.debit), 0);
const totalCredit = acc.journalLines.reduce((s, l) => s + Number(l.credit), 0);
const balance = totalDebit - totalCredit;
if (!byType[acc.type]) byType[acc.type] = { debit: 0, credit: 0, balance: 0 };
byType[acc.type].debit += totalDebit;
byType[acc.type].credit += totalCredit;
byType[acc.type].balance += balance;
// Track individual cash accounts (codes 1010-1040)
if (['1010', '1020', '1030', '1040'].includes(acc.code)) {
cashAccounts.push({ code: acc.code, name: acc.name, balance });
}
}
// Monthly income (revenue credits this month)
const monthlyRevenue = accounts
.filter((a) => a.type === 'revenue')
.reduce((sum, acc) => {
const monthCredits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.credit), 0);
return sum + monthCredits;
}, 0);
// Monthly expenses (expense debits this month)
const monthlyExpenses = accounts
.filter((a) => a.type === 'expense')
.reduce((sum, acc) => {
const monthDebits = acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0);
return sum + monthDebits;
}, 0);
// Expense breakdown by category this month
const expenseBreakdown = accounts
.filter((a) => a.type === 'expense')
.map((acc) => ({
code: acc.code,
name: acc.name,
amount: acc.journalLines
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
.reduce((s, l) => s + Number(l.debit), 0),
}))
.filter((e) => e.amount > 0)
.sort((a, b) => b.amount - a.amount);
return {
totalAssets: byType['asset']?.balance || 0,
totalLiabilities: byType['liability']?.balance || 0,
totalEquity: byType['equity']?.balance || 0,
totalRevenue: byType['revenue']?.credit || 0,
totalExpenses: byType['expense']?.debit || 0,
accountsReceivable: accounts.find((a) => a.code === '1100')?.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0) || 0,
cashAccounts,
cashOnHand: cashAccounts.reduce((s, a) => s + a.balance, 0),
monthlyRevenue,
monthlyExpenses,
netIncome: monthlyRevenue - monthlyExpenses,
expenseBreakdown,
};
}
}

View File

@@ -0,0 +1,8 @@
import { IsString, MinLength, IsOptional, IsIn, IsUUID } from 'class-validator';
export class CreateAccountDto {
@IsString() @MinLength(3) code: string;
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['asset', 'liability', 'equity', 'revenue', 'expense']) type: string;
@IsOptional() @IsUUID() parentId?: string;
}

View File

@@ -0,0 +1,259 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface JournalLineInput {
accountCode: string;
debit?: number;
credit?: number;
}
// Maps payment method to CoA code suffix
const METHOD_SUFFIX: Record<string, string> = {
cash: 'Cash',
gcash: 'GCash',
maya: 'Maya',
bank_transfer: 'Bank',
};
const METHOD_LABEL: Record<string, string> = {
cash: 'Cash',
gcash: 'GCash',
maya: 'Maya',
bank_transfer: 'Bank Transfer',
};
// Company-level CoA codes per method
const COMPANY_COA: Record<string, string> = {
cash: '1010',
gcash: '1020',
maya: '1030',
bank_transfer: '1040',
};
@Injectable()
export class JournalService {
private readonly logger = new Logger(JournalService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Creates a journal entry with debit/credit lines.
*/
async createEntry(
tenantId: string,
description: string,
lines: JournalLineInput[],
options?: { reference?: string; sourceType?: string; sourceId?: string; createdById?: string },
) {
const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0);
const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0);
if (Math.abs(totalDebit - totalCredit) > 0.01) {
this.logger.warn(`Unbalanced entry: DR ${totalDebit} != CR ${totalCredit}${description}`);
return null;
}
const resolvedLines = [];
for (const line of lines) {
const account = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: line.accountCode },
});
if (!account) {
this.logger.warn(`CoA ${line.accountCode} not found for tenant ${tenantId}`);
return null;
}
resolvedLines.push({ accountId: account.id, debit: line.debit || 0, credit: line.credit || 0 });
}
return this.prisma.journalEntry.create({
data: {
tenantId,
description,
reference: options?.reference,
sourceType: options?.sourceType,
sourceId: options?.sourceId,
createdById: options?.createdById,
lines: { create: resolvedLines },
},
include: { lines: true },
});
}
/**
* Create custodial CoA accounts for a user.
* Pattern: "UserName - Cash" with code 15XX where XX = sequential.
*/
async createCustodialAccounts(tenantId: string, userId: string, userName: string) {
// Find next available code block in 15XX range
const existing = await this.prisma.chartOfAccount.findMany({
where: { tenantId, code: { startsWith: '15' } },
orderBy: { code: 'desc' },
take: 1,
});
const baseCode = existing[0] ? parseInt(existing[0].code) + 10 : 1500;
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
const created = [];
for (let i = 0; i < methods.length; i++) {
const code = String(baseCode + i);
const name = `${userName} - ${methods[i]}`;
const account = await this.prisma.chartOfAccount.upsert({
where: { tenantId_code: { tenantId, code } },
create: { tenantId, code, name, type: 'asset', isSystem: false },
update: {},
});
created.push(account);
}
return created;
}
/**
* Get the custodial CoA code for a user + method.
* Looks up "UserName - Cash/GCash/Maya/Bank" in the CoA.
*/
async getCustodialCode(tenantId: string, collectorId: string, method: string): Promise<string | null> {
const user = await this.prisma.user.findUnique({ where: { id: collectorId } });
if (!user) return null;
const suffix = METHOD_SUFFIX[method] || 'Cash';
const searchName = `${user.firstName} ${user.lastName} - ${suffix}`;
const account = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, name: searchName },
});
return account?.code || null;
}
/**
* Payment collected → DR collector's custodial CoA, CR Accounts Receivable.
* Money sits in collector's custody until remittance is approved.
*/
async journalForPayment(
tenantId: string,
paymentId: string,
amount: number,
method: string,
context: { invoiceNumber?: string; clientName?: string; collectorId?: string; collectorName?: string },
) {
let debitCode: string;
if (context.collectorId) {
let custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
if (!custodialCode && context.collectorName) {
await this.createCustodialAccounts(tenantId, context.collectorId, context.collectorName);
custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
}
debitCode = custodialCode || COMPANY_COA[method] || '1010';
} else {
debitCode = COMPANY_COA[method] || '1010';
}
const description = [
`Payment collected via ${METHOD_LABEL[method] || method}`,
context.collectorName ? `by ${context.collectorName}` : '',
context.clientName ? `from ${context.clientName}` : '',
context.invoiceNumber ? `for ${context.invoiceNumber}` : '',
].filter(Boolean).join(' ');
return this.createEntry(tenantId, description, [
{ accountCode: debitCode, debit: amount },
{ accountCode: '1100', credit: amount },
], { reference: context.invoiceNumber, sourceType: 'payment', sourceId: paymentId });
}
/**
* Remittance approved → DR company CoA, CR collector's custodial CoA.
* Clears money from collector custody into company books.
*/
async journalForRemittance(
tenantId: string,
remittanceId: string,
collectorId: string,
collectorName: string,
paymentsByMethod: { method: string; total: number }[],
) {
const lines: JournalLineInput[] = [];
for (const pm of paymentsByMethod) {
let custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
// Auto-create custodial accounts if they don't exist yet
if (!custodialCode) {
this.logger.warn(`Custodial account missing for ${collectorName} (${pm.method}) — auto-creating`);
await this.createCustodialAccounts(tenantId, collectorId, collectorName);
custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
}
const companyCode = COMPANY_COA[pm.method] || '1010';
if (custodialCode) {
lines.push({ accountCode: companyCode, debit: pm.total });
lines.push({ accountCode: custodialCode, credit: pm.total });
} else {
this.logger.error(`Still no custodial account for ${collectorName} (${pm.method}) after auto-create`);
}
}
if (lines.length === 0) {
this.logger.warn(`No journal lines for remittance ${remittanceId} — skipping`);
return null;
}
const totalAmount = paymentsByMethod.reduce((s, p) => s + p.total, 0);
return this.createEntry(
tenantId,
`Remittance approved — ${collectorName} cleared PHP ${totalAmount.toLocaleString()}`,
lines,
{ reference: `REM-${remittanceId.slice(0, 8)}`, sourceType: 'remittance', sourceId: remittanceId },
);
}
/** Invoice issued → DR Accounts Receivable (1100), CR Service Revenue (4010) */
async journalForInvoice(tenantId: string, invoiceId: string, invoiceNumber: string, amount: number) {
return this.createEntry(tenantId, `Invoice ${invoiceNumber} issued`, [
{ accountCode: '1100', debit: amount },
{ accountCode: '4010', credit: amount },
], { reference: invoiceNumber, sourceType: 'invoice', sourceId: invoiceId });
}
/**
* Update CompanyAccount.balance when a journal entry affects cash accounts.
* Called after remittance confirmation (increment) and expense approval (decrement).
*/
async updateCompanyAccountBalance(
tenantId: string,
coaCode: string,
amount: number,
direction: 'increment' | 'decrement',
) {
const coa = await this.prisma.chartOfAccount.findFirst({
where: { tenantId, code: coaCode },
});
if (!coa) return;
const account = await this.prisma.companyAccount.findFirst({
where: { tenantId, chartOfAccountId: coa.id, isActive: true },
});
if (!account) return;
await this.prisma.companyAccount.update({
where: { id: account.id },
data: { balance: { [direction]: amount } },
});
}
/** Expense approved → DR Expense Category, CR Cash (1010) */
async journalForExpense(tenantId: string, expenseId: string, amount: number, category: string) {
const expenseAccountCode: Record<string, string> = {
utilities: '5010', salary: '5020', maintenance: '5030',
transport: '5040', supplies: '5050', equipment: '5060', other: '5000',
};
return this.createEntry(tenantId, `Expense: ${category}`, [
{ accountCode: expenseAccountCode[category] || '5000', debit: amount },
{ accountCode: '1010', credit: amount },
], { sourceType: 'expense', sourceId: expenseId });
}
}

81
src/app.module.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { TenantModule } from './tenant/tenant.module';
import { AreaModule } from './area/area.module';
import { PlanModule } from './plan/plan.module';
import { ClientModule } from './client/client.module';
import { TicketModule } from './ticket/ticket.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { InvoiceModule } from './invoice/invoice.module';
import { PaymentModule } from './payment/payment.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ReportModule } from './report/report.module';
import { NotificationModule } from './notification/notification.module';
import { AuditModule } from './audit/audit.module';
import { EmployeeModule } from './employee/employee.module';
import { ExpenseModule } from './expense/expense.module';
import { AccountModule } from './account/account.module';
import { AssetModule } from './asset/asset.module';
import { PortalModule } from './portal/portal.module';
import { BillingModule } from './billing/billing.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { AccountingModule } from './accounting/accounting.module';
import { PayrollModule } from './payroll/payroll.module';
import { RoleModule } from './role/role.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { AccessGuard } from './common/guards/access.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
}),
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1000, limit: 50 }, // 50 req/sec
{ name: 'medium', ttl: 60000, limit: 500 }, // 500 req/min
]),
PrismaModule,
HealthModule,
AuthModule,
UserModule,
TenantModule,
AreaModule,
PlanModule,
ClientModule,
TicketModule,
SubscriptionModule,
InvoiceModule,
PaymentModule,
DashboardModule,
ReportModule,
NotificationModule,
AuditModule,
EmployeeModule,
ExpenseModule,
AccountModule,
AssetModule,
PortalModule,
BillingModule,
SchedulerModule,
AccountingModule,
PayrollModule,
RoleModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: AccessGuard },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
],
})
export class AppModule {}

View File

@@ -0,0 +1,63 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AreaService } from './area.service';
import { CreateAreaDto } from './dto/create-area.dto';
import { UpdateAreaDto } from './dto/update-area.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('areas')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class AreaController {
constructor(private readonly areaService: AreaService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload) {
return this.areaService.findAll(user.tenantId);
}
@Get(':id')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.areaService.findById(user.tenantId, id);
}
@Post()
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateAreaDto,
) {
return this.areaService.create(user.tenantId, dto);
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@Body() dto: UpdateAreaDto,
) {
return this.areaService.update(user.tenantId, id, dto);
}
@Delete(':id')
async remove(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.areaService.remove(user.tenantId, id);
}
}

10
src/area/area.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AreaController } from './area.controller';
import { AreaService } from './area.service';
@Module({
controllers: [AreaController],
providers: [AreaService],
exports: [AreaService],
})
export class AreaModule {}

105
src/area/area.service.ts Normal file
View File

@@ -0,0 +1,105 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAreaDto } from './dto/create-area.dto';
import { UpdateAreaDto } from './dto/update-area.dto';
@Injectable()
export class AreaService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string) {
const db = this.prisma.forTenant(tenantId);
return db.area.findMany({
orderBy: { name: 'asc' },
include: {
_count: { select: { clients: true } },
},
});
}
async findById(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const area = await db.area.findFirst({
where: { id },
include: {
_count: { select: { clients: true } },
},
});
if (!area) {
throw new NotFoundException('Area not found');
}
return area;
}
async create(tenantId: string, dto: CreateAreaDto) {
const existing = await this.prisma.area.findFirst({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new ConflictException('Area name already exists');
}
return this.prisma.area.create({
data: {
tenantId,
name: dto.name,
description: dto.description,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAreaDto) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.area.findFirst({ where: { id } });
if (!existing) {
throw new NotFoundException('Area not found');
}
if (dto.name && dto.name !== existing.name) {
const duplicate = await this.prisma.area.findFirst({
where: { tenantId, name: dto.name },
});
if (duplicate) {
throw new ConflictException('Area name already exists');
}
}
return this.prisma.area.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
async remove(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.area.findFirst({
where: { id },
include: { _count: { select: { clients: true } } },
});
if (!existing) {
throw new NotFoundException('Area not found');
}
if (existing._count.clients > 0) {
throw new ConflictException(
'Cannot delete area with assigned clients. Deactivate it instead.',
);
}
await this.prisma.area.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength, IsOptional } from 'class-validator';
export class CreateAreaDto {
@IsString()
@MinLength(2)
name: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
export class UpdateAreaDto {
@IsOptional()
@IsString()
@MinLength(2)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('assets')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class AssetController {
constructor(private readonly assetService: AssetService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('category') category?: string, @Query('status') status?: string) {
return this.assetService.findAll(user.tenantId, { category, status });
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAssetDto) {
return this.assetService.create(user.tenantId, dto);
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAssetDto) {
return this.assetService.update(user.tenantId, id, dto);
}
}

10
src/asset/asset.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
import { AssetService } from './asset.service';
@Module({
controllers: [AssetController],
providers: [AssetService],
exports: [AssetService],
})
export class AssetModule {}

View File

@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
@Injectable()
export class AssetService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string, filters?: { category?: string; status?: string }) {
return this.prisma.asset.findMany({
where: {
tenantId,
...(filters?.category && { category: filters.category }),
...(filters?.status && { status: filters.status }),
},
include: { assignedTo: { select: { id: true, firstName: true, lastName: true, employeeNo: true } } },
orderBy: { createdAt: 'desc' },
});
}
async create(tenantId: string, dto: CreateAssetDto) {
return this.prisma.asset.create({
data: {
tenantId,
name: dto.name,
category: dto.category,
serialNumber: dto.serialNumber,
purchaseDate: dto.purchaseDate ? new Date(dto.purchaseDate) : null,
purchasePrice: dto.purchasePrice || null,
location: dto.location,
notes: dto.notes,
},
});
}
async update(tenantId: string, id: string, dto: UpdateAssetDto) {
const existing = await this.prisma.asset.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException('Asset not found');
return this.prisma.asset.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.status && { status: dto.status }),
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId || null }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.notes !== undefined && { notes: dto.notes }),
},
include: { assignedTo: { select: { id: true, firstName: true, lastName: true } } },
});
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
export class CreateAssetDto {
@IsString() @MinLength(2) name: string;
@IsString() @IsIn(['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other']) category: string;
@IsOptional() @IsString() serialNumber?: string;
@IsOptional() @IsString() purchaseDate?: string;
@IsOptional() @IsNumber() purchasePrice?: number;
@IsOptional() @IsString() location?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsOptional, IsIn, IsUUID } from 'class-validator';
export class UpdateAssetDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() @IsIn(['available', 'in_use', 'maintenance', 'retired']) status?: string;
@IsOptional() @IsUUID() assignedToId?: string;
@IsOptional() @IsString() location?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AuditEntry {
tenantId: string;
userId: string;
action: string;
entity: string;
entityId: string;
details?: Record<string, unknown>;
ipAddress?: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(entry: AuditEntry) {
return this.prisma.$queryRawUnsafe(
`INSERT INTO audit_logs (id, "tenantId", "userId", action, entity, "entityId", details, "ipAddress", "createdAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6::jsonb, $7, NOW())`,
entry.tenantId,
entry.userId,
entry.action,
entry.entity,
entry.entityId,
JSON.stringify(entry.details || {}),
entry.ipAddress || null,
);
}
async findByTenant(tenantId: string, limit = 50) {
return this.prisma.$queryRawUnsafe(
`SELECT * FROM audit_logs WHERE "tenantId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
tenantId,
limit,
);
}
}

View File

@@ -0,0 +1,62 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('register')
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
async register(@Body() dto: RegisterTenantDto) {
return this.authService.registerTenant(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Post('logout')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async logout(@Req() req: any) {
await this.authService.logout(req.user.sub);
return { message: 'Logged out successfully' };
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.sub);
}
@Post('change-password')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
}
}

26
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

267
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,267 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto): Promise<TokenResponse> {
const user = await this.prisma.user.findFirst({
where: { email: dto.email, isActive: true },
include: { roles: true, tenant: true },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Super admin may not have a tenant; regular users must have active tenant
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordValid = await bcrypt.compare(dto.password, user.password);
if (!passwordValid) {
throw new UnauthorizedException('Invalid credentials');
}
const roles = user.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
...tokens,
mustChangePassword: user.mustChangePassword,
};
}
async registerTenant(dto: RegisterTenantDto) {
const existing = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) {
throw new ConflictException('Tenant slug already taken');
}
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
const tenant = await this.prisma.tenant.create({
data: {
name: dto.tenantName,
slug: dto.slug,
settings: {
companyName: dto.tenantName,
currency: 'PHP',
timezone: 'Asia/Manila',
},
users: {
create: {
email: dto.adminEmail,
password: hashedPassword,
firstName: dto.adminFirstName,
lastName: dto.adminLastName,
roles: {
create: { role: 'tenant_admin' },
},
},
},
},
include: {
users: {
include: { roles: true },
},
},
});
const admin = tenant.users[0]!;
const roles = admin.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: admin.id,
tenantId: tenant.id,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
},
user: {
id: admin.id,
email: admin.email,
firstName: admin.firstName,
lastName: admin.lastName,
roles,
},
...tokens,
};
}
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
const stored = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!stored || stored.expiresAt < new Date()) {
if (stored) {
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
}
throw new UnauthorizedException('Invalid or expired refresh token');
}
// Delete the used refresh token (rotation)
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
const user = await this.prisma.user.findUnique({
where: { id: stored.userId },
include: { roles: true, tenant: true },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Account is inactive');
}
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Account is inactive');
}
const roles = user.roles.map((r) => r.role);
return this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
}
async logout(userId: string): Promise<void> {
await this.prisma.refreshToken.deleteMany({
where: { userId },
});
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: true,
tenant: { select: { id: true, name: true, slug: true, settings: true } },
tenantRoles: {
include: {
tenantRole: {
include: { permissions: true },
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const roles = user.roles.map((r) => r.role);
const isSuperAdmin = roles.includes('super_admin');
// Resolve permission matrix from tenant roles
const accessMap: Record<string, Record<string, boolean>> = {};
if (!isSuperAdmin) {
for (const utr of user.tenantRoles) {
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
for (const p of utr.tenantRole.permissions) {
if (!accessMap[p.module]) {
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}
if (p.canView) accessMap[p.module].canView = true;
if (p.canCreate) accessMap[p.module].canCreate = true;
if (p.canUpdate) accessMap[p.module].canUpdate = true;
if (p.canArchive) accessMap[p.module].canArchive = true;
if (p.canApprove) accessMap[p.module].canApprove = true;
if (p.canExport) accessMap[p.module].canExport = true;
}
}
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mustChangePassword: user.mustChangePassword,
roles,
permissions: getPermissionsForRoles(roles),
accessMap: isSuperAdmin ? 'all' : accessMap,
tenantRoles: user.tenantRoles.map((tr) => ({
id: tr.tenantRole.id,
name: tr.tenantRole.name,
slug: tr.tenantRole.slug,
})),
tenant: user.tenant,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
const hashed = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: { password: hashed, mustChangePassword: false },
});
return { success: true };
}
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
const jti = crypto.randomUUID();
const accessToken = this.jwt.sign({ ...payload, jti });
const refreshJti = crypto.randomUUID();
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
});
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const expiresAt = new Date();
const days = parseInt(expiresIn) || 7;
expiresAt.setDate(expiresAt.getDate() + days);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: payload.sub,
expiresAt,
},
});
return { accessToken, refreshToken };
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(8)
newPassword: string;
}

10
src/auth/dto/login.dto.ts Normal file
View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,30 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterTenantDto {
@IsString()
@MinLength(2)
tenantName: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@Matches(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
slug: string;
@IsEmail()
adminEmail: string;
@IsString()
@MinLength(8)
adminPassword: string;
@IsString()
@MinLength(1)
adminFirstName: string;
@IsString()
@MinLength(1)
adminLastName: string;
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '@fiberops/shared';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
});
}
validate(payload: JwtPayload) {
return {
sub: payload.sub,
tenantId: payload.tenantId || null,
roles: payload.roles || [],
permissions: payload.permissions || [],
};
}
}

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BillingService } from './billing.service';
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('billing-settings')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('tenant_admin')
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Get()
async getSettings(@CurrentUser() user: CurrentUserPayload) {
return this.billingService.getSettings(user.tenantId);
}
@Patch()
async updateSettings(@CurrentUser() user: CurrentUserPayload, @Body() dto: UpdateBillingSettingsDto) {
return this.billingService.updateSettings(user.tenantId, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
async getSettings(tenantId: string) {
let settings = await this.prisma.billingSetting.findUnique({ where: { tenantId } });
if (!settings) {
settings = await this.prisma.billingSetting.create({ data: { tenantId } });
}
return settings;
}
async updateSettings(tenantId: string, dto: UpdateBillingSettingsDto) {
await this.prisma.billingSetting.upsert({
where: { tenantId },
create: { tenantId, ...dto },
update: dto,
});
return this.getSettings(tenantId);
}
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateBillingSettingsDto {
@IsOptional() @IsBoolean() autoGenerate?: boolean;
@IsOptional() @IsInt() @Min(0) @Max(90) gracePeriodDays?: number;
@IsOptional() @IsInt() @Min(1) @Max(60) dueDateOffsetDays?: number;
@IsOptional() @IsNumber() @Min(0) @Max(100) lateFeePercent?: number;
@IsOptional() @IsString() invoicePrefix?: string;
}

View File

@@ -0,0 +1,65 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ClientService } from './client.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('clients')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class ClientController {
constructor(private readonly clientService: ClientService) {}
@Get()
@Roles('technician')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('areaId') areaId?: string,
@Query('status') status?: string,
@Query('search') search?: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.clientService.findAll(user.tenantId, { areaId, status, search, page, limit });
}
@Get(':id')
@Roles('technician')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.clientService.findById(user.tenantId, id);
}
@Post()
@Roles('manager')
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateClientDto,
) {
return this.clientService.create(user.tenantId, user.sub, dto);
}
@Patch(':id')
@Roles('manager')
async update(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
@Body() dto: UpdateClientDto,
) {
return this.clientService.update(user.tenantId, id, dto);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ClientController } from './client.controller';
import { ClientService } from './client.service';
import { TicketModule } from '../ticket/ticket.module';
@Module({
imports: [TicketModule],
controllers: [ClientController],
providers: [ClientService],
exports: [ClientService],
})
export class ClientModule {}

View File

@@ -0,0 +1,164 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TicketService } from '../ticket/ticket.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
@Injectable()
export class ClientService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
) {}
async findAll(tenantId: string, filters?: { areaId?: string; status?: string; search?: string; page?: number; limit?: number }) {
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
const db = this.prisma.forTenant(tenantId);
const where = {
...(filters?.areaId && { areaId: filters.areaId }),
...(filters?.status && { status: filters.status }),
...(filters?.search && {
OR: [
{ firstName: { contains: filters.search, mode: 'insensitive' as any } },
{ lastName: { contains: filters.search, mode: 'insensitive' as any } },
{ accountNumber: { contains: filters.search, mode: 'insensitive' as any } },
{ email: { contains: filters.search, mode: 'insensitive' as any } },
],
}),
};
const [items, total] = await Promise.all([
db.client.findMany({
where,
skip,
take,
include: {
area: { select: { id: true, name: true } },
_count: { select: { subscriptions: true, tickets: true } },
},
orderBy: { createdAt: 'desc' },
}),
db.client.count({ where }),
]);
return paginatedResult(items, total, page, limit);
}
async findById(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const client = await db.client.findFirst({
where: { id },
include: {
area: { select: { id: true, name: true } },
subscriptions: {
include: { plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } } },
orderBy: { createdAt: 'desc' },
},
tickets: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
assignee: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
if (!client) {
throw new NotFoundException('Client not found');
}
return client;
}
async create(tenantId: string, createdById: string, dto: CreateClientDto) {
// Generate account number
const count = await this.prisma.client.count({ where: { tenantId } });
const accountNumber = `C-${String(count + 1).padStart(6, '0')}`;
// Check unique account number
const existingAccount = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (existingAccount) {
throw new ConflictException('Account number conflict, please retry');
}
const client = await this.prisma.client.create({
data: {
tenantId,
accountNumber,
firstName: dto.firstName,
lastName: dto.lastName,
email: dto.email,
phone: dto.phone,
address: dto.address,
areaId: dto.areaId,
},
include: {
area: { select: { id: true, name: true } },
},
});
// Validate plan exists
const plan = await this.prisma.plan.findFirst({
where: { id: dto.planId, tenantId, isActive: true },
});
if (!plan) {
throw new NotFoundException('Plan not found or inactive');
}
// Auto-create subscription (onboarding = client + plan + type)
await this.prisma.subscription.create({
data: {
tenantId,
clientId: client.id,
planId: dto.planId,
type: dto.subscriptionType,
status: 'pending',
},
});
// Auto-create INSTALLATION ticket → kicks off the workflow
// Postpaid: install → activation ticket → activate + invoice
// Prepaid: install → invoice → payment → activation ticket → activate + next invoice
await this.ticketService.createSystemTicket(tenantId, createdById, {
clientId: client.id,
type: 'installation',
title: `Installation for ${client.firstName} ${client.lastName}`,
description: `New client installation at ${client.address}. Plan: ${plan.name} (${dto.subscriptionType})`,
});
return client;
}
async update(tenantId: string, id: string, dto: UpdateClientDto) {
const db = this.prisma.forTenant(tenantId);
const existing = await db.client.findFirst({ where: { id } });
if (!existing) {
throw new NotFoundException('Client not found');
}
return this.prisma.client.update({
where: { id },
data: {
...(dto.firstName && { firstName: dto.firstName }),
...(dto.lastName && { lastName: dto.lastName }),
...(dto.email !== undefined && { email: dto.email }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.address && { address: dto.address }),
...(dto.areaId !== undefined && { areaId: dto.areaId }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
...(dto.status && { status: dto.status }),
},
include: {
area: { select: { id: true, name: true } },
},
});
}
}

View File

@@ -0,0 +1,34 @@
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn } from 'class-validator';
export class CreateClientDto {
@IsString()
@MinLength(1)
firstName: string;
@IsString()
@MinLength(1)
lastName: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsString()
@MinLength(5)
address: string;
@IsOptional()
@IsUUID()
areaId?: string;
@IsUUID()
planId: string;
@IsString()
@IsIn(['prepaid', 'postpaid'])
subscriptionType: string;
}

View File

@@ -0,0 +1,47 @@
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn, IsNumber, Min, Max } from 'class-validator';
export class UpdateClientDto {
@IsOptional()
@IsString()
@MinLength(1)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
lastName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
@MinLength(5)
address?: string;
@IsOptional()
@IsUUID()
areaId?: string;
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@IsString()
@IsIn(['active', 'inactive', 'suspended'])
status?: string;
}

View File

@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common';
export interface AccessRequirement {
module: string;
action: 'canView' | 'canCreate' | 'canUpdate' | 'canArchive' | 'canApprove' | 'canExport';
}
export const ACCESS_KEY = 'access_requirement';
/**
* Require the user's tenant role to grant a specific module+action.
* Example: @RequireAccess('clients', 'canCreate')
*/
export const RequireAccess = (module: string, action: AccessRequirement['action']) =>
SetMetadata(ACCESS_KEY, { module, action } as AccessRequirement);

View File

@@ -0,0 +1,20 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
sub: string;
tenantId: string;
roles: string[];
permissions: string[];
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload;
// For tenantId, prefer request.tenantId which TenantGuard may have set (e.g., super_admin x-tenant-id header)
if (data === 'tenantId') {
return request.tenantId || user?.tenantId;
}
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@fiberops/shared';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,55 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export interface PaginatedResult<T> {
items: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function paginationArgs(dto: PaginationDto) {
const page = dto.page || 1;
const limit = dto.limit || 20;
return {
skip: (page - 1) * limit,
take: limit,
page,
limit,
};
}
export function paginatedResult<T>(
items: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
return {
items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

View File

@@ -0,0 +1,39 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiResponse } from '@fiberops/shared';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as { message?: string }).message || message;
} else {
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
}
const body: ApiResponse = {
success: false,
data: null,
error: Array.isArray(message) ? message.join(', ') : message,
};
response.status(status).json(body);
}
}

View File

@@ -0,0 +1,53 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ACCESS_KEY, AccessRequirement } from '../decorators/access.decorator';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AccessGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<AccessRequirement>(ACCESS_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @RequireAccess decorator → allow
if (!requirement) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) return false;
// Super admin bypasses all access checks
if (user.roles?.includes('super_admin')) return true;
// Resolve user's tenant role permissions from DB
const assignments = await this.prisma.userTenantRole.findMany({
where: { userId: user.sub },
include: {
tenantRole: {
include: { permissions: true },
},
},
});
// Check if any assigned role grants the required module+action
for (const a of assignments) {
if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue;
for (const p of a.tenantRole.permissions) {
if (p.module === requirement.module && p[requirement.action]) {
return true;
}
}
}
throw new ForbiddenException(
`Access denied: requires ${requirement.action} on ${requirement.module}`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) return false;
// Super admin bypasses all permission checks
if (user.roles?.includes('super_admin')) return true;
const userPerms: string[] = user.permissions || [];
const missing = required.filter((p) => !userPerms.includes(p));
if (missing.length > 0) {
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role, satisfiesRole } from '@fiberops/shared';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.roles) {
return false;
}
// Hierarchy-aware check: user with 'manager' satisfies 'technician' requirement
return requiredRoles.some((role) => satisfiesRole(user.roles, role));
}
}

View File

@@ -0,0 +1,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
}
// Super admin: can switch tenant context via x-tenant-id header
const isSuperAdmin = user.roles?.includes('super_admin');
if (isSuperAdmin) {
const headerTenantId = request.headers['x-tenant-id'];
// Use header tenant if provided, otherwise fall back to user's own tenant (if any)
request.tenantId = headerTenantId || user.tenantId || null;
return true;
}
// Regular users must have a tenantId from JWT
if (!user.tenantId) {
throw new ForbiddenException('Tenant context required');
}
request.tenantId = user.tenantId;
return true;
}
}

View File

@@ -0,0 +1,24 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApiResponse } from '@fiberops/shared';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
error: null,
})),
);
}
}

View File

@@ -0,0 +1,20 @@
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
try {
return this.schema.parse(value);
} catch (error) {
if (error instanceof ZodError) {
const messages = error.errors.map(
(e) => `${e.path.join('.')}: ${e.message}`,
);
throw new BadRequestException(messages.join('; '));
}
throw new BadRequestException('Validation failed');
}
}
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DashboardService } from './dashboard.service';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('dashboard')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('kpis')
@Roles('manager')
async getKpis(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getKpis(user.tenantId);
}
@Get('revenue-chart')
@Roles('manager')
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRevenueChart(user.tenantId);
}
@Get('activity')
@Roles('manager')
async getActivity(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getRecentActivity(user.tenantId);
}
@Get('financial-summary')
@Roles('manager')
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
return this.dashboardService.getFinancialSummary(user.tenantId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async getKpis(tenantId: string) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
activeSubscribers,
totalClients,
todayPayments,
overdueInvoices,
newSignups,
pendingTickets,
] = await Promise.all([
this.prisma.subscription.count({
where: { tenantId, status: 'active' },
}),
this.prisma.client.count({
where: { tenantId, status: 'active' },
}),
this.prisma.payment.aggregate({
where: {
tenantId,
createdAt: { gte: today, lt: tomorrow },
},
_sum: { amount: true },
_count: true,
}),
this.prisma.invoice.count({
where: {
tenantId,
status: { in: ['sent', 'partial'] },
dueDate: { lt: today },
},
}),
this.prisma.client.count({
where: {
tenantId,
createdAt: { gte: thirtyDaysAgo },
},
}),
this.prisma.ticket.count({
where: {
tenantId,
status: { in: ['open', 'in_progress'] },
},
}),
]);
return {
activeSubscribers,
totalClients,
todayCollections: {
amount: Number(todayPayments._sum.amount || 0),
count: todayPayments._count,
},
overdueAccounts: overdueInvoices,
newSignups,
pendingTickets,
};
}
async getRevenueChart(tenantId: string) {
const months: { month: string; revenue: number; count: number }[] = [];
for (let i = 5; i >= 0; i--) {
const start = new Date();
start.setMonth(start.getMonth() - i, 1);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setMonth(end.getMonth() + 1);
const result = await this.prisma.payment.aggregate({
where: {
tenantId,
createdAt: { gte: start, lt: end },
},
_sum: { amount: true },
_count: true,
});
months.push({
month: start.toLocaleString('en-US', { month: 'short', year: 'numeric' }),
revenue: Number(result._sum.amount || 0),
count: result._count,
});
}
return months;
}
async getRecentActivity(tenantId: string) {
const [recentPayments, recentTickets, recentClients] = await Promise.all([
this.prisma.payment.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
client: { select: { firstName: true, lastName: true } },
},
}),
this.prisma.ticket.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
client: { select: { firstName: true, lastName: true } },
},
}),
this.prisma.client.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: 5,
select: { id: true, firstName: true, lastName: true, accountNumber: true, createdAt: true },
}),
]);
return { recentPayments, recentTickets, recentClients };
}
async getFinancialSummary(tenantId: string) {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// Source of truth: compute all values from journal entries
const assetAccounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId, type: 'asset', code: { in: ['1010', '1020', '1030', '1040'] } },
include: {
journalLines: { select: { debit: true, credit: true } },
},
});
const cashOnHand = assetAccounts.reduce(
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0),
0,
);
const revenueAccounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId, type: 'revenue' },
include: {
journalLines: {
where: { journalEntry: { entryDate: { gte: monthStart } } },
select: { credit: true },
},
},
});
const expenseAccounts = await this.prisma.chartOfAccount.findMany({
where: { tenantId, type: 'expense' },
include: {
journalLines: {
where: { journalEntry: { entryDate: { gte: monthStart } } },
select: { debit: true },
},
},
});
const monthlyIncome = revenueAccounts.reduce(
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.credit), 0),
0,
);
const monthlyExpenses = expenseAccounts.reduce(
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.debit), 0),
0,
);
return {
cashOnHand,
monthlyIncome,
monthlyExpenses,
netIncome: monthlyIncome - monthlyExpenses,
};
}
}

View File

@@ -0,0 +1,13 @@
import { IsString, MinLength, IsOptional, IsEmail, IsNumber, IsUUID } from 'class-validator';
export class CreateEmployeeDto {
@IsString() @MinLength(1) firstName: string;
@IsString() @MinLength(1) lastName: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() phone?: string;
@IsString() @MinLength(1) position: string;
@IsOptional() @IsString() department?: string;
@IsOptional() @IsNumber() salary?: number;
@IsOptional() @IsUUID() userId?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsString, MinLength, IsOptional, IsEmail, IsNumber, IsIn, IsUUID } from 'class-validator';
export class UpdateEmployeeDto {
@IsOptional() @IsString() @MinLength(1) firstName?: string;
@IsOptional() @IsString() @MinLength(1) lastName?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() position?: string;
@IsOptional() @IsString() department?: string;
@IsOptional() @IsNumber() salary?: number;
@IsOptional() @IsString() @IsIn(['active', 'on_leave', 'terminated']) status?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsUUID() userId?: string;
}

View File

@@ -0,0 +1,36 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { EmployeeService } from './employee.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('employees')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class EmployeeController {
constructor(private readonly employeeService: EmployeeService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload) {
return this.employeeService.findAll(user.tenantId);
}
@Get(':id')
async findById(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.employeeService.findById(user.tenantId, id);
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateEmployeeDto) {
return this.employeeService.create(user.tenantId, dto);
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateEmployeeDto) {
return this.employeeService.update(user.tenantId, id, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EmployeeController } from './employee.controller';
import { EmployeeService } from './employee.service';
@Module({
controllers: [EmployeeController],
providers: [EmployeeService],
exports: [EmployeeService],
})
export class EmployeeModule {}

View File

@@ -0,0 +1,57 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
@Injectable()
export class EmployeeService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string) {
return this.prisma.employee.findMany({
where: { tenantId },
include: { _count: { select: { assets: true } } },
orderBy: { createdAt: 'desc' },
});
}
async findById(tenantId: string, id: string) {
const employee = await this.prisma.employee.findFirst({
where: { id, tenantId },
include: { assets: true },
});
if (!employee) throw new NotFoundException('Employee not found');
return employee;
}
async create(tenantId: string, dto: CreateEmployeeDto) {
const count = await this.prisma.employee.count({ where: { tenantId } });
const employeeNo = `E-${String(count + 1).padStart(4, '0')}`;
return this.prisma.employee.create({
data: { tenantId, employeeNo, ...dto, salary: dto.salary || null },
});
}
async update(tenantId: string, id: string, dto: UpdateEmployeeDto) {
const existing = await this.prisma.employee.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException('Employee not found');
return this.prisma.employee.update({
where: { id },
data: {
...(dto.firstName && { firstName: dto.firstName }),
...(dto.lastName && { lastName: dto.lastName }),
...(dto.email !== undefined && { email: dto.email }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.position && { position: dto.position }),
...(dto.department !== undefined && { department: dto.department }),
...(dto.status && { status: dto.status }),
...(dto.salary !== undefined && { salary: dto.salary }),
...(dto.notes !== undefined && { notes: dto.notes }),
...(dto.userId !== undefined && { userId: dto.userId || null }),
...(dto.status === 'terminated' && { terminatedAt: new Date() }),
},
});
}
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
export class CreateExpenseDto {
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
@IsString() @MinLength(3) description: string;
@IsNumber() @IsPositive() amount: number;
@IsOptional() @IsString() expenseDate?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,68 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExpenseService } from './expense.service';
import { CreateExpenseDto } from './dto/create-expense.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
class CreateRecurringDto {
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
@IsString() @MinLength(3) description: string;
@IsNumber() @IsPositive() amount: number;
@IsOptional() @IsString() @IsIn(['monthly', 'quarterly', 'yearly']) frequency?: string;
}
@Controller('expenses')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
@Roles('manager')
export class ExpenseController {
constructor(private readonly expenseService: ExpenseService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('status') status?: string, @Query('category') category?: string) {
return this.expenseService.findAll(user.tenantId, { status, category });
}
@Get('summary')
async getSummary(@CurrentUser() user: CurrentUserPayload) {
return this.expenseService.getSummary(user.tenantId);
}
@Post()
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateExpenseDto) {
return this.expenseService.create(user.tenantId, user.sub, dto);
}
@Patch(':id/approve')
async approve(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.expenseService.approve(user.tenantId, id, user.sub);
}
@Patch(':id/reject')
async reject(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.expenseService.reject(user.tenantId, id, user.sub);
}
@Get('recurring')
async getRecurring(@CurrentUser() user: CurrentUserPayload) {
return this.expenseService.getRecurring(user.tenantId);
}
@Post('recurring')
async createRecurring(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateRecurringDto) {
return this.expenseService.createRecurring(user.tenantId, dto);
}
@Patch('recurring/:id/toggle')
async toggleRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.expenseService.toggleRecurring(user.tenantId, id);
}
@Delete('recurring/:id')
async deleteRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
return this.expenseService.deleteRecurring(user.tenantId, id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ExpenseController } from './expense.controller';
import { ExpenseService } from './expense.service';
import { AccountingModule } from '../accounting/accounting.module';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [AccountingModule, NotificationModule],
controllers: [ExpenseController],
providers: [ExpenseService],
exports: [ExpenseService],
})
export class ExpenseModule {}

View File

@@ -0,0 +1,129 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { JournalService } from '../accounting/journal.service';
import { NotificationService } from '../notification/notification.service';
import { CreateExpenseDto } from './dto/create-expense.dto';
@Injectable()
export class ExpenseService {
constructor(
private readonly prisma: PrismaService,
private readonly journal: JournalService,
private readonly notificationService: NotificationService,
) {}
async findAll(tenantId: string, filters?: { status?: string; category?: string }) {
return this.prisma.expense.findMany({
where: {
tenantId,
...(filters?.status && { status: filters.status }),
...(filters?.category && { category: filters.category }),
},
orderBy: { createdAt: 'desc' },
});
}
async create(tenantId: string, createdById: string, dto: CreateExpenseDto) {
return this.prisma.expense.create({
data: {
tenantId,
createdById,
category: dto.category,
description: dto.description,
amount: dto.amount,
expenseDate: dto.expenseDate ? new Date(dto.expenseDate) : new Date(),
notes: dto.notes,
},
});
}
async approve(tenantId: string, id: string, approvedById: string) {
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
if (!expense) throw new NotFoundException('Expense not found');
if (expense.status !== 'pending') throw new BadRequestException(`Expense is already ${expense.status}`);
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
if (activeUserCount > 1 && expense.createdById === approvedById) {
throw new ForbiddenException('Cannot approve your own expense');
}
const result = await this.prisma.expense.update({
where: { id },
data: { status: 'approved', approvedById, approvedAt: new Date() },
});
// Journal entry: DR Expense Category, CR Cash on Hand
this.journal.journalForExpense(tenantId, id, Number(expense.amount), expense.category).catch(() => {});
// Decrement Cash on Hand CompanyAccount
this.journal.updateCompanyAccountBalance(tenantId, '1010', Number(expense.amount), 'decrement')
.catch(() => {});
// Notify about expense approval
this.notificationService.create(tenantId, {
type: 'in_app',
channel: 'billing_reminder',
title: 'Expense Approved',
message: `${Number(expense.amount).toLocaleString()} expense for "${expense.description}" has been approved`,
}).catch(() => {});
return result;
}
async reject(tenantId: string, id: string, rejectedById: string) {
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
if (!expense) throw new NotFoundException('Expense not found');
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
if (activeUserCount > 1 && expense.createdById === rejectedById) {
throw new ForbiddenException('Cannot reject your own expense');
}
return this.prisma.expense.update({
where: { id },
data: { status: 'rejected', approvedById: rejectedById, approvedAt: new Date() },
});
}
async getSummary(tenantId: string) {
const [pending, approved, byCategory] = await Promise.all([
this.prisma.expense.aggregate({ where: { tenantId, status: 'pending' }, _sum: { amount: true }, _count: true }),
this.prisma.expense.aggregate({ where: { tenantId, status: 'approved' }, _sum: { amount: true }, _count: true }),
this.prisma.expense.groupBy({ by: ['category'], where: { tenantId, status: 'approved' }, _sum: { amount: true } }),
]);
return {
pending: { total: Number(pending._sum.amount || 0), count: pending._count },
approved: { total: Number(approved._sum.amount || 0), count: approved._count },
byCategory: byCategory.map((c) => ({ category: c.category, total: Number(c._sum.amount || 0) })),
};
}
// ─── Recurring Expenses ──────────────────────────────────
async getRecurring(tenantId: string) {
return this.prisma.recurringExpense.findMany({ where: { tenantId }, orderBy: { nextRunDate: 'asc' } });
}
async createRecurring(tenantId: string, data: { category: string; description: string; amount: number; frequency?: string }) {
const nextRunDate = new Date();
nextRunDate.setMonth(nextRunDate.getMonth() + 1);
nextRunDate.setDate(1);
return this.prisma.recurringExpense.create({
data: { tenantId, category: data.category, description: data.description, amount: data.amount, frequency: data.frequency || 'monthly', nextRunDate },
});
}
async toggleRecurring(tenantId: string, id: string) {
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
if (!rec) throw new NotFoundException('Not found');
return this.prisma.recurringExpense.update({ where: { id }, data: { isActive: !rec.isActive } });
}
async deleteRecurring(tenantId: string, id: string) {
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
if (!rec) throw new NotFoundException('Not found');
await this.prisma.recurringExpense.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -0,0 +1,32 @@
import { Controller, Get } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { PrismaService } from '../prisma/prisma.service';
@Controller('health')
@SkipThrottle()
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async check() {
const dbHealthy = await this.checkDatabase();
return {
status: dbHealthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
services: {
api: 'ok',
database: dbHealthy ? 'ok' : 'down',
},
};
}
private async checkDatabase(): Promise<boolean> {
try {
await this.prisma.$queryRawUnsafe('SELECT 1');
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,58 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { InvoiceService } from './invoice.service';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('invoices')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class InvoiceController {
constructor(private readonly invoiceService: InvoiceService) {}
@Get()
@Roles('technician')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
@Query('status') status?: string,
) {
return this.invoiceService.findAll(user.tenantId, { clientId, status });
}
@Get(':id')
@Roles('technician')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.invoiceService.findById(user.tenantId, id);
}
@Post('generate/:clientId')
@Roles('manager')
async generate(
@CurrentUser() user: CurrentUserPayload,
@Param('clientId') clientId: string,
) {
return this.invoiceService.generateForClient(user.tenantId, clientId);
}
@Patch(':id/void')
@Roles('tenant_admin')
async voidInvoice(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.invoiceService.voidInvoice(user.tenantId, id);
}
}

Some files were not shown because too many files have changed in this diff Show More