From 4a8f1e9318594152fb24419f0057bf62de2b852f Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 09:36:55 +0800 Subject: [PATCH] initial: standalone repo from monorepo split --- .dockerignore | 5 + .env.example | 3 + .gitignore | 6 + Dockerfile | 28 + nest-cli.json | 8 + package.json | 47 ++ packages/db/package.json | 28 + .../20260403015002_init/migration.sql | 361 +++++++++ .../migration.sql | 23 + .../migration.sql | 135 ++++ .../migration.sql | 90 +++ .../migration.sql | 3 + .../migration.sql | 14 + .../migration.sql | 183 +++++ .../migration.sql | 3 + .../db/prisma/migrations/migration_lock.toml | 3 + packages/db/prisma/schema.prisma | 682 ++++++++++++++++++ packages/db/prisma/seed.ts | 603 ++++++++++++++++ packages/db/src/index.ts | 19 + packages/db/tsconfig.json | 8 + packages/shared/package.json | 18 + packages/shared/src/constants/index.ts | 16 + packages/shared/src/constants/permissions.ts | 224 ++++++ packages/shared/src/constants/roles.ts | 36 + packages/shared/src/constants/statuses.ts | 67 ++ packages/shared/src/index.ts | 3 + packages/shared/src/schemas/auth.ts | 25 + packages/shared/src/schemas/index.ts | 5 + packages/shared/src/schemas/user.ts | 27 + packages/shared/src/types/api.ts | 36 + packages/shared/src/types/index.ts | 7 + packages/shared/tsconfig.json | 8 + src/account/account.controller.ts | 47 ++ src/account/account.module.ts | 12 + src/account/account.service.ts | 137 ++++ src/account/dto/create-account.dto.ts | 8 + src/account/dto/transfer-funds.dto.ts | 8 + src/account/dto/update-account.dto.ts | 7 + src/accounting/accounting.controller.ts | 45 ++ src/accounting/accounting.module.ts | 11 + src/accounting/accounting.service.ts | 209 ++++++ src/accounting/dto/create-coa.dto.ts | 8 + src/accounting/journal.service.ts | 259 +++++++ src/app.module.ts | 81 +++ src/area/area.controller.ts | 63 ++ src/area/area.module.ts | 10 + src/area/area.service.ts | 105 +++ src/area/dto/create-area.dto.ts | 11 + src/area/dto/update-area.dto.ts | 16 + src/asset/asset.controller.ts | 31 + src/asset/asset.module.ts | 10 + src/asset/asset.service.ts | 53 ++ src/asset/dto/create-asset.dto.ts | 11 + src/asset/dto/update-asset.dto.ts | 9 + src/audit/audit.module.ts | 9 + src/audit/audit.service.ts | 39 + src/auth/auth.controller.ts | 62 ++ src/auth/auth.module.ts | 26 + src/auth/auth.service.ts | 267 +++++++ src/auth/dto/change-password.dto.ts | 10 + src/auth/dto/login.dto.ts | 10 + src/auth/dto/refresh-token.dto.ts | 6 + src/auth/dto/register-tenant.dto.ts | 30 + src/auth/strategies/jwt.strategy.ts | 25 + src/billing/billing.controller.ts | 25 + src/billing/billing.module.ts | 10 + src/billing/billing.service.ts | 25 + .../dto/update-billing-settings.dto.ts | 9 + src/client/client.controller.ts | 65 ++ src/client/client.module.ts | 12 + src/client/client.service.ts | 164 +++++ src/client/dto/create-client.dto.ts | 34 + src/client/dto/update-client.dto.ts | 47 ++ src/common/decorators/access.decorator.ts | 15 + .../decorators/current-user.decorator.ts | 20 + .../decorators/permissions.decorator.ts | 5 + src/common/decorators/roles.decorator.ts | 5 + src/common/dto/pagination.dto.ts | 55 ++ src/common/filters/http-exception.filter.ts | 39 + src/common/guards/access.guard.ts | 53 ++ src/common/guards/permissions.guard.ts | 34 + src/common/guards/roles.guard.ts | 28 + src/common/guards/tenant.guard.ts | 36 + .../interceptors/response.interceptor.ts | 24 + src/common/pipes/zod-validation.pipe.ts | 20 + src/dashboard/dashboard.controller.ts | 37 + src/dashboard/dashboard.module.ts | 9 + src/dashboard/dashboard.service.ts | 185 +++++ src/employee/dto/create-employee.dto.ts | 13 + src/employee/dto/update-employee.dto.ts | 14 + src/employee/employee.controller.ts | 36 + src/employee/employee.module.ts | 10 + src/employee/employee.service.ts | 57 ++ src/expense/dto/create-expense.dto.ts | 9 + src/expense/expense.controller.ts | 68 ++ src/expense/expense.module.ts | 13 + src/expense/expense.service.ts | 129 ++++ src/health/health.controller.ts | 32 + src/health/health.module.ts | 7 + src/invoice/invoice.controller.ts | 58 ++ src/invoice/invoice.module.ts | 10 + src/invoice/invoice.service.ts | 136 ++++ src/main.ts | 45 ++ src/notification/notification.controller.ts | 37 + src/notification/notification.module.ts | 10 + src/notification/notification.service.ts | 57 ++ src/payment/dto/create-remittance.dto.ts | 11 + src/payment/dto/record-payment.dto.ts | 25 + src/payment/payment.controller.ts | 81 +++ src/payment/payment.module.ts | 15 + src/payment/payment.service.ts | 249 +++++++ src/payroll/payroll.controller.ts | 47 ++ src/payroll/payroll.module.ts | 12 + src/payroll/payroll.service.ts | 120 +++ src/plan/dto/create-plan.dto.ts | 28 + src/plan/dto/update-plan.dto.ts | 36 + src/plan/plan.controller.ts | 63 ++ src/plan/plan.module.ts | 10 + src/plan/plan.service.ts | 113 +++ src/portal/dto/create-portal-ticket.dto.ts | 11 + src/portal/dto/portal-login.dto.ts | 11 + src/portal/portal-jwt.strategy.ts | 22 + src/portal/portal.controller.ts | 59 ++ src/portal/portal.module.ts | 21 + src/portal/portal.service.ts | 132 ++++ src/prisma/prisma.module.ts | 9 + src/prisma/prisma.service.ts | 161 +++++ src/report/report.controller.ts | 60 ++ src/report/report.module.ts | 9 + src/report/report.service.ts | 131 ++++ src/role/dto/create-role.dto.ts | 39 + src/role/dto/update-role.dto.ts | 23 + src/role/role.controller.ts | 72 ++ src/role/role.module.ts | 12 + src/role/role.service.ts | 221 ++++++ src/scheduler/invoice.scheduler.ts | 116 +++ src/scheduler/scheduler.module.ts | 10 + .../dto/create-subscription.dto.ts | 13 + src/subscription/subscription.controller.ts | 78 ++ src/subscription/subscription.module.ts | 25 + src/subscription/subscription.service.ts | 316 ++++++++ src/tenant/dto/update-tenant.dto.ts | 11 + src/tenant/tenant.controller.ts | 29 + src/tenant/tenant.module.ts | 10 + src/tenant/tenant.service.ts | 64 ++ src/ticket/dto/create-ticket.dto.ts | 28 + src/ticket/dto/update-ticket.dto.ts | 26 + src/ticket/ticket.controller.ts | 73 ++ src/ticket/ticket.module.ts | 12 + src/ticket/ticket.service.ts | 186 +++++ src/user/dto/create-user.dto.ts | 28 + src/user/dto/update-user.dto.ts | 23 + src/user/user.controller.ts | 62 ++ src/user/user.module.ts | 12 + src/user/user.service.ts | 176 +++++ tsconfig.json | 20 + vitest.config.ts | 21 + 157 files changed, 9198 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 nest-cli.json create mode 100644 package.json create mode 100644 packages/db/package.json create mode 100644 packages/db/prisma/migrations/20260403015002_init/migration.sql create mode 100644 packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql create mode 100644 packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql create mode 100644 packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql create mode 100644 packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql create mode 100644 packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql create mode 100644 packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql create mode 100644 packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql create mode 100644 packages/db/prisma/migrations/migration_lock.toml create mode 100644 packages/db/prisma/schema.prisma create mode 100644 packages/db/prisma/seed.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/constants/permissions.ts create mode 100644 packages/shared/src/constants/roles.ts create mode 100644 packages/shared/src/constants/statuses.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/auth.ts create mode 100644 packages/shared/src/schemas/index.ts create mode 100644 packages/shared/src/schemas/user.ts create mode 100644 packages/shared/src/types/api.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 src/account/account.controller.ts create mode 100644 src/account/account.module.ts create mode 100644 src/account/account.service.ts create mode 100644 src/account/dto/create-account.dto.ts create mode 100644 src/account/dto/transfer-funds.dto.ts create mode 100644 src/account/dto/update-account.dto.ts create mode 100644 src/accounting/accounting.controller.ts create mode 100644 src/accounting/accounting.module.ts create mode 100644 src/accounting/accounting.service.ts create mode 100644 src/accounting/dto/create-coa.dto.ts create mode 100644 src/accounting/journal.service.ts create mode 100644 src/app.module.ts create mode 100644 src/area/area.controller.ts create mode 100644 src/area/area.module.ts create mode 100644 src/area/area.service.ts create mode 100644 src/area/dto/create-area.dto.ts create mode 100644 src/area/dto/update-area.dto.ts create mode 100644 src/asset/asset.controller.ts create mode 100644 src/asset/asset.module.ts create mode 100644 src/asset/asset.service.ts create mode 100644 src/asset/dto/create-asset.dto.ts create mode 100644 src/asset/dto/update-asset.dto.ts create mode 100644 src/audit/audit.module.ts create mode 100644 src/audit/audit.service.ts create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/dto/change-password.dto.ts create mode 100644 src/auth/dto/login.dto.ts create mode 100644 src/auth/dto/refresh-token.dto.ts create mode 100644 src/auth/dto/register-tenant.dto.ts create mode 100644 src/auth/strategies/jwt.strategy.ts create mode 100644 src/billing/billing.controller.ts create mode 100644 src/billing/billing.module.ts create mode 100644 src/billing/billing.service.ts create mode 100644 src/billing/dto/update-billing-settings.dto.ts create mode 100644 src/client/client.controller.ts create mode 100644 src/client/client.module.ts create mode 100644 src/client/client.service.ts create mode 100644 src/client/dto/create-client.dto.ts create mode 100644 src/client/dto/update-client.dto.ts create mode 100644 src/common/decorators/access.decorator.ts create mode 100644 src/common/decorators/current-user.decorator.ts create mode 100644 src/common/decorators/permissions.decorator.ts create mode 100644 src/common/decorators/roles.decorator.ts create mode 100644 src/common/dto/pagination.dto.ts create mode 100644 src/common/filters/http-exception.filter.ts create mode 100644 src/common/guards/access.guard.ts create mode 100644 src/common/guards/permissions.guard.ts create mode 100644 src/common/guards/roles.guard.ts create mode 100644 src/common/guards/tenant.guard.ts create mode 100644 src/common/interceptors/response.interceptor.ts create mode 100644 src/common/pipes/zod-validation.pipe.ts create mode 100644 src/dashboard/dashboard.controller.ts create mode 100644 src/dashboard/dashboard.module.ts create mode 100644 src/dashboard/dashboard.service.ts create mode 100644 src/employee/dto/create-employee.dto.ts create mode 100644 src/employee/dto/update-employee.dto.ts create mode 100644 src/employee/employee.controller.ts create mode 100644 src/employee/employee.module.ts create mode 100644 src/employee/employee.service.ts create mode 100644 src/expense/dto/create-expense.dto.ts create mode 100644 src/expense/expense.controller.ts create mode 100644 src/expense/expense.module.ts create mode 100644 src/expense/expense.service.ts create mode 100644 src/health/health.controller.ts create mode 100644 src/health/health.module.ts create mode 100644 src/invoice/invoice.controller.ts create mode 100644 src/invoice/invoice.module.ts create mode 100644 src/invoice/invoice.service.ts create mode 100644 src/main.ts create mode 100644 src/notification/notification.controller.ts create mode 100644 src/notification/notification.module.ts create mode 100644 src/notification/notification.service.ts create mode 100644 src/payment/dto/create-remittance.dto.ts create mode 100644 src/payment/dto/record-payment.dto.ts create mode 100644 src/payment/payment.controller.ts create mode 100644 src/payment/payment.module.ts create mode 100644 src/payment/payment.service.ts create mode 100644 src/payroll/payroll.controller.ts create mode 100644 src/payroll/payroll.module.ts create mode 100644 src/payroll/payroll.service.ts create mode 100644 src/plan/dto/create-plan.dto.ts create mode 100644 src/plan/dto/update-plan.dto.ts create mode 100644 src/plan/plan.controller.ts create mode 100644 src/plan/plan.module.ts create mode 100644 src/plan/plan.service.ts create mode 100644 src/portal/dto/create-portal-ticket.dto.ts create mode 100644 src/portal/dto/portal-login.dto.ts create mode 100644 src/portal/portal-jwt.strategy.ts create mode 100644 src/portal/portal.controller.ts create mode 100644 src/portal/portal.module.ts create mode 100644 src/portal/portal.service.ts create mode 100644 src/prisma/prisma.module.ts create mode 100644 src/prisma/prisma.service.ts create mode 100644 src/report/report.controller.ts create mode 100644 src/report/report.module.ts create mode 100644 src/report/report.service.ts create mode 100644 src/role/dto/create-role.dto.ts create mode 100644 src/role/dto/update-role.dto.ts create mode 100644 src/role/role.controller.ts create mode 100644 src/role/role.module.ts create mode 100644 src/role/role.service.ts create mode 100644 src/scheduler/invoice.scheduler.ts create mode 100644 src/scheduler/scheduler.module.ts create mode 100644 src/subscription/dto/create-subscription.dto.ts create mode 100644 src/subscription/subscription.controller.ts create mode 100644 src/subscription/subscription.module.ts create mode 100644 src/subscription/subscription.service.ts create mode 100644 src/tenant/dto/update-tenant.dto.ts create mode 100644 src/tenant/tenant.controller.ts create mode 100644 src/tenant/tenant.module.ts create mode 100644 src/tenant/tenant.service.ts create mode 100644 src/ticket/dto/create-ticket.dto.ts create mode 100644 src/ticket/dto/update-ticket.dto.ts create mode 100644 src/ticket/ticket.controller.ts create mode 100644 src/ticket/ticket.module.ts create mode 100644 src/ticket/ticket.service.ts create mode 100644 src/user/dto/create-user.dto.ts create mode 100644 src/user/dto/update-user.dto.ts create mode 100644 src/user/user.controller.ts create mode 100644 src/user/user.module.ts create mode 100644 src/user/user.service.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd5dc63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.env +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c7c1b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fiberops +JWT_SECRET=your-secret-key +PORT=3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e91722 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +.env.*.local +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..160a555 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/db/package.json ./packages/db/ +RUN npm install --ignore-scripts && npm rebuild bcrypt +COPY packages/shared/ ./packages/shared/ +COPY packages/db/ ./packages/db/ +COPY . . +RUN cd packages/shared && npx tsc --module commonjs --moduleResolution node --outDir dist --declaration +RUN cd packages/db && npx prisma generate +RUN npx 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"] diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec0378d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..6baed8f --- /dev/null +++ b/packages/db/package.json @@ -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" + } +} diff --git a/packages/db/prisma/migrations/20260403015002_init/migration.sql b/packages/db/prisma/migrations/20260403015002_init/migration.sql new file mode 100644 index 0000000..8618410 --- /dev/null +++ b/packages/db/prisma/migrations/20260403015002_init/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql b/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql new file mode 100644 index 0000000..8091445 --- /dev/null +++ b/packages/db/prisma/migrations/20260403082101_add_audit_logs/migration.sql @@ -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"); diff --git a/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql b/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql new file mode 100644 index 0000000..761cf4f --- /dev/null +++ b/packages/db/prisma/migrations/20260403122737_add_operations_accounting/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql b/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql new file mode 100644 index 0000000..f651fc4 --- /dev/null +++ b/packages/db/prisma/migrations/20260403132431_add_billing_accounting/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql b/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql new file mode 100644 index 0000000..5a3bf7d --- /dev/null +++ b/packages/db/prisma/migrations/20260403223747_company_account_coa_link/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "company_accounts" ADD COLUMN "chartOfAccountId" TEXT, +ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql b/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql new file mode 100644 index 0000000..8689836 --- /dev/null +++ b/packages/db/prisma/migrations/20260403233912_add_remittance_payments/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql b/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql new file mode 100644 index 0000000..35b914b --- /dev/null +++ b/packages/db/prisma/migrations/20260406000000_sync_schema_drift/migration.sql @@ -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 $$; diff --git a/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql b/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql new file mode 100644 index 0000000..bfabc68 --- /dev/null +++ b/packages/db/prisma/migrations/20260406120000_add_client_coordinates/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION; +ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -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" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 0000000..7db5f7a --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -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") +} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts new file mode 100644 index 0000000..86b3d81 --- /dev/null +++ b/packages/db/prisma/seed.ts @@ -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 { + 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 = {}; + const usersByEmail: Record = {}; + 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 = {}; + 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 = { + 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(); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..85ac550 --- /dev/null +++ b/packages/db/src/index.ts @@ -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'; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..d4e8bcf --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src", "prisma"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..073de52 --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000..8c7be2b --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -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'; diff --git a/packages/shared/src/constants/permissions.ts b/packages/shared/src/constants/permissions.ts new file mode 100644 index 0000000..5a19140 --- /dev/null +++ b/packages/shared/src/constants/permissions.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 []; +} diff --git a/packages/shared/src/constants/roles.ts b/packages/shared/src/constants/roles.ts new file mode 100644 index 0000000..568ad34 --- /dev/null +++ b/packages/shared/src/constants/roles.ts @@ -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 = { + [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); +} diff --git a/packages/shared/src/constants/statuses.ts b/packages/shared/src/constants/statuses.ts new file mode 100644 index 0000000..5f5934b --- /dev/null +++ b/packages/shared/src/constants/statuses.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..0f31ffa --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './schemas'; +export * from './types'; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..5b793fd --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -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; +export type RegisterTenantInput = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000..e5095cd --- /dev/null +++ b/packages/shared/src/schemas/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000..5a8e5c1 --- /dev/null +++ b/packages/shared/src/schemas/user.ts @@ -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; +export type UpdateUserInput = z.infer; diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts new file mode 100644 index 0000000..735cd83 --- /dev/null +++ b/packages/shared/src/types/api.ts @@ -0,0 +1,36 @@ +export interface ApiResponse { + 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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..ad6b9f0 --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -0,0 +1,7 @@ +export type { + ApiResponse, + PaginationMeta, + PaginationQuery, + JwtPayload, + TokenResponse, +} from './api'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/src/account/account.controller.ts b/src/account/account.controller.ts new file mode 100644 index 0000000..367a289 --- /dev/null +++ b/src/account/account.controller.ts @@ -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); + } +} diff --git a/src/account/account.module.ts b/src/account/account.module.ts new file mode 100644 index 0000000..38c8a44 --- /dev/null +++ b/src/account/account.module.ts @@ -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 {} diff --git a/src/account/account.service.ts b/src/account/account.service.ts new file mode 100644 index 0000000..d9fede7 --- /dev/null +++ b/src/account/account.service.ts @@ -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' }, + }); + } +} diff --git a/src/account/dto/create-account.dto.ts b/src/account/dto/create-account.dto.ts new file mode 100644 index 0000000..aea815e --- /dev/null +++ b/src/account/dto/create-account.dto.ts @@ -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; +} diff --git a/src/account/dto/transfer-funds.dto.ts b/src/account/dto/transfer-funds.dto.ts new file mode 100644 index 0000000..62ce8fb --- /dev/null +++ b/src/account/dto/transfer-funds.dto.ts @@ -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; +} diff --git a/src/account/dto/update-account.dto.ts b/src/account/dto/update-account.dto.ts new file mode 100644 index 0000000..6314121 --- /dev/null +++ b/src/account/dto/update-account.dto.ts @@ -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; +} diff --git a/src/accounting/accounting.controller.ts b/src/accounting/accounting.controller.ts new file mode 100644 index 0000000..b09c55f --- /dev/null +++ b/src/accounting/accounting.controller.ts @@ -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); + } +} diff --git a/src/accounting/accounting.module.ts b/src/accounting/accounting.module.ts new file mode 100644 index 0000000..520be9b --- /dev/null +++ b/src/accounting/accounting.module.ts @@ -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 {} diff --git a/src/accounting/accounting.service.ts b/src/accounting/accounting.service.ts new file mode 100644 index 0000000..72c241c --- /dev/null +++ b/src/accounting/accounting.service.ts @@ -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 = {}; + 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, + }; + } +} diff --git a/src/accounting/dto/create-coa.dto.ts b/src/accounting/dto/create-coa.dto.ts new file mode 100644 index 0000000..b836737 --- /dev/null +++ b/src/accounting/dto/create-coa.dto.ts @@ -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; +} diff --git a/src/accounting/journal.service.ts b/src/accounting/journal.service.ts new file mode 100644 index 0000000..4929a4c --- /dev/null +++ b/src/accounting/journal.service.ts @@ -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 = { + cash: 'Cash', + gcash: 'GCash', + maya: 'Maya', + bank_transfer: 'Bank', +}; + +const METHOD_LABEL: Record = { + cash: 'Cash', + gcash: 'GCash', + maya: 'Maya', + bank_transfer: 'Bank Transfer', +}; + +// Company-level CoA codes per method +const COMPANY_COA: Record = { + 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 { + 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 = { + 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 }); + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..0db00ed --- /dev/null +++ b/src/app.module.ts @@ -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 {} diff --git a/src/area/area.controller.ts b/src/area/area.controller.ts new file mode 100644 index 0000000..94706be --- /dev/null +++ b/src/area/area.controller.ts @@ -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); + } +} diff --git a/src/area/area.module.ts b/src/area/area.module.ts new file mode 100644 index 0000000..e0be08f --- /dev/null +++ b/src/area/area.module.ts @@ -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 {} diff --git a/src/area/area.service.ts b/src/area/area.service.ts new file mode 100644 index 0000000..55ad3b7 --- /dev/null +++ b/src/area/area.service.ts @@ -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 }; + } +} diff --git a/src/area/dto/create-area.dto.ts b/src/area/dto/create-area.dto.ts new file mode 100644 index 0000000..609c623 --- /dev/null +++ b/src/area/dto/create-area.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MinLength, IsOptional } from 'class-validator'; + +export class CreateAreaDto { + @IsString() + @MinLength(2) + name: string; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/area/dto/update-area.dto.ts b/src/area/dto/update-area.dto.ts new file mode 100644 index 0000000..0a261c3 --- /dev/null +++ b/src/area/dto/update-area.dto.ts @@ -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; +} diff --git a/src/asset/asset.controller.ts b/src/asset/asset.controller.ts new file mode 100644 index 0000000..95237a2 --- /dev/null +++ b/src/asset/asset.controller.ts @@ -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); + } +} diff --git a/src/asset/asset.module.ts b/src/asset/asset.module.ts new file mode 100644 index 0000000..e93aea7 --- /dev/null +++ b/src/asset/asset.module.ts @@ -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 {} diff --git a/src/asset/asset.service.ts b/src/asset/asset.service.ts new file mode 100644 index 0000000..6e236f8 --- /dev/null +++ b/src/asset/asset.service.ts @@ -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 } } }, + }); + } +} diff --git a/src/asset/dto/create-asset.dto.ts b/src/asset/dto/create-asset.dto.ts new file mode 100644 index 0000000..4470344 --- /dev/null +++ b/src/asset/dto/create-asset.dto.ts @@ -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; +} diff --git a/src/asset/dto/update-asset.dto.ts b/src/asset/dto/update-asset.dto.ts new file mode 100644 index 0000000..30789dd --- /dev/null +++ b/src/asset/dto/update-asset.dto.ts @@ -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; +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 0000000..8f9397d --- /dev/null +++ b/src/audit/audit.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 0000000..8c44a8e --- /dev/null +++ b/src/audit/audit.service.ts @@ -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; + 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, + ); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..2f31353 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..63b0890 --- /dev/null +++ b/src/auth/auth.module.ts @@ -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('JWT_SECRET'), + signOptions: { + expiresIn: config.get('JWT_EXPIRES_IN', '15m') as any, + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..3cd860e --- /dev/null +++ b/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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> = {}; + 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 { + 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('JWT_REFRESH_SECRET'), + expiresIn: this.config.get('JWT_REFRESH_EXPIRES_IN', '7d') as any, + }); + + const expiresIn = this.config.get('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 }; + } +} diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..2403f19 --- /dev/null +++ b/src/auth/dto/change-password.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @IsString() + currentPassword: string; + + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..ba45ea1 --- /dev/null +++ b/src/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; +} diff --git a/src/auth/dto/refresh-token.dto.ts b/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..3c56e21 --- /dev/null +++ b/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + refreshToken: string; +} diff --git a/src/auth/dto/register-tenant.dto.ts b/src/auth/dto/register-tenant.dto.ts new file mode 100644 index 0000000..19d48ba --- /dev/null +++ b/src/auth/dto/register-tenant.dto.ts @@ -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; +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..0d832fc --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -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('JWT_SECRET') || 'fallback', + }); + } + + validate(payload: JwtPayload) { + return { + sub: payload.sub, + tenantId: payload.tenantId || null, + roles: payload.roles || [], + permissions: payload.permissions || [], + }; + } +} diff --git a/src/billing/billing.controller.ts b/src/billing/billing.controller.ts new file mode 100644 index 0000000..bc6045e --- /dev/null +++ b/src/billing/billing.controller.ts @@ -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); + } +} diff --git a/src/billing/billing.module.ts b/src/billing/billing.module.ts new file mode 100644 index 0000000..0a6469c --- /dev/null +++ b/src/billing/billing.module.ts @@ -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 {} diff --git a/src/billing/billing.service.ts b/src/billing/billing.service.ts new file mode 100644 index 0000000..a220245 --- /dev/null +++ b/src/billing/billing.service.ts @@ -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); + } +} diff --git a/src/billing/dto/update-billing-settings.dto.ts b/src/billing/dto/update-billing-settings.dto.ts new file mode 100644 index 0000000..fadda25 --- /dev/null +++ b/src/billing/dto/update-billing-settings.dto.ts @@ -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; +} diff --git a/src/client/client.controller.ts b/src/client/client.controller.ts new file mode 100644 index 0000000..78f50a5 --- /dev/null +++ b/src/client/client.controller.ts @@ -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); + } +} diff --git a/src/client/client.module.ts b/src/client/client.module.ts new file mode 100644 index 0000000..b121cfe --- /dev/null +++ b/src/client/client.module.ts @@ -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 {} diff --git a/src/client/client.service.ts b/src/client/client.service.ts new file mode 100644 index 0000000..8a4a241 --- /dev/null +++ b/src/client/client.service.ts @@ -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 } }, + }, + }); + } +} diff --git a/src/client/dto/create-client.dto.ts b/src/client/dto/create-client.dto.ts new file mode 100644 index 0000000..85289d6 --- /dev/null +++ b/src/client/dto/create-client.dto.ts @@ -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; +} diff --git a/src/client/dto/update-client.dto.ts b/src/client/dto/update-client.dto.ts new file mode 100644 index 0000000..334d46f --- /dev/null +++ b/src/client/dto/update-client.dto.ts @@ -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; +} diff --git a/src/common/decorators/access.decorator.ts b/src/common/decorators/access.decorator.ts new file mode 100644 index 0000000..40d20c3 --- /dev/null +++ b/src/common/decorators/access.decorator.ts @@ -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); diff --git a/src/common/decorators/current-user.decorator.ts b/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..9e63ac6 --- /dev/null +++ b/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/src/common/decorators/permissions.decorator.ts b/src/common/decorators/permissions.decorator.ts new file mode 100644 index 0000000..0998827 --- /dev/null +++ b/src/common/decorators/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..c4fc100 --- /dev/null +++ b/src/common/decorators/roles.decorator.ts @@ -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); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..4e99776 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -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 { + 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( + items: T[], + total: number, + page: number, + limit: number, +): PaginatedResult { + return { + items, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..10f4c45 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -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(); + + 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); + } +} diff --git a/src/common/guards/access.guard.ts b/src/common/guards/access.guard.ts new file mode 100644 index 0000000..ba75dfa --- /dev/null +++ b/src/common/guards/access.guard.ts @@ -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 { + const requirement = this.reflector.getAllAndOverride(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}`, + ); + } +} diff --git a/src/common/guards/permissions.guard.ts b/src/common/guards/permissions.guard.ts new file mode 100644 index 0000000..4cc3270 --- /dev/null +++ b/src/common/guards/permissions.guard.ts @@ -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(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; + } +} diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..d3ebd40 --- /dev/null +++ b/src/common/guards/roles.guard.ts @@ -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(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)); + } +} diff --git a/src/common/guards/tenant.guard.ts b/src/common/guards/tenant.guard.ts new file mode 100644 index 0000000..f7955bd --- /dev/null +++ b/src/common/guards/tenant.guard.ts @@ -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; + } +} diff --git a/src/common/interceptors/response.interceptor.ts b/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..1569a9b --- /dev/null +++ b/src/common/interceptors/response.interceptor.ts @@ -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 implements NestInterceptor> { + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => ({ + success: true, + data, + error: null, + })), + ); + } +} diff --git a/src/common/pipes/zod-validation.pipe.ts b/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 0000000..7adc2f5 --- /dev/null +++ b/src/common/pipes/zod-validation.pipe.ts @@ -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'); + } + } +} diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..76d5604 --- /dev/null +++ b/src/dashboard/dashboard.controller.ts @@ -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); + } +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/src/dashboard/dashboard.module.ts @@ -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 {} diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..4491ca2 --- /dev/null +++ b/src/dashboard/dashboard.service.ts @@ -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, + }; + } +} diff --git a/src/employee/dto/create-employee.dto.ts b/src/employee/dto/create-employee.dto.ts new file mode 100644 index 0000000..0895c5a --- /dev/null +++ b/src/employee/dto/create-employee.dto.ts @@ -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; +} diff --git a/src/employee/dto/update-employee.dto.ts b/src/employee/dto/update-employee.dto.ts new file mode 100644 index 0000000..4c73bbc --- /dev/null +++ b/src/employee/dto/update-employee.dto.ts @@ -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; +} diff --git a/src/employee/employee.controller.ts b/src/employee/employee.controller.ts new file mode 100644 index 0000000..0855e79 --- /dev/null +++ b/src/employee/employee.controller.ts @@ -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); + } +} diff --git a/src/employee/employee.module.ts b/src/employee/employee.module.ts new file mode 100644 index 0000000..52ac0d7 --- /dev/null +++ b/src/employee/employee.module.ts @@ -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 {} diff --git a/src/employee/employee.service.ts b/src/employee/employee.service.ts new file mode 100644 index 0000000..4635c04 --- /dev/null +++ b/src/employee/employee.service.ts @@ -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() }), + }, + }); + } +} diff --git a/src/expense/dto/create-expense.dto.ts b/src/expense/dto/create-expense.dto.ts new file mode 100644 index 0000000..ae0f452 --- /dev/null +++ b/src/expense/dto/create-expense.dto.ts @@ -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; +} diff --git a/src/expense/expense.controller.ts b/src/expense/expense.controller.ts new file mode 100644 index 0000000..15619ce --- /dev/null +++ b/src/expense/expense.controller.ts @@ -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); + } +} diff --git a/src/expense/expense.module.ts b/src/expense/expense.module.ts new file mode 100644 index 0000000..b91d00f --- /dev/null +++ b/src/expense/expense.module.ts @@ -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 {} diff --git a/src/expense/expense.service.ts b/src/expense/expense.service.ts new file mode 100644 index 0000000..d578220 --- /dev/null +++ b/src/expense/expense.service.ts @@ -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 }; + } +} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..b3f9024 --- /dev/null +++ b/src/health/health.controller.ts @@ -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 { + try { + await this.prisma.$queryRawUnsafe('SELECT 1'); + return true; + } catch { + return false; + } + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/invoice/invoice.controller.ts b/src/invoice/invoice.controller.ts new file mode 100644 index 0000000..2b51cb7 --- /dev/null +++ b/src/invoice/invoice.controller.ts @@ -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); + } +} diff --git a/src/invoice/invoice.module.ts b/src/invoice/invoice.module.ts new file mode 100644 index 0000000..c6be770 --- /dev/null +++ b/src/invoice/invoice.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvoiceController } from './invoice.controller'; +import { InvoiceService } from './invoice.service'; + +@Module({ + controllers: [InvoiceController], + providers: [InvoiceService], + exports: [InvoiceService], +}) +export class InvoiceModule {} diff --git a/src/invoice/invoice.service.ts b/src/invoice/invoice.service.ts new file mode 100644 index 0000000..a7ec743 --- /dev/null +++ b/src/invoice/invoice.service.ts @@ -0,0 +1,136 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto'; + +@Injectable() +export class InvoiceService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(tenantId: string, filters?: { clientId?: string; status?: 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?.clientId && { clientId: filters.clientId }), + ...(filters?.status && { status: filters.status }), + }; + const [items, total] = await Promise.all([ + db.invoice.findMany({ + where, + skip, + take, + include: { + client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } }, + _count: { select: { payments: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + db.invoice.count({ where }), + ]); + return paginatedResult(items, total, page, limit); + } + + async findById(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const invoice = await db.invoice.findFirst({ + where: { id }, + include: { + client: true, + payments: { + include: { + collectedBy: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + return invoice; + } + + async generateForClient(tenantId: string, clientId: string) { + const subscription = await this.prisma.subscription.findFirst({ + where: { clientId, tenantId, status: 'active' }, + include: { plan: true, client: true }, + }); + + if (!subscription) { + throw new BadRequestException('No active subscription for this client'); + } + + const invoiceCount = await this.prisma.invoice.count({ where: { tenantId } }); + const now = new Date(); + const dueDate = new Date(now); + dueDate.setDate(dueDate.getDate() + (subscription.plan.billingCycle || 30)); + + return this.prisma.invoice.create({ + data: { + tenantId, + clientId, + number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`, + amount: subscription.plan.price, + balance: subscription.plan.price, + status: 'sent', + dueDate, + periodStart: now, + periodEnd: dueDate, + }, + include: { + client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } }, + }, + }); + } + + async voidInvoice(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const invoice = await db.invoice.findFirst({ where: { id } }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + if (invoice.status === 'paid') { + throw new BadRequestException('Cannot void a paid invoice'); + } + + return this.prisma.invoice.update({ + where: { id }, + data: { status: 'void' }, + }); + } + + async applyPayment(tenantId: string, invoiceId: string, amount: number) { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, tenantId }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + const newBalance = Number(invoice.balance) - amount; + + let newStatus = invoice.status; + if (newBalance <= 0) { + newStatus = 'paid'; + } else if (newBalance < Number(invoice.amount)) { + newStatus = 'partial'; + } + + return this.prisma.invoice.update({ + where: { id: invoiceId }, + data: { + balance: Math.max(0, newBalance), + status: newStatus, + ...(newStatus === 'paid' && { paidAt: new Date() }), + }, + }); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..464bd24 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,45 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + // Security headers + app.use( + helmet({ + contentSecurityPolicy: false, // Handled by Next.js + crossOriginEmbedderPolicy: false, + }), + ); + + // CORS + const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:3000,http://localhost:3002').split(','); + app.enableCors({ + origin: allowedOrigins, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + app.setGlobalPrefix('api'); + + // Validation with sanitization + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + const port = process.env.API_PORT || 3001; + await app.listen(port, '0.0.0.0'); + console.log(`API running on http://localhost:${port}/api`); +} + +bootstrap(); diff --git a/src/notification/notification.controller.ts b/src/notification/notification.controller.ts new file mode 100644 index 0000000..c6208b1 --- /dev/null +++ b/src/notification/notification.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Patch, Param, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { NotificationService } from './notification.service'; +import { TenantGuard } from '../common/guards/tenant.guard'; +import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator'; + +@Controller('notifications') +@UseGuards(AuthGuard('jwt'), TenantGuard) +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserPayload) { + return this.notificationService.findForUser(user.tenantId, user.sub); + } + + @Get('unread-count') + async unreadCount(@CurrentUser() user: CurrentUserPayload) { + const count = await this.notificationService.getUnreadCount(user.tenantId, user.sub); + return { count }; + } + + @Patch(':id/read') + async markAsRead( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + await this.notificationService.markAsRead(user.tenantId, user.sub, id); + return { success: true }; + } + + @Patch('read-all') + async markAllAsRead(@CurrentUser() user: CurrentUserPayload) { + await this.notificationService.markAllAsRead(user.tenantId, user.sub); + return { success: true }; + } +} diff --git a/src/notification/notification.module.ts b/src/notification/notification.module.ts new file mode 100644 index 0000000..d94cc81 --- /dev/null +++ b/src/notification/notification.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; + +@Module({ + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/src/notification/notification.service.ts b/src/notification/notification.service.ts new file mode 100644 index 0000000..67e62e0 --- /dev/null +++ b/src/notification/notification.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class NotificationService { + constructor(private readonly prisma: PrismaService) {} + + async findForUser(tenantId: string, userId: string) { + return this.prisma.notification.findMany({ + where: { tenantId, userId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } + + async getUnreadCount(tenantId: string, userId: string) { + return this.prisma.notification.count({ + where: { tenantId, userId, isRead: false }, + }); + } + + async markAsRead(tenantId: string, userId: string, notificationId: string) { + return this.prisma.notification.updateMany({ + where: { id: notificationId, tenantId, userId }, + data: { isRead: true }, + }); + } + + async markAllAsRead(tenantId: string, userId: string) { + return this.prisma.notification.updateMany({ + where: { tenantId, userId, isRead: false }, + data: { isRead: true }, + }); + } + + async create(tenantId: string, data: { + userId?: string; + clientId?: string; + type: string; + channel: string; + title: string; + message: string; + }) { + return this.prisma.notification.create({ + data: { + tenantId, + userId: data.userId, + clientId: data.clientId, + type: data.type, + channel: data.channel, + title: data.title, + message: data.message, + sentAt: new Date(), + }, + }); + } +} diff --git a/src/payment/dto/create-remittance.dto.ts b/src/payment/dto/create-remittance.dto.ts new file mode 100644 index 0000000..9aa8764 --- /dev/null +++ b/src/payment/dto/create-remittance.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, IsArray, IsUUID } from 'class-validator'; + +export class CreateRemittanceDto { + @IsArray() + @IsUUID('4', { each: true }) + paymentIds: string[]; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/payment/dto/record-payment.dto.ts b/src/payment/dto/record-payment.dto.ts new file mode 100644 index 0000000..6a4258d --- /dev/null +++ b/src/payment/dto/record-payment.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsNumber, IsPositive, IsOptional, IsUUID, IsIn } from 'class-validator'; + +export class RecordPaymentDto { + @IsUUID() + clientId: string; + + @IsUUID() + invoiceId: string; + + @IsNumber() + @IsPositive() + amount: number; + + @IsString() + @IsIn(['gcash', 'maya', 'cash', 'bank_transfer']) + method: string; + + @IsOptional() + @IsString() + referenceNo?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/payment/payment.controller.ts b/src/payment/payment.controller.ts new file mode 100644 index 0000000..ad2c78c --- /dev/null +++ b/src/payment/payment.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PaymentService } from './payment.service'; +import { RecordPaymentDto } from './dto/record-payment.dto'; +import { CreateRemittanceDto } from './dto/create-remittance.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('payments') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +export class PaymentController { + constructor(private readonly paymentService: PaymentService) {} + + @Get() + @Roles('technician') + async findAll( + @CurrentUser() user: CurrentUserPayload, + @Query('clientId') clientId?: string, + ) { + return this.paymentService.findAll(user.tenantId, { clientId }); + } + + @Post() + @Roles('technician') + async record( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: RecordPaymentDto, + ) { + return this.paymentService.recordPayment(user.tenantId, user.sub, dto); + } + + @Get('unremitted') + @Roles('technician') + async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) { + return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub); + } + + @Get('remittances') + @Roles('technician') + async findRemittances(@CurrentUser() user: CurrentUserPayload) { + return this.paymentService.findRemittances(user.tenantId); + } + + @Post('remittances') + @Roles('technician') + async submitRemittance( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateRemittanceDto, + ) { + return this.paymentService.submitRemittance(user.tenantId, user.sub, dto); + } + + @Patch('remittances/:id/confirm') + @Roles('manager') + async confirmRemittance( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.paymentService.confirmRemittance(user.tenantId, id, user.sub); + } + + @Patch('remittances/:id/reject') + @Roles('manager') + async rejectRemittance( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.paymentService.rejectRemittance(user.tenantId, id, user.sub); + } +} diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts new file mode 100644 index 0000000..0262ace --- /dev/null +++ b/src/payment/payment.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { PaymentController } from './payment.controller'; +import { PaymentService } from './payment.service'; +import { InvoiceModule } from '../invoice/invoice.module'; +import { SubscriptionModule } from '../subscription/subscription.module'; +import { AccountingModule } from '../accounting/accounting.module'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [InvoiceModule, SubscriptionModule, AccountingModule, NotificationModule], + controllers: [PaymentController], + providers: [PaymentService], + exports: [PaymentService], +}) +export class PaymentModule {} diff --git a/src/payment/payment.service.ts b/src/payment/payment.service.ts new file mode 100644 index 0000000..dd5c4d9 --- /dev/null +++ b/src/payment/payment.service.ts @@ -0,0 +1,249 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { InvoiceService } from '../invoice/invoice.service'; +import { SubscriptionService } from '../subscription/subscription.service'; +import { AuditService } from '../audit/audit.service'; +import { RecordPaymentDto } from './dto/record-payment.dto'; +import { CreateRemittanceDto } from './dto/create-remittance.dto'; +import { JournalService } from '../accounting/journal.service'; +import { NotificationService } from '../notification/notification.service'; + +@Injectable() +export class PaymentService { + private readonly logger = new Logger(PaymentService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly invoiceService: InvoiceService, + private readonly subscriptionService: SubscriptionService, + private readonly audit: AuditService, + private readonly journal: JournalService, + private readonly notificationService: NotificationService, + ) {} + + async findAll(tenantId: string, filters?: { clientId?: string }) { + const db = this.prisma.forTenant(tenantId); + return db.payment.findMany({ + where: { + ...(filters?.clientId && { clientId: filters.clientId }), + }, + include: { + client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } }, + invoice: { select: { id: true, number: true, amount: true, status: true } }, + collectedBy: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async recordPayment(tenantId: string, collectedById: string, dto: RecordPaymentDto) { + // Verify client exists + const client = await this.prisma.client.findFirst({ + where: { id: dto.clientId, tenantId }, + }); + if (!client) { + throw new NotFoundException('Client not found'); + } + + // Verify invoice exists and belongs to client + const invoice = await this.prisma.invoice.findFirst({ + where: { id: dto.invoiceId, tenantId, clientId: dto.clientId }, + }); + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + if (invoice.status === 'paid' || invoice.status === 'void') { + throw new BadRequestException(`Invoice is already ${invoice.status}`); + } + + // Record payment + const payment = await this.prisma.payment.create({ + data: { + tenantId, + clientId: dto.clientId, + invoiceId: dto.invoiceId, + collectedById, + amount: dto.amount, + method: dto.method, + referenceNo: dto.referenceNo, + notes: dto.notes, + }, + include: { + client: { select: { id: true, firstName: true, lastName: true } }, + invoice: { select: { id: true, number: true } }, + }, + }); + + // Audit log + Journal entry (fire-and-forget) + this.audit.log({ + tenantId, userId: collectedById, action: 'payment.created', entity: 'payment', entityId: payment.id, + details: { amount: dto.amount, method: dto.method, invoiceId: dto.invoiceId, clientId: dto.clientId }, + }).catch(() => {}); + + // Notify users about the payment + this.notificationService.create(tenantId, { + type: 'in_app', + channel: 'payment_confirmation', + title: 'Payment Received', + message: `₱${Number(dto.amount).toLocaleString()} payment from ${payment.client?.firstName} ${payment.client?.lastName} (${dto.method})`, + }).catch(() => {}); + + // Lookup collector name for journal + const collector = await this.prisma.user.findUnique({ where: { id: collectedById } }); + this.journal.journalForPayment(tenantId, payment.id, dto.amount, dto.method, { + invoiceNumber: payment.invoice?.number, + clientName: payment.client ? `${payment.client.firstName} ${payment.client.lastName}` : undefined, + collectorId: collectedById, + collectorName: collector ? `${collector.firstName} ${collector.lastName}` : undefined, + }).catch(() => {}); + + // Apply payment to invoice balance + await this.invoiceService.applyPayment(tenantId, dto.invoiceId, dto.amount); + + // Check if this is a prepaid first payment — trigger activation workflow + const updatedInvoice = await this.prisma.invoice.findFirst({ + where: { id: dto.invoiceId }, + }); + if (updatedInvoice && updatedInvoice.status === 'paid') { + await this.subscriptionService.handlePrepaidPayment( + tenantId, + dto.clientId, + collectedById, + ); + } + + return payment; + } + + // ─── Remittance (custodial clearing) ───────────────────────── + + async findRemittances(tenantId: string) { + return this.prisma.remittance.findMany({ + where: { tenantId }, + include: { + collector: { select: { id: true, firstName: true, lastName: true } }, + confirmedBy: { select: { id: true, firstName: true, lastName: true } }, + payments: true, + }, + orderBy: { submittedAt: 'desc' }, + }); + } + + async getUnremittedPayments(tenantId: string, collectorId: string) { + const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } })) + .map((r) => r.paymentId); + + return this.prisma.payment.findMany({ + where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } }, + include: { + client: { select: { firstName: true, lastName: true, accountNumber: true } }, + invoice: { select: { number: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async submitRemittance(tenantId: string, collectorId: string, dto: CreateRemittanceDto) { + const payments = await this.prisma.payment.findMany({ + where: { id: { in: dto.paymentIds }, tenantId, collectedById: collectorId }, + }); + if (payments.length !== dto.paymentIds.length) { + throw new BadRequestException('Some payments do not belong to you'); + } + + const totalAmount = payments.reduce((s, p) => s + Number(p.amount), 0); + + return this.prisma.remittance.create({ + data: { + tenantId, collectorId, totalAmount, notes: dto.notes, + payments: { create: dto.paymentIds.map((paymentId) => ({ paymentId })) }, + }, + include: { collector: { select: { id: true, firstName: true, lastName: true } }, payments: true }, + }); + } + + async confirmRemittance(tenantId: string, remittanceId: string, confirmedById: string) { + const remittance = await this.prisma.remittance.findFirst({ + where: { id: remittanceId, tenantId }, + include: { payments: true, collector: { select: { id: true, firstName: true, lastName: true } } }, + }); + + if (!remittance) throw new NotFoundException('Remittance not found'); + if (remittance.status !== 'pending') throw new BadRequestException(`Remittance is already ${remittance.status}`); + + const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } }); + if (activeUserCount > 1 && remittance.collectorId === confirmedById) { + throw new ForbiddenException('Collector cannot confirm their own remittance'); + } + + const result = await this.prisma.remittance.update({ + where: { id: remittanceId }, + data: { status: 'confirmed', confirmedById, confirmedAt: new Date() }, + include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } }, + }); + + this.audit.log({ + tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId, + details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) }, + }).catch(() => {}); + + // Journal: clear collector custody → company accounts + const linkedPayments = await this.prisma.payment.findMany({ + where: { id: { in: remittance.payments.map((p) => p.paymentId) } }, + }); + const byMethod: Record = {}; + for (const p of linkedPayments) { byMethod[p.method] = (byMethod[p.method] || 0) + Number(p.amount); } + const collectorName = `${remittance.collector.firstName} ${remittance.collector.lastName}`; + + this.journal.journalForRemittance( + tenantId, remittanceId, remittance.collectorId, collectorName, + Object.entries(byMethod).map(([method, total]) => ({ method, total })), + ).catch((err) => this.logger.error(`Remittance journal failed: ${err.message}`, err.stack)); + + // Sync CompanyAccount balances for each payment method + const METHOD_COA: Record = { + cash: '1010', gcash: '1020', maya: '1030', bank_transfer: '1040', + }; + for (const [method, total] of Object.entries(byMethod)) { + const coaCode = METHOD_COA[method]; + if (coaCode) { + this.journal.updateCompanyAccountBalance(tenantId, coaCode, total, 'increment') + .catch((err) => this.logger.error(`COH sync failed for ${method}: ${err.message}`)); + } + } + + return result; + } + + async rejectRemittance(tenantId: string, remittanceId: string, rejectedById: string) { + const remittance = await this.prisma.remittance.findFirst({ + where: { id: remittanceId, tenantId }, + }); + + if (!remittance) { + throw new NotFoundException('Remittance not found'); + } + + if (remittance.collectorId === rejectedById) { + const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } }); + if (activeUserCount > 1) { + throw new ForbiddenException('Collector cannot reject their own remittance'); + } + } + + return this.prisma.remittance.update({ + where: { id: remittanceId }, + data: { + status: 'rejected', + confirmedById: rejectedById, + confirmedAt: new Date(), + }, + }); + } +} diff --git a/src/payroll/payroll.controller.ts b/src/payroll/payroll.controller.ts new file mode 100644 index 0000000..1debbf9 --- /dev/null +++ b/src/payroll/payroll.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Post, Patch, Param, Body, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PayrollService } from './payroll.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'; +import { IsString, IsOptional, IsNumber, Min } from 'class-validator'; + +class CreatePayrollDto { @IsString() period: string; } +class UpdatePayslipDto { + @IsOptional() @IsNumber() @Min(0) deductions?: number; + @IsOptional() @IsNumber() @Min(0) bonuses?: number; + @IsOptional() @IsString() notes?: string; +} + +@Controller('payroll') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +@Roles('tenant_admin') +export class PayrollController { + constructor(private readonly payrollService: PayrollService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserPayload) { + return this.payrollService.findAll(user.tenantId); + } + + @Get(':id') + async findById(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) { + return this.payrollService.findById(user.tenantId, id); + } + + @Post() + async createRun(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreatePayrollDto) { + return this.payrollService.createRun(user.tenantId, dto.period); + } + + @Patch('payslips/:id') + async updatePayslip(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdatePayslipDto) { + return this.payrollService.updatePayslip(user.tenantId, id, dto); + } + + @Post(':id/process') + async processRun(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) { + return this.payrollService.processRun(user.tenantId, id, user.sub); + } +} diff --git a/src/payroll/payroll.module.ts b/src/payroll/payroll.module.ts new file mode 100644 index 0000000..233ddb2 --- /dev/null +++ b/src/payroll/payroll.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PayrollController } from './payroll.controller'; +import { PayrollService } from './payroll.service'; +import { AccountingModule } from '../accounting/accounting.module'; + +@Module({ + imports: [AccountingModule], + controllers: [PayrollController], + providers: [PayrollService], + exports: [PayrollService], +}) +export class PayrollModule {} diff --git a/src/payroll/payroll.service.ts b/src/payroll/payroll.service.ts new file mode 100644 index 0000000..58f9523 --- /dev/null +++ b/src/payroll/payroll.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { JournalService } from '../accounting/journal.service'; + +@Injectable() +export class PayrollService { + constructor( + private readonly prisma: PrismaService, + private readonly journal: JournalService, + ) {} + + async findAll(tenantId: string) { + return this.prisma.payrollRun.findMany({ + where: { tenantId }, + include: { _count: { select: { payslips: true } } }, + orderBy: { period: 'desc' }, + }); + } + + async findById(tenantId: string, id: string) { + const run = await this.prisma.payrollRun.findFirst({ + where: { id, tenantId }, + include: { + payslips: { + include: { employee: { select: { id: true, firstName: true, lastName: true, employeeNo: true, position: true } } }, + orderBy: { employee: { lastName: 'asc' } }, + }, + }, + }); + if (!run) throw new NotFoundException('Payroll run not found'); + return run; + } + + /** Create a payroll run for a given period. Auto-generates payslips from active employees. */ + async createRun(tenantId: string, period: string) { + const existing = await this.prisma.payrollRun.findFirst({ where: { tenantId, period } }); + if (existing) throw new BadRequestException(`Payroll for ${period} already exists`); + + const employees = await this.prisma.employee.findMany({ + where: { tenantId, status: 'active', salary: { not: null } }, + }); + + if (employees.length === 0) throw new BadRequestException('No active employees with salary'); + + const totalAmount = employees.reduce((s, e) => s + Number(e.salary || 0), 0); + + return this.prisma.payrollRun.create({ + data: { + tenantId, + period, + totalAmount, + payslips: { + create: employees.map((e) => ({ + employeeId: e.id, + baseSalary: e.salary!, + netPay: e.salary!, + })), + }, + }, + include: { + payslips: { + include: { employee: { select: { firstName: true, lastName: true, employeeNo: true } } }, + }, + _count: { select: { payslips: true } }, + }, + }); + } + + /** Update a payslip (adjust deductions, bonuses) */ + async updatePayslip(tenantId: string, payslipId: string, data: { deductions?: number; bonuses?: number; notes?: string }) { + const payslip = await this.prisma.payslip.findFirst({ + where: { id: payslipId }, + include: { payrollRun: true }, + }); + if (!payslip || payslip.payrollRun.tenantId !== tenantId) throw new NotFoundException('Payslip not found'); + if (payslip.payrollRun.status === 'completed') throw new BadRequestException('Cannot modify completed payroll'); + + const deductions = data.deductions ?? Number(payslip.deductions); + const bonuses = data.bonuses ?? Number(payslip.bonuses); + const netPay = Number(payslip.baseSalary) - deductions + bonuses; + + return this.prisma.payslip.update({ + where: { id: payslipId }, + data: { deductions, bonuses, netPay, notes: data.notes ?? payslip.notes }, + }); + } + + /** Process payroll — marks as completed + creates journal entries */ + async processRun(tenantId: string, id: string, processedBy: string) { + const run = await this.prisma.payrollRun.findFirst({ + where: { id, tenantId }, + include: { payslips: { include: { employee: true } } }, + }); + if (!run) throw new NotFoundException('Payroll run not found'); + if (run.status === 'completed') throw new BadRequestException('Already completed'); + + // Recalculate total + const totalAmount = run.payslips.reduce((s, p) => s + Number(p.netPay), 0); + + const result = await this.prisma.payrollRun.update({ + where: { id }, + data: { status: 'completed', processedBy, processedAt: new Date(), totalAmount }, + include: { payslips: { include: { employee: { select: { firstName: true, lastName: true } } } } }, + }); + + // Mark all payslips as paid + await this.prisma.payslip.updateMany({ + where: { payrollRunId: id }, + data: { status: 'paid' }, + }); + + // Journal entry: DR Salaries Expense (5020), CR Cash on Hand (1010) + this.journal.createEntry(tenantId, `Payroll ${run.period} — ${run.payslips.length} employees`, [ + { accountCode: '5020', debit: totalAmount }, + { accountCode: '1010', credit: totalAmount }, + ], { reference: `PAY-${run.period}`, sourceType: 'payroll', sourceId: id }).catch(() => {}); + + return result; + } +} diff --git a/src/plan/dto/create-plan.dto.ts b/src/plan/dto/create-plan.dto.ts new file mode 100644 index 0000000..29451d9 --- /dev/null +++ b/src/plan/dto/create-plan.dto.ts @@ -0,0 +1,28 @@ +import { IsString, MinLength, IsOptional, IsNumber, IsPositive, IsInt, Min } from 'class-validator'; + +export class CreatePlanDto { + @IsString() + @MinLength(2) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsInt() + @IsPositive() + speedDown: number; + + @IsInt() + @IsPositive() + speedUp: number; + + @IsNumber() + @IsPositive() + price: number; + + @IsOptional() + @IsInt() + @Min(1) + billingCycle?: number; +} diff --git a/src/plan/dto/update-plan.dto.ts b/src/plan/dto/update-plan.dto.ts new file mode 100644 index 0000000..60826fb --- /dev/null +++ b/src/plan/dto/update-plan.dto.ts @@ -0,0 +1,36 @@ +import { IsString, MinLength, IsOptional, IsNumber, IsPositive, IsInt, Min, IsBoolean } from 'class-validator'; + +export class UpdatePlanDto { + @IsOptional() + @IsString() + @MinLength(2) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsInt() + @IsPositive() + speedDown?: number; + + @IsOptional() + @IsInt() + @IsPositive() + speedUp?: number; + + @IsOptional() + @IsNumber() + @IsPositive() + price?: number; + + @IsOptional() + @IsInt() + @Min(1) + billingCycle?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/plan/plan.controller.ts b/src/plan/plan.controller.ts new file mode 100644 index 0000000..e165db4 --- /dev/null +++ b/src/plan/plan.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PlanService } from './plan.service'; +import { CreatePlanDto } from './dto/create-plan.dto'; +import { UpdatePlanDto } from './dto/update-plan.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('plans') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +@Roles('manager') +export class PlanController { + constructor(private readonly planService: PlanService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserPayload) { + return this.planService.findAll(user.tenantId); + } + + @Get(':id') + async findById( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.planService.findById(user.tenantId, id); + } + + @Post() + async create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreatePlanDto, + ) { + return this.planService.create(user.tenantId, dto); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() dto: UpdatePlanDto, + ) { + return this.planService.update(user.tenantId, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.planService.remove(user.tenantId, id); + } +} diff --git a/src/plan/plan.module.ts b/src/plan/plan.module.ts new file mode 100644 index 0000000..6a3a98a --- /dev/null +++ b/src/plan/plan.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PlanController } from './plan.controller'; +import { PlanService } from './plan.service'; + +@Module({ + controllers: [PlanController], + providers: [PlanService], + exports: [PlanService], +}) +export class PlanModule {} diff --git a/src/plan/plan.service.ts b/src/plan/plan.service.ts new file mode 100644 index 0000000..0800841 --- /dev/null +++ b/src/plan/plan.service.ts @@ -0,0 +1,113 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePlanDto } from './dto/create-plan.dto'; +import { UpdatePlanDto } from './dto/update-plan.dto'; + +@Injectable() +export class PlanService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(tenantId: string) { + const db = this.prisma.forTenant(tenantId); + return db.plan.findMany({ + orderBy: { price: 'asc' }, + include: { + _count: { select: { subscriptions: true } }, + }, + }); + } + + async findById(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const plan = await db.plan.findFirst({ + where: { id }, + include: { + _count: { select: { subscriptions: true } }, + }, + }); + + if (!plan) { + throw new NotFoundException('Plan not found'); + } + + return plan; + } + + async create(tenantId: string, dto: CreatePlanDto) { + const existing = await this.prisma.plan.findFirst({ + where: { tenantId, name: dto.name }, + }); + + if (existing) { + throw new ConflictException('Plan name already exists'); + } + + return this.prisma.plan.create({ + data: { + tenantId, + name: dto.name, + description: dto.description, + speedDown: dto.speedDown, + speedUp: dto.speedUp, + price: dto.price, + billingCycle: dto.billingCycle ?? 30, + }, + }); + } + + async update(tenantId: string, id: string, dto: UpdatePlanDto) { + const db = this.prisma.forTenant(tenantId); + const existing = await db.plan.findFirst({ where: { id } }); + + if (!existing) { + throw new NotFoundException('Plan not found'); + } + + if (dto.name && dto.name !== existing.name) { + const duplicate = await this.prisma.plan.findFirst({ + where: { tenantId, name: dto.name }, + }); + if (duplicate) { + throw new ConflictException('Plan name already exists'); + } + } + + return this.prisma.plan.update({ + where: { id }, + data: { + ...(dto.name && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.speedDown !== undefined && { speedDown: dto.speedDown }), + ...(dto.speedUp !== undefined && { speedUp: dto.speedUp }), + ...(dto.price !== undefined && { price: dto.price }), + ...(dto.billingCycle !== undefined && { billingCycle: dto.billingCycle }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + }, + }); + } + + async remove(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const existing = await db.plan.findFirst({ + where: { id }, + include: { _count: { select: { subscriptions: true } } }, + }); + + if (!existing) { + throw new NotFoundException('Plan not found'); + } + + if (existing._count.subscriptions > 0) { + throw new ConflictException( + 'Cannot delete plan with active subscriptions. Deactivate it instead.', + ); + } + + await this.prisma.plan.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/src/portal/dto/create-portal-ticket.dto.ts b/src/portal/dto/create-portal-ticket.dto.ts new file mode 100644 index 0000000..75cc7b6 --- /dev/null +++ b/src/portal/dto/create-portal-ticket.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MinLength, IsOptional } from 'class-validator'; + +export class CreatePortalTicketDto { + @IsString() + @MinLength(5) + title: string; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/portal/dto/portal-login.dto.ts b/src/portal/dto/portal-login.dto.ts new file mode 100644 index 0000000..7b30081 --- /dev/null +++ b/src/portal/dto/portal-login.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MinLength } from 'class-validator'; + +export class PortalLoginDto { + @IsString() + @MinLength(1) + accountNumber: string; + + @IsString() + @MinLength(1) + phone: string; +} diff --git a/src/portal/portal-jwt.strategy.ts b/src/portal/portal-jwt.strategy.ts new file mode 100644 index 0000000..97e06b8 --- /dev/null +++ b/src/portal/portal-jwt.strategy.ts @@ -0,0 +1,22 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class PortalJwtStrategy extends PassportStrategy(Strategy, 'portal-jwt') { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.get('JWT_SECRET') || 'fallback', + }); + } + + validate(payload: any) { + if (payload.type !== 'portal') { + throw new UnauthorizedException('Invalid token type'); + } + return { sub: payload.sub, tenantId: payload.tenantId, type: 'portal' }; + } +} diff --git a/src/portal/portal.controller.ts b/src/portal/portal.controller.ts new file mode 100644 index 0000000..89a1081 --- /dev/null +++ b/src/portal/portal.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, Post, Body, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { SkipThrottle } from '@nestjs/throttler'; +import { PortalService } from './portal.service'; +import { PortalLoginDto } from './dto/portal-login.dto'; +import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto'; + +@Controller('portal') +export class PortalController { + constructor(private readonly portalService: PortalService) {} + + @Post('auth/login') + @HttpCode(HttpStatus.OK) + async login(@Body() dto: PortalLoginDto) { + return this.portalService.login(dto); + } + + @Get('profile') + @UseGuards(AuthGuard('portal-jwt')) + async getProfile(@Req() req: any) { + return this.portalService.getProfile(req.user.sub); + } + + @Get('dashboard') + @UseGuards(AuthGuard('portal-jwt')) + async getDashboard(@Req() req: any) { + return this.portalService.getDashboard(req.user.sub, req.user.tenantId); + } + + @Get('subscription') + @UseGuards(AuthGuard('portal-jwt')) + async getSubscription(@Req() req: any) { + return this.portalService.getSubscription(req.user.sub, req.user.tenantId); + } + + @Get('invoices') + @UseGuards(AuthGuard('portal-jwt')) + async getInvoices(@Req() req: any) { + return this.portalService.getInvoices(req.user.sub, req.user.tenantId); + } + + @Get('payments') + @UseGuards(AuthGuard('portal-jwt')) + async getPayments(@Req() req: any) { + return this.portalService.getPayments(req.user.sub, req.user.tenantId); + } + + @Get('tickets') + @UseGuards(AuthGuard('portal-jwt')) + async getTickets(@Req() req: any) { + return this.portalService.getTickets(req.user.sub, req.user.tenantId); + } + + @Post('tickets') + @UseGuards(AuthGuard('portal-jwt')) + async createTicket(@Req() req: any, @Body() dto: CreatePortalTicketDto) { + return this.portalService.createTicket(req.user.sub, req.user.tenantId, dto); + } +} diff --git a/src/portal/portal.module.ts b/src/portal/portal.module.ts new file mode 100644 index 0000000..1c27575 --- /dev/null +++ b/src/portal/portal.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { PortalController } from './portal.controller'; +import { PortalService } from './portal.service'; +import { PortalJwtStrategy } from './portal-jwt.strategy'; + +@Module({ + imports: [ + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { expiresIn: '24h' as any }, + }), + }), + ], + controllers: [PortalController], + providers: [PortalService, PortalJwtStrategy], +}) +export class PortalModule {} diff --git a/src/portal/portal.service.ts b/src/portal/portal.service.ts new file mode 100644 index 0000000..48f62be --- /dev/null +++ b/src/portal/portal.service.ts @@ -0,0 +1,132 @@ +import { Injectable, UnauthorizedException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import { PortalLoginDto } from './dto/portal-login.dto'; +import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto'; + +@Injectable() +export class PortalService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) {} + + async login(dto: PortalLoginDto) { + const client = await this.prisma.client.findFirst({ + where: { + accountNumber: dto.accountNumber, + phone: dto.phone, + status: 'active', + }, + include: { area: { select: { name: true } } }, + }); + + if (!client) { + throw new UnauthorizedException('Invalid account number or phone number'); + } + + const token = this.jwt.sign({ + sub: client.id, + tenantId: client.tenantId, + type: 'portal', + }); + + return { + accessToken: token, + client: { + id: client.id, + accountNumber: client.accountNumber, + firstName: client.firstName, + lastName: client.lastName, + email: client.email, + phone: client.phone, + address: client.address, + area: client.area?.name, + }, + }; + } + + async getProfile(clientId: string) { + const client = await this.prisma.client.findUnique({ + where: { id: clientId }, + include: { + area: { select: { name: true } }, + subscriptions: { + where: { status: { in: ['active', 'pending'] } }, + include: { plan: { select: { name: true, price: true, speedDown: true, speedUp: true, billingCycle: true } } }, + take: 1, + }, + }, + }); + if (!client) throw new NotFoundException('Client not found'); + return client; + } + + async getSubscription(clientId: string, tenantId: string) { + return this.prisma.subscription.findFirst({ + where: { clientId, tenantId, status: { in: ['active', 'pending', 'suspended'] } }, + include: { plan: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getInvoices(clientId: string, tenantId: string) { + return this.prisma.invoice.findMany({ + where: { clientId, tenantId }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + } + + async getPayments(clientId: string, tenantId: string) { + return this.prisma.payment.findMany({ + where: { clientId, tenantId }, + include: { invoice: { select: { number: true } } }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + } + + async getTickets(clientId: string, tenantId: string) { + return this.prisma.ticket.findMany({ + where: { clientId, tenantId }, + include: { assignee: { select: { firstName: true, lastName: true } } }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + } + + async createTicket(clientId: string, tenantId: string, dto: CreatePortalTicketDto) { + // Find an admin user to attribute as creator + const admin = await this.prisma.user.findFirst({ + where: { tenantId }, + include: { roles: { where: { role: 'tenant_admin' } } }, + }); + + return this.prisma.ticket.create({ + data: { + tenantId, + clientId, + createdById: admin?.id || '', + type: 'support', + title: dto.title, + description: dto.description, + priority: 'normal', + }, + }); + } + + async getDashboard(clientId: string, tenantId: string) { + const [subscription, unpaidInvoices, recentPayment, openTickets] = await Promise.all([ + this.prisma.subscription.findFirst({ + where: { clientId, tenantId, status: 'active' }, + include: { plan: { select: { name: true, speedDown: true, speedUp: true, price: true } } }, + }), + this.prisma.invoice.count({ where: { clientId, tenantId, status: { in: ['sent', 'partial'] } } }), + this.prisma.payment.findFirst({ where: { clientId, tenantId }, orderBy: { createdAt: 'desc' } }), + this.prisma.ticket.count({ where: { clientId, tenantId, status: { in: ['open', 'in_progress'] } } }), + ]); + + return { subscription, unpaidInvoices, recentPayment, openTickets }; + } +} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..d9823b9 --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,161 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +// Models that require tenant scoping +const TENANT_SCOPED_MODELS = [ + 'User', + 'Area', + 'Plan', + 'Client', + 'Subscription', + 'Invoice', + 'Payment', + 'Remittance', + 'Ticket', + 'Notification', +]; + +// Models that have a deletedAt column for soft deletes +const SOFT_DELETE_MODELS = [ + 'Tenant', + 'User', + 'Area', + 'Plan', + 'Client', + 'Subscription', + 'Invoice', + 'Payment', + 'Remittance', + 'Ticket', + 'Employee', + 'PayrollRun', + 'RecurringExpense', + 'Expense', + 'CompanyAccount', + 'Asset', + 'ChartOfAccount', + 'TenantRole', +]; + +function isSoftDeleteModel(model: string): boolean { + return SOFT_DELETE_MODELS.includes(model); +} + +function addDeletedAtFilter(args: any): any { + if (!args) args = {}; + if (!args.where) args.where = {}; + if (args.where.deletedAt === undefined) { + args.where.deletedAt = null; + } + return args; +} + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } + + /** + * Returns a scoped Prisma client that automatically: + * 1. Filters all queries on tenant-scoped models by tenantId + * 2. Applies soft-delete filtering (excludes deletedAt != null) + * 3. Converts delete → soft delete for soft-delete-enabled models + */ + forTenant(tenantId: string) { + return this.$extends({ + query: { + $allModels: { + async findMany({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model)) { + args.where = { ...args.where, tenantId }; + } + if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args); + return query(args); + }, + async findFirst({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model)) { + args.where = { ...args.where, tenantId }; + } + if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args); + return query(args); + }, + async findUnique({ model, args, query }) { + const result = await query(args); + if ( + TENANT_SCOPED_MODELS.includes(model) && + result && + 'tenantId' in result && + (result as any).tenantId !== tenantId + ) { + return null; + } + if (isSoftDeleteModel(model) && result && 'deletedAt' in result && result.deletedAt !== null) { + return null; + } + return result; + }, + async create({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model)) { + args.data = { ...args.data, tenantId } as any; + } + return query(args); + }, + async update({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model) && args.where) { + args.where = { ...args.where, tenantId } as any; + } + return query(args); + }, + async delete({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model) && args.where) { + args.where = { ...args.where, tenantId } as any; + } + // Soft delete: convert to update + if (isSoftDeleteModel(model)) { + const modelKey = model[0].toLowerCase() + model.slice(1); + return (this as any)[modelKey].update({ + where: args.where, + data: { deletedAt: new Date() }, + }); + } + return query(args); + }, + async deleteMany({ model, args, query }) { + // Soft delete: convert to updateMany + if (isSoftDeleteModel(model)) { + const modelKey = model[0].toLowerCase() + model.slice(1); + return (this as any)[modelKey].updateMany({ + where: args.where, + data: { deletedAt: new Date() }, + }); + } + return query(args); + }, + async count({ model, args, query }) { + if (TENANT_SCOPED_MODELS.includes(model)) { + args.where = { ...args.where, tenantId }; + } + if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args); + return query(args); + }, + async aggregate({ model, args, query }) { + if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args); + return query(args); + }, + async groupBy({ model, args, query }) { + if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args); + return query(args); + }, + }, + }, + }); + } +} diff --git a/src/report/report.controller.ts b/src/report/report.controller.ts new file mode 100644 index 0000000..5ee4561 --- /dev/null +++ b/src/report/report.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ReportService } from './report.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('reports') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +@Roles('manager') +export class ReportController { + constructor(private readonly reportService: ReportService) {} + + @Get() + async index() { + return { + available: [ + { key: 'collections', label: 'Collection Report', path: '/reports/collections' }, + { key: 'subscribers', label: 'Subscriber List', path: '/reports/subscribers' }, + { key: 'aging', label: 'Aging Report', path: '/reports/aging' }, + { key: 'plan-distribution', label: 'Plan Distribution', path: '/reports/plan-distribution' }, + { key: 'expenses', label: 'Expense Report', path: '/reports/expenses' }, + ], + }; + } + + @Get('collections') + async collections( + @CurrentUser() user: CurrentUserPayload, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.reportService.getCollectionReport(user.tenantId, from, to); + } + + @Get('subscribers') + async subscribers(@CurrentUser() user: CurrentUserPayload) { + return this.reportService.getSubscriberList(user.tenantId); + } + + @Get('aging') + async aging(@CurrentUser() user: CurrentUserPayload) { + return this.reportService.getAgingReport(user.tenantId); + } + + @Get('plan-distribution') + async planDistribution(@CurrentUser() user: CurrentUserPayload) { + return this.reportService.getPlanDistribution(user.tenantId); + } + + @Get('expenses') + async expenses( + @CurrentUser() user: CurrentUserPayload, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.reportService.getExpenseReport(user.tenantId, from, to); + } +} diff --git a/src/report/report.module.ts b/src/report/report.module.ts new file mode 100644 index 0000000..3046d49 --- /dev/null +++ b/src/report/report.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ReportController } from './report.controller'; +import { ReportService } from './report.service'; + +@Module({ + controllers: [ReportController], + providers: [ReportService], +}) +export class ReportModule {} diff --git a/src/report/report.service.ts b/src/report/report.service.ts new file mode 100644 index 0000000..730fd53 --- /dev/null +++ b/src/report/report.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class ReportService { + constructor(private readonly prisma: PrismaService) {} + + async getCollectionReport(tenantId: string, from?: string, to?: string) { + const dateFrom = from ? new Date(from) : new Date(new Date().setDate(1)); + const dateTo = to ? new Date(to) : new Date(); + dateTo.setHours(23, 59, 59, 999); + + const payments = await this.prisma.payment.findMany({ + where: { + tenantId, + createdAt: { gte: dateFrom, lte: dateTo }, + }, + include: { + client: { select: { firstName: true, lastName: true, accountNumber: true } }, + invoice: { select: { number: true } }, + collectedBy: { select: { firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const total = payments.reduce((sum, p) => sum + Number(p.amount), 0); + const byMethod: Record = {}; + for (const p of payments) { + byMethod[p.method] = (byMethod[p.method] || 0) + Number(p.amount); + } + + return { payments, summary: { total, count: payments.length, byMethod } }; + } + + async getSubscriberList(tenantId: string) { + return this.prisma.client.findMany({ + where: { tenantId }, + include: { + area: { select: { name: true } }, + subscriptions: { + where: { status: { in: ['active', 'pending'] } }, + include: { plan: { select: { name: true, price: true, speedDown: true, speedUp: true } } }, + take: 1, + }, + }, + orderBy: { accountNumber: 'asc' }, + }); + } + + async getAgingReport(tenantId: string) { + const now = new Date(); + const invoices = await this.prisma.invoice.findMany({ + where: { + tenantId, + status: { in: ['sent', 'partial'] }, + dueDate: { lt: now }, + }, + include: { + client: { select: { firstName: true, lastName: true, accountNumber: true } }, + }, + orderBy: { dueDate: 'asc' }, + }); + + const buckets = { current: [] as any[], days30: [] as any[], days60: [] as any[], days90: [] as any[] }; + for (const inv of invoices) { + const daysOverdue = Math.floor((now.getTime() - new Date(inv.dueDate).getTime()) / 86400000); + const entry = { ...inv, daysOverdue }; + if (daysOverdue <= 30) buckets.current.push(entry); + else if (daysOverdue <= 60) buckets.days30.push(entry); + else if (daysOverdue <= 90) buckets.days60.push(entry); + else buckets.days90.push(entry); + } + + return { + buckets, + summary: { + current: buckets.current.reduce((s, i) => s + Number(i.balance), 0), + days30: buckets.days30.reduce((s, i) => s + Number(i.balance), 0), + days60: buckets.days60.reduce((s, i) => s + Number(i.balance), 0), + days90Plus: buckets.days90.reduce((s, i) => s + Number(i.balance), 0), + total: invoices.reduce((s, i) => s + Number(i.balance), 0), + }, + }; + } + + async getExpenseReport(tenantId: string, from?: string, to?: string) { + const dateFrom = from ? new Date(from) : new Date(new Date().setDate(1)); + const dateTo = to ? new Date(to) : new Date(); + dateTo.setHours(23, 59, 59, 999); + + const expenses = await this.prisma.expense.findMany({ + where: { + tenantId, + expenseDate: { gte: dateFrom, lte: dateTo }, + }, + orderBy: { expenseDate: 'desc' }, + }); + + const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0); + const byCategory: Record = {}; + for (const e of expenses) { + byCategory[e.category] = (byCategory[e.category] || 0) + Number(e.amount); + } + + return { expenses, summary: { total, count: expenses.length, byCategory } }; + } + + async getPlanDistribution(tenantId: string) { + const plans = await this.prisma.plan.findMany({ + where: { tenantId }, + include: { + _count: { select: { subscriptions: true } }, + subscriptions: { + where: { status: 'active' }, + select: { id: true }, + }, + }, + orderBy: { price: 'asc' }, + }); + + return plans.map((p) => ({ + id: p.id, + name: p.name, + price: Number(p.price), + speedDown: p.speedDown, + speedUp: p.speedUp, + totalSubscriptions: p._count.subscriptions, + activeSubscriptions: p.subscriptions.length, + })); + } +} diff --git a/src/role/dto/create-role.dto.ts b/src/role/dto/create-role.dto.ts new file mode 100644 index 0000000..2a7b1d9 --- /dev/null +++ b/src/role/dto/create-role.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsArray, ValidateNested, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PermissionRowDto { + @IsString() + module: string; + + @IsBoolean() + canView: boolean; + + @IsBoolean() + canCreate: boolean; + + @IsBoolean() + canUpdate: boolean; + + @IsBoolean() + canArchive: boolean; + + @IsBoolean() + canApprove: boolean; + + @IsBoolean() + canExport: boolean; +} + +export class CreateRoleDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PermissionRowDto) + permissions: PermissionRowDto[]; +} diff --git a/src/role/dto/update-role.dto.ts b/src/role/dto/update-role.dto.ts new file mode 100644 index 0000000..2cc1052 --- /dev/null +++ b/src/role/dto/update-role.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional, IsArray, ValidateNested, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; +import { PermissionRowDto } from './create-role.dto'; + +export class UpdateRoleDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PermissionRowDto) + permissions?: PermissionRowDto[]; +} diff --git a/src/role/role.controller.ts b/src/role/role.controller.ts new file mode 100644 index 0000000..1bf8402 --- /dev/null +++ b/src/role/role.controller.ts @@ -0,0 +1,72 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TenantGuard } from '../common/guards/tenant.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator'; +import { RoleService } from './role.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@Controller('roles') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +@Roles('tenant_admin') +export class RoleController { + constructor(private readonly roleService: RoleService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserPayload) { + return this.roleService.findAll(user.tenantId!); + } + + @Get(':id') + async findById( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.roleService.findById(user.tenantId!, id); + } + + @Post() + async create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateRoleDto, + ) { + return this.roleService.create(user.tenantId!, dto); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() dto: UpdateRoleDto, + ) { + return this.roleService.update(user.tenantId!, id, dto); + } + + @Post(':id/duplicate') + async duplicate( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body('name') name: string, + ) { + return this.roleService.duplicate(user.tenantId!, id, name); + } + + @Delete(':id') + async remove( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.roleService.remove(user.tenantId!, id); + } +} diff --git a/src/role/role.module.ts b/src/role/role.module.ts new file mode 100644 index 0000000..174a3d5 --- /dev/null +++ b/src/role/role.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { RoleController } from './role.controller'; +import { RoleService } from './role.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [RoleController], + providers: [RoleService], + exports: [RoleService], +}) +export class RoleModule {} diff --git a/src/role/role.service.ts b/src/role/role.service.ts new file mode 100644 index 0000000..d1d1893 --- /dev/null +++ b/src/role/role.service.ts @@ -0,0 +1,221 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@Injectable() +export class RoleService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(tenantId: string) { + const roles = await this.prisma.tenantRole.findMany({ + where: { tenantId, deletedAt: null }, + include: { + permissions: true, + _count: { select: { users: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return roles.map((r) => ({ + id: r.id, + name: r.name, + slug: r.slug, + description: r.description, + isSystem: r.isSystem, + isActive: r.isActive, + userCount: r._count.users, + permissions: r.permissions, + createdAt: r.createdAt, + })); + } + + async findById(tenantId: string, roleId: string) { + const role = await this.prisma.tenantRole.findFirst({ + where: { id: roleId, tenantId, deletedAt: null }, + include: { + permissions: true, + users: { + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true, isActive: true } }, + }, + }, + }, + }); + + if (!role) throw new NotFoundException('Role not found'); + + return { + id: role.id, + name: role.name, + slug: role.slug, + description: role.description, + isSystem: role.isSystem, + isActive: role.isActive, + permissions: role.permissions, + users: role.users.map((u) => u.user), + createdAt: role.createdAt, + }; + } + + async create(tenantId: string, dto: CreateRoleDto) { + const slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + + const existing = await this.prisma.tenantRole.findFirst({ + where: { tenantId, slug, deletedAt: null }, + }); + if (existing) throw new ConflictException('A role with this name already exists'); + + const role = await this.prisma.tenantRole.create({ + data: { + tenantId, + name: dto.name, + slug, + description: dto.description, + isSystem: false, + permissions: { + create: dto.permissions.map((p) => ({ + module: p.module, + canView: p.canView, + canCreate: p.canCreate, + canUpdate: p.canUpdate, + canArchive: p.canArchive, + canApprove: p.canApprove, + canExport: p.canExport, + })), + }, + }, + include: { permissions: true }, + }); + + return { + id: role.id, + name: role.name, + slug: role.slug, + description: role.description, + isSystem: role.isSystem, + isActive: role.isActive, + permissions: role.permissions, + }; + } + + async update(tenantId: string, roleId: string, dto: UpdateRoleDto) { + const existing = await this.prisma.tenantRole.findFirst({ + where: { id: roleId, tenantId, deletedAt: null }, + }); + if (!existing) throw new NotFoundException('Role not found'); + + // Update basic fields + const updateData: any = {}; + if (dto.name !== undefined) { + updateData.name = dto.name; + updateData.slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + } + if (dto.description !== undefined) updateData.description = dto.description; + if (dto.isActive !== undefined) updateData.isActive = dto.isActive; + + await this.prisma.tenantRole.update({ + where: { id: roleId }, + data: updateData, + }); + + // Replace permissions if provided + if (dto.permissions) { + await this.prisma.rolePermission.deleteMany({ where: { tenantRoleId: roleId } }); + await this.prisma.rolePermission.createMany({ + data: dto.permissions.map((p) => ({ + tenantRoleId: roleId, + module: p.module, + canView: p.canView, + canCreate: p.canCreate, + canUpdate: p.canUpdate, + canArchive: p.canArchive, + canApprove: p.canApprove, + canExport: p.canExport, + })), + }); + } + + return this.findById(tenantId, roleId); + } + + async duplicate(tenantId: string, roleId: string, newName: string) { + const source = await this.prisma.tenantRole.findFirst({ + where: { id: roleId, tenantId, deletedAt: null }, + include: { permissions: true }, + }); + if (!source) throw new NotFoundException('Source role not found'); + + return this.create(tenantId, { + name: newName, + description: `Duplicated from ${source.name}`, + permissions: source.permissions.map((p) => ({ + module: p.module, + canView: p.canView, + canCreate: p.canCreate, + canUpdate: p.canUpdate, + canArchive: p.canArchive, + canApprove: p.canApprove, + canExport: p.canExport, + })), + }); + } + + async remove(tenantId: string, roleId: string) { + const role = await this.prisma.tenantRole.findFirst({ + where: { id: roleId, tenantId, deletedAt: null }, + include: { _count: { select: { users: true } } }, + }); + if (!role) throw new NotFoundException('Role not found'); + if (role.isSystem) throw new BadRequestException('Cannot delete system roles'); + if (role._count.users > 0) throw new BadRequestException('Cannot delete role with assigned users. Remove user assignments first.'); + + await this.prisma.tenantRole.update({ + where: { id: roleId }, + data: { deletedAt: new Date() }, + }); + + return { message: 'Role deleted' }; + } + + /** + * Get the permission matrix for a user by resolving all their tenant roles. + * Returns a flat map: { module: { canView, canCreate, ... } } + * A permission is granted if ANY of the user's roles grants it. + */ + async getUserPermissions(userId: string, tenantId: string) { + const assignments = await this.prisma.userTenantRole.findMany({ + where: { userId }, + include: { + tenantRole: { + include: { permissions: true }, + }, + }, + }); + + // Union all permissions — if any role grants it, user has it + const merged: Record = {}; + + for (const a of assignments) { + if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue; + for (const p of a.tenantRole.permissions) { + if (!merged[p.module]) { + merged[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false }; + } + if (p.canView) merged[p.module].canView = true; + if (p.canCreate) merged[p.module].canCreate = true; + if (p.canUpdate) merged[p.module].canUpdate = true; + if (p.canArchive) merged[p.module].canArchive = true; + if (p.canApprove) merged[p.module].canApprove = true; + if (p.canExport) merged[p.module].canExport = true; + } + } + + return merged; + } +} diff --git a/src/scheduler/invoice.scheduler.ts b/src/scheduler/invoice.scheduler.ts new file mode 100644 index 0000000..4731ec7 --- /dev/null +++ b/src/scheduler/invoice.scheduler.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { BillingService } from '../billing/billing.service'; + +@Injectable() +export class InvoiceScheduler { + private readonly logger = new Logger(InvoiceScheduler.name); + + constructor( + private readonly prisma: PrismaService, + private readonly billingService: BillingService, + ) {} + + /** + * Runs daily at 2 AM — generates recurring invoices for active subscriptions + * whose last invoice period has ended. + */ + @Cron(CronExpression.EVERY_DAY_AT_2AM) + async generateRecurringInvoices() { + this.logger.log('Starting recurring invoice generation...'); + + const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } }); + + for (const tenant of tenants) { + try { + const settings = await this.billingService.getSettings(tenant.id); + if (!settings.autoGenerate) continue; + + const activeSubscriptions = await this.prisma.subscription.findMany({ + where: { tenantId: tenant.id, status: 'active' }, + include: { plan: true, client: true }, + }); + + for (const sub of activeSubscriptions) { + // Find the latest invoice for this client + const lastInvoice = await this.prisma.invoice.findFirst({ + where: { tenantId: tenant.id, clientId: sub.clientId }, + orderBy: { createdAt: 'desc' }, + }); + + if (!lastInvoice) continue; + + // Check if the billing cycle has passed since last invoice + const cycleEndDate = new Date(lastInvoice.periodEnd || lastInvoice.createdAt); + const now = new Date(); + + if (now < cycleEndDate) continue; // Not time yet + + // Generate next invoice + const invoiceCount = await this.prisma.invoice.count({ where: { tenantId: tenant.id } }); + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + settings.dueDateOffsetDays); + + const periodEnd = new Date(); + periodEnd.setDate(periodEnd.getDate() + (sub.plan.billingCycle || 30)); + + await this.prisma.invoice.create({ + data: { + tenantId: tenant.id, + clientId: sub.clientId, + number: `${settings.invoicePrefix}-${String(invoiceCount + 1).padStart(6, '0')}`, + amount: sub.plan.price, + balance: sub.plan.price, + status: 'sent', + dueDate, + periodStart: now, + periodEnd, + }, + }); + + this.logger.log(`Generated invoice for client ${sub.client.accountNumber} (${tenant.slug})`); + } + } catch (error) { + this.logger.error(`Failed to generate invoices for tenant ${tenant.slug}:`, error); + } + } + + this.logger.log('Recurring invoice generation complete.'); + } + + /** + * Runs daily at 3 AM — tags unpaid invoices as overdue based on grace period. + */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async tagOverdueInvoices() { + this.logger.log('Starting overdue invoice tagging...'); + + const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } }); + + for (const tenant of tenants) { + try { + const settings = await this.billingService.getSettings(tenant.id); + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - settings.gracePeriodDays); + + const result = await this.prisma.invoice.updateMany({ + where: { + tenantId: tenant.id, + status: { in: ['sent', 'partial'] }, + dueDate: { lt: cutoffDate }, + }, + data: { status: 'overdue' }, + }); + + if (result.count > 0) { + this.logger.log(`Tagged ${result.count} invoices as overdue for ${tenant.slug}`); + } + } catch (error) { + this.logger.error(`Failed to tag overdue for tenant ${tenant.slug}:`, error); + } + } + + this.logger.log('Overdue tagging complete.'); + } +} diff --git a/src/scheduler/scheduler.module.ts b/src/scheduler/scheduler.module.ts new file mode 100644 index 0000000..b7e6a56 --- /dev/null +++ b/src/scheduler/scheduler.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { InvoiceScheduler } from './invoice.scheduler'; +import { BillingModule } from '../billing/billing.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), BillingModule], + providers: [InvoiceScheduler], +}) +export class SchedulerModule {} diff --git a/src/subscription/dto/create-subscription.dto.ts b/src/subscription/dto/create-subscription.dto.ts new file mode 100644 index 0000000..0fb602c --- /dev/null +++ b/src/subscription/dto/create-subscription.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsUUID, IsIn } from 'class-validator'; + +export class CreateSubscriptionDto { + @IsUUID() + clientId: string; + + @IsUUID() + planId: string; + + @IsString() + @IsIn(['prepaid', 'postpaid']) + type: string; +} diff --git a/src/subscription/subscription.controller.ts b/src/subscription/subscription.controller.ts new file mode 100644 index 0000000..3d9500f --- /dev/null +++ b/src/subscription/subscription.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { SubscriptionService } from './subscription.service'; +import { CreateSubscriptionDto } from './dto/create-subscription.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('subscriptions') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +export class SubscriptionController { + constructor(private readonly subscriptionService: SubscriptionService) {} + + @Get() + @Roles('manager') + async findAll( + @CurrentUser() user: CurrentUserPayload, + @Query('clientId') clientId?: string, + @Query('status') status?: string, + ) { + return this.subscriptionService.findAll(user.tenantId, { clientId, status }); + } + + @Get(':id') + @Roles('manager') + async findById( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.subscriptionService.findById(user.tenantId, id); + } + + @Post() + @Roles('manager') + async create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateSubscriptionDto, + ) { + return this.subscriptionService.create(user.tenantId, dto); + } + + @Patch(':id/suspend') + @Roles('manager') + async suspend( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.subscriptionService.suspend(user.tenantId, id); + } + + @Patch(':id/reactivate') + @Roles('manager') + async reactivate( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.subscriptionService.reactivate(user.tenantId, id); + } + + @Patch(':id/cancel') + @Roles('manager') + async cancel( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.subscriptionService.cancel(user.tenantId, id); + } +} diff --git a/src/subscription/subscription.module.ts b/src/subscription/subscription.module.ts new file mode 100644 index 0000000..1f03261 --- /dev/null +++ b/src/subscription/subscription.module.ts @@ -0,0 +1,25 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; +import { TicketModule } from '../ticket/ticket.module'; +import { TicketService } from '../ticket/ticket.service'; + +@Module({ + imports: [TicketModule], + controllers: [SubscriptionController], + providers: [SubscriptionService], + exports: [SubscriptionService], +}) +export class SubscriptionModule implements OnModuleInit { + constructor( + private readonly subscriptionService: SubscriptionService, + private readonly ticketService: TicketService, + ) {} + + onModuleInit() { + // Wire up the ticket resolution event to subscription workflow + this.ticketService.onTicketResolved = async (event) => { + await this.subscriptionService.handleTicketResolved(event); + }; + } +} diff --git a/src/subscription/subscription.service.ts b/src/subscription/subscription.service.ts new file mode 100644 index 0000000..3ba3aae --- /dev/null +++ b/src/subscription/subscription.service.ts @@ -0,0 +1,316 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { TicketService, TicketResolvedEvent } from '../ticket/ticket.service'; +import { CreateSubscriptionDto } from './dto/create-subscription.dto'; + +@Injectable() +export class SubscriptionService { + constructor( + private readonly prisma: PrismaService, + private readonly ticketService: TicketService, + ) {} + + async findAll(tenantId: string, filters?: { clientId?: string; status?: string }) { + const db = this.prisma.forTenant(tenantId); + return db.subscription.findMany({ + where: { + ...(filters?.clientId && { clientId: filters.clientId }), + ...(filters?.status && { status: filters.status }), + }, + include: { + client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } }, + plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const sub = await db.subscription.findFirst({ + where: { id }, + include: { + client: true, + plan: true, + }, + }); + + if (!sub) { + throw new NotFoundException('Subscription not found'); + } + + return sub; + } + + async create(tenantId: string, dto: CreateSubscriptionDto) { + // Verify client exists + const client = await this.prisma.client.findFirst({ + where: { id: dto.clientId, tenantId }, + }); + if (!client) { + throw new NotFoundException('Client not found'); + } + + // Verify 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'); + } + + // Check no active subscription for this client + const activeSub = await this.prisma.subscription.findFirst({ + where: { + clientId: dto.clientId, + tenantId, + status: { in: ['pending', 'active'] }, + }, + }); + if (activeSub) { + throw new BadRequestException('Client already has an active or pending subscription'); + } + + return this.prisma.subscription.create({ + data: { + tenantId, + clientId: dto.clientId, + planId: dto.planId, + type: dto.type, + status: 'pending', + }, + include: { + plan: { select: { id: true, name: true, price: true } }, + client: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + } + + async suspend(tenantId: string, id: string) { + return this.updateStatus(tenantId, id, 'suspended', ['active']); + } + + async reactivate(tenantId: string, id: string) { + return this.updateStatus(tenantId, id, 'active', ['suspended']); + } + + async cancel(tenantId: string, id: string) { + return this.updateStatus(tenantId, id, 'cancelled', ['pending', 'active', 'suspended']); + } + + /** + * Handle ticket resolution events — drives the postpaid/prepaid workflow. + * + * POSTPAID: install resolved → auto activation ticket + * activation resolved → activate subscription + auto 1st invoice (due 1 month) + * + * PREPAID: install resolved → auto 1st invoice (will need payment before activation) + * activation resolved → activate subscription + auto next invoice + */ + async handleTicketResolved(event: TicketResolvedEvent) { + if (!event.clientId) return; + + const subscription = await this.prisma.subscription.findFirst({ + where: { + clientId: event.clientId, + tenantId: event.tenantId, + status: 'pending', + }, + include: { client: true, plan: true }, + }); + + if (!subscription) return; + + // Find a user to attribute system actions to (first admin of tenant) + const adminUser = await this.prisma.user.findFirst({ + where: { tenantId: event.tenantId }, + include: { roles: { where: { role: 'tenant_admin' } } }, + }); + const systemUserId = adminUser?.id || ''; + + if (subscription.type === 'postpaid') { + await this.handlePostpaidTicketResolved(event, subscription, systemUserId); + } else { + await this.handlePrepaidTicketResolved(event, subscription, systemUserId); + } + } + + private async handlePostpaidTicketResolved( + event: TicketResolvedEvent, + subscription: any, + systemUserId: string, + ) { + if (event.type === 'installation') { + // Installation done → auto-create activation ticket + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { installedAt: new Date() }, + }); + + await this.ticketService.createSystemTicket(event.tenantId, systemUserId, { + clientId: event.clientId!, + type: 'activation', + title: `Activation for ${subscription.client.firstName} ${subscription.client.lastName}`, + description: 'Auto-created after installation completion (postpaid)', + }); + } else if (event.type === 'activation') { + // Activation done → activate subscription + const now = new Date(); + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'active', + activatedAt: now, + startDate: now, + }, + }); + + // Auto-create 1st invoice due 1 month from installation + const dueDate = new Date(subscription.installedAt || now); + dueDate.setDate(dueDate.getDate() + (subscription.plan.billingCycle || 30)); + + const invoiceCount = await this.prisma.invoice.count({ + where: { tenantId: event.tenantId }, + }); + + await this.prisma.invoice.create({ + data: { + tenantId: event.tenantId, + clientId: event.clientId!, + number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`, + amount: subscription.plan.price, + balance: subscription.plan.price, + status: 'sent', + dueDate, + periodStart: now, + periodEnd: dueDate, + }, + }); + } + } + + private async handlePrepaidTicketResolved( + event: TicketResolvedEvent, + subscription: any, + systemUserId: string, + ) { + if (event.type === 'installation') { + // Installation done → auto-create 1st invoice (must pay before activation) + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { installedAt: new Date() }, + }); + + const invoiceCount = await this.prisma.invoice.count({ + where: { tenantId: event.tenantId }, + }); + + await this.prisma.invoice.create({ + data: { + tenantId: event.tenantId, + clientId: event.clientId!, + number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`, + amount: subscription.plan.price, + balance: subscription.plan.price, + status: 'sent', + dueDate: new Date(), // Due immediately for prepaid + periodStart: new Date(), + periodEnd: new Date(Date.now() + (subscription.plan.billingCycle || 30) * 86400000), + }, + }); + } else if (event.type === 'activation') { + // Activation done → activate subscription + auto next invoice + const now = new Date(); + await this.prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'active', + activatedAt: now, + startDate: now, + }, + }); + + // Create next month invoice + const nextDue = new Date(now); + nextDue.setDate(nextDue.getDate() + (subscription.plan.billingCycle || 30)); + + const invoiceCount = await this.prisma.invoice.count({ + where: { tenantId: event.tenantId }, + }); + + await this.prisma.invoice.create({ + data: { + tenantId: event.tenantId, + clientId: event.clientId!, + number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`, + amount: subscription.plan.price, + balance: subscription.plan.price, + status: 'sent', + dueDate: nextDue, + periodStart: now, + periodEnd: nextDue, + }, + }); + } + } + + /** + * Called when a prepaid client pays their first invoice. + * Triggers auto-creation of activation ticket. + */ + async handlePrepaidPayment(tenantId: string, clientId: string, systemUserId: string) { + const subscription = await this.prisma.subscription.findFirst({ + where: { clientId, tenantId, type: 'prepaid', status: 'pending' }, + include: { client: true }, + }); + + if (!subscription) return; + + // Check if activation ticket already exists + const existingActivation = await this.prisma.ticket.findFirst({ + where: { clientId, tenantId, type: 'activation', status: { in: ['open', 'in_progress'] } }, + }); + + if (existingActivation) return; + + await this.ticketService.createSystemTicket(tenantId, systemUserId, { + clientId, + type: 'activation', + title: `Activation for ${subscription.client.firstName} ${subscription.client.lastName}`, + description: 'Auto-created after first payment received (prepaid)', + }); + } + + private async updateStatus( + tenantId: string, + id: string, + newStatus: string, + validFromStatuses: string[], + ) { + const db = this.prisma.forTenant(tenantId); + const sub = await db.subscription.findFirst({ where: { id } }); + + if (!sub) { + throw new NotFoundException('Subscription not found'); + } + + if (!validFromStatuses.includes(sub.status)) { + throw new BadRequestException( + `Cannot change status from '${sub.status}' to '${newStatus}'`, + ); + } + + return this.prisma.subscription.update({ + where: { id }, + data: { status: newStatus }, + include: { + plan: { select: { id: true, name: true, price: true } }, + client: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + } +} diff --git a/src/tenant/dto/update-tenant.dto.ts b/src/tenant/dto/update-tenant.dto.ts new file mode 100644 index 0000000..71edd7d --- /dev/null +++ b/src/tenant/dto/update-tenant.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsOptional, IsObject } from 'class-validator'; + +export class UpdateTenantDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsObject() + settings?: Record; +} diff --git a/src/tenant/tenant.controller.ts b/src/tenant/tenant.controller.ts new file mode 100644 index 0000000..eb92fc0 --- /dev/null +++ b/src/tenant/tenant.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TenantService } from './tenant.service'; +import { UpdateTenantDto } from './dto/update-tenant.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('tenant') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +export class TenantController { + constructor(private readonly tenantService: TenantService) {} + + @Get() + @Roles('manager') + async getSettings(@CurrentUser() user: CurrentUserPayload) { + return this.tenantService.getSettings(user.tenantId); + } + + @Patch() + @Roles('tenant_admin') + async updateSettings( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: UpdateTenantDto, + ) { + return this.tenantService.updateSettings(user.tenantId, dto); + } +} diff --git a/src/tenant/tenant.module.ts b/src/tenant/tenant.module.ts new file mode 100644 index 0000000..094a6ee --- /dev/null +++ b/src/tenant/tenant.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TenantController } from './tenant.controller'; +import { TenantService } from './tenant.service'; + +@Module({ + controllers: [TenantController], + providers: [TenantService], + exports: [TenantService], +}) +export class TenantModule {} diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts new file mode 100644 index 0000000..c03ad18 --- /dev/null +++ b/src/tenant/tenant.service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpdateTenantDto } from './dto/update-tenant.dto'; + +@Injectable() +export class TenantService { + constructor(private readonly prisma: PrismaService) {} + + async getSettings(tenantId: string) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { + id: true, + name: true, + slug: true, + settings: true, + isActive: true, + createdAt: true, + }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + return tenant; + } + + async updateSettings(tenantId: string, dto: UpdateTenantDto) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + const currentSettings = + typeof tenant.settings === 'object' && tenant.settings !== null + ? tenant.settings + : {}; + + const updated = await this.prisma.tenant.update({ + where: { id: tenantId }, + data: { + name: dto.name ?? tenant.name, + settings: { + ...(currentSettings as object), + ...(dto.settings ?? {}), + } as any, + }, + select: { + id: true, + name: true, + slug: true, + settings: true, + isActive: true, + createdAt: true, + }, + }); + + return updated; + } +} diff --git a/src/ticket/dto/create-ticket.dto.ts b/src/ticket/dto/create-ticket.dto.ts new file mode 100644 index 0000000..ee3d57a --- /dev/null +++ b/src/ticket/dto/create-ticket.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional, IsIn, MinLength, IsUUID } from 'class-validator'; + +export class CreateTicketDto { + @IsOptional() + @IsUUID() + clientId?: string; + + @IsOptional() + @IsUUID() + assigneeId?: string; + + @IsString() + @IsIn(['installation', 'activation', 'support', 'maintenance']) + type: string; + + @IsString() + @MinLength(3) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @IsIn(['low', 'normal', 'high', 'urgent']) + priority?: string; +} diff --git a/src/ticket/dto/update-ticket.dto.ts b/src/ticket/dto/update-ticket.dto.ts new file mode 100644 index 0000000..65401d0 --- /dev/null +++ b/src/ticket/dto/update-ticket.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsOptional, IsIn, MinLength, IsUUID } from 'class-validator'; + +export class UpdateTicketDto { + @IsOptional() + @IsUUID() + assigneeId?: string; + + @IsOptional() + @IsString() + @MinLength(3) + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @IsIn(['low', 'normal', 'high', 'urgent']) + priority?: string; + + @IsOptional() + @IsString() + @IsIn(['open', 'in_progress', 'resolved', 'cancelled']) + status?: string; +} diff --git a/src/ticket/ticket.controller.ts b/src/ticket/ticket.controller.ts new file mode 100644 index 0000000..fcf8c11 --- /dev/null +++ b/src/ticket/ticket.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TicketService } from './ticket.service'; +import { CreateTicketDto } from './dto/create-ticket.dto'; +import { UpdateTicketDto } from './dto/update-ticket.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('tickets') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +export class TicketController { + constructor(private readonly ticketService: TicketService) {} + + @Get() + @Roles('technician') + async findAll( + @CurrentUser() user: CurrentUserPayload, + @Query('clientId') clientId?: string, + @Query('status') status?: string, + @Query('type') type?: string, + ) { + return this.ticketService.findAll(user.tenantId, { clientId, status, type }); + } + + @Get(':id') + @Roles('technician') + async findById( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.ticketService.findById(user.tenantId, id); + } + + @Post() + @Roles('manager') + async create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateTicketDto, + ) { + return this.ticketService.create(user.tenantId, user.sub, dto); + } + + @Patch(':id') + @Roles('technician') + async update( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() dto: UpdateTicketDto, + ) { + return this.ticketService.update(user.tenantId, id, dto); + } + + @Patch(':id/resolve') + @Roles('technician') + async resolve( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() body?: { latitude?: number; longitude?: number }, + ) { + return this.ticketService.resolve(user.tenantId, id, user.sub, body); + } +} diff --git a/src/ticket/ticket.module.ts b/src/ticket/ticket.module.ts new file mode 100644 index 0000000..b21989a --- /dev/null +++ b/src/ticket/ticket.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TicketController } from './ticket.controller'; +import { TicketService } from './ticket.service'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [NotificationModule], + controllers: [TicketController], + providers: [TicketService], + exports: [TicketService], +}) +export class TicketModule {} diff --git a/src/ticket/ticket.service.ts b/src/ticket/ticket.service.ts new file mode 100644 index 0000000..3484281 --- /dev/null +++ b/src/ticket/ticket.service.ts @@ -0,0 +1,186 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateTicketDto } from './dto/create-ticket.dto'; +import { UpdateTicketDto } from './dto/update-ticket.dto'; +import { NotificationService } from '../notification/notification.service'; + +export interface TicketResolvedEvent { + ticketId: string; + tenantId: string; + clientId: string | null; + type: string; +} + +@Injectable() +export class TicketService { + // Event handler for ticket resolution — set by SubscriptionService + onTicketResolved: ((event: TicketResolvedEvent) => Promise) | null = + null; + + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} + + async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) { + const db = this.prisma.forTenant(tenantId); + return db.ticket.findMany({ + where: { + ...(filters?.clientId && { clientId: filters.clientId }), + ...(filters?.status && { status: filters.status }), + ...(filters?.type && { type: filters.type }), + }, + include: { + client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } }, + assignee: { select: { id: true, firstName: true, lastName: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(tenantId: string, id: string) { + const db = this.prisma.forTenant(tenantId); + const ticket = await db.ticket.findFirst({ + where: { id }, + include: { + client: true, + assignee: { select: { id: true, firstName: true, lastName: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + + return ticket; + } + + async create(tenantId: string, createdById: string, dto: CreateTicketDto) { + return this.prisma.ticket.create({ + data: { + tenantId, + clientId: dto.clientId, + createdById, + assigneeId: dto.assigneeId, + type: dto.type, + title: dto.title, + description: dto.description, + priority: dto.priority ?? 'normal', + }, + }); + } + + async createSystemTicket( + tenantId: string, + systemUserId: string, + data: { + clientId: string; + type: string; + title: string; + description?: string; + }, + ) { + return this.prisma.ticket.create({ + data: { + tenantId, + clientId: data.clientId, + createdById: systemUserId, + type: data.type, + title: data.title, + description: data.description, + priority: 'high', + }, + }); + } + + async update(tenantId: string, id: string, dto: UpdateTicketDto) { + const db = this.prisma.forTenant(tenantId); + const existing = await db.ticket.findFirst({ where: { id } }); + + if (!existing) { + throw new NotFoundException('Ticket not found'); + } + + return this.prisma.ticket.update({ + where: { id }, + data: { + ...(dto.assigneeId !== undefined && { assigneeId: dto.assigneeId }), + ...(dto.title && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.priority && { priority: dto.priority }), + ...(dto.status && { status: dto.status }), + }, + }).then(async (ticket) => { + // Notify on status change + if (dto.status) { + this.notificationService.create(tenantId, { + type: 'in_app', + channel: 'ticket_update', + title: 'Ticket Updated', + message: `Ticket "${existing.title}" status changed to ${dto.status.replace(/_/g, ' ')}`, + }).catch(() => {}); + } + return ticket; + }); + } + + async resolve(tenantId: string, id: string, resolvedById: string, coords?: { latitude?: number; longitude?: number }) { + const db = this.prisma.forTenant(tenantId); + const ticket = await db.ticket.findFirst({ where: { id } }); + + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + + if (ticket.status === 'resolved') { + throw new BadRequestException('Ticket is already resolved'); + } + + if (ticket.status === 'cancelled') { + throw new BadRequestException('Cannot resolve a cancelled ticket'); + } + + const resolved = await this.prisma.ticket.update({ + where: { id }, + data: { + status: 'resolved', + resolvedAt: new Date(), + assigneeId: resolvedById, + }, + }); + + // Update client coordinates if this is an installation ticket with coords + if (coords?.latitude !== undefined && coords?.longitude !== undefined && ticket.clientId && ticket.type === 'installation') { + await this.prisma.client.update({ + where: { id: ticket.clientId }, + data: { latitude: coords.latitude, longitude: coords.longitude }, + }); + } + + // Fire event for workflow automation + if (this.onTicketResolved && ticket.clientId) { + await this.onTicketResolved({ + ticketId: id, + tenantId, + clientId: ticket.clientId, + type: ticket.type, + }); + } + + // Notify on ticket resolution + this.notificationService.create(tenantId, { + type: 'in_app', + channel: 'ticket_update', + title: 'Ticket Resolved', + message: `Ticket "${ticket.title}" has been resolved`, + }).catch(() => {}); + + return resolved; + } +} diff --git a/src/user/dto/create-user.dto.ts b/src/user/dto/create-user.dto.ts new file mode 100644 index 0000000..3b77f61 --- /dev/null +++ b/src/user/dto/create-user.dto.ts @@ -0,0 +1,28 @@ +import { IsEmail, IsString, MinLength, IsArray, IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class CreateUserDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; + + @IsString() + @MinLength(1) + firstName: string; + + @IsString() + @MinLength(1) + lastName: string; + + @IsOptional() + @IsArray() + @IsIn(['tenant_admin', 'manager', 'technician', 'viewer'], { each: true }) + roles?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + tenantRoleIds?: string[]; +} diff --git a/src/user/dto/update-user.dto.ts b/src/user/dto/update-user.dto.ts new file mode 100644 index 0000000..78d5f2e --- /dev/null +++ b/src/user/dto/update-user.dto.ts @@ -0,0 +1,23 @@ +import { IsString, MinLength, IsArray, IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + @MinLength(1) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + lastName?: string; + + @IsOptional() + @IsArray() + @IsIn(['tenant_admin', 'manager', 'technician', 'viewer'], { each: true }) + roles?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + tenantRoleIds?: string[]; +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts new file mode 100644 index 0000000..9acaab2 --- /dev/null +++ b/src/user/user.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { UserService } from './user.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.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('users') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +@Roles('tenant_admin') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserPayload) { + return this.userService.findAll(user.tenantId); + } + + @Get(':id') + async findById( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.userService.findById(user.tenantId, id); + } + + @Post() + async create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateUserDto, + ) { + return this.userService.create(user.tenantId, dto); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() dto: UpdateUserDto, + ) { + return this.userService.update(user.tenantId, id, dto); + } + + @Patch(':id/toggle-active') + async toggleActive( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + ) { + return this.userService.toggleActive(user.tenantId, id); + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 0000000..ad81132 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; +import { AccountingModule } from '../accounting/accounting.module'; + +@Module({ + imports: [AccountingModule], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts new file mode 100644 index 0000000..0484ac0 --- /dev/null +++ b/src/user/user.service.ts @@ -0,0 +1,176 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { JournalService } from '../accounting/journal.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UserService { + constructor( + private readonly prisma: PrismaService, + private readonly journal: JournalService, + ) {} + + private formatUser(user: any) { + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + isActive: user.isActive, + roles: user.roles?.map((r: any) => r.role) ?? [], + tenantRoles: user.tenantRoles?.map((tr: any) => ({ + id: tr.tenantRole.id, + name: tr.tenantRole.name, + slug: tr.tenantRole.slug, + })) ?? [], + createdAt: user.createdAt, + }; + } + + private readonly userInclude = { + roles: { select: { role: true } }, + tenantRoles: { + include: { + tenantRole: { select: { id: true, name: true, slug: true } }, + }, + }, + }; + + async findAll(tenantId: string) { + const db = this.prisma.forTenant(tenantId); + const users = await db.user.findMany({ + include: this.userInclude, + orderBy: { createdAt: 'desc' }, + }); + return users.map((u) => this.formatUser(u)); + } + + async findById(tenantId: string, userId: string) { + const db = this.prisma.forTenant(tenantId); + const user = await db.user.findFirst({ + where: { id: userId }, + include: this.userInclude, + }); + if (!user) throw new NotFoundException('User not found'); + return this.formatUser(user); + } + + async create(tenantId: string, dto: CreateUserDto) { + const existing = await this.prisma.user.findFirst({ + where: { tenantId, email: dto.email }, + }); + if (existing) throw new ConflictException('Email already exists in this tenant'); + + const hashedPassword = await bcrypt.hash(dto.password, 12); + + const user = await this.prisma.user.create({ + data: { + tenantId, + email: dto.email, + password: hashedPassword, + firstName: dto.firstName, + lastName: dto.lastName, + }, + }); + + // Assign tenant roles if provided + if (dto.tenantRoleIds?.length) { + await this.prisma.userTenantRole.createMany({ + data: dto.tenantRoleIds.map((roleId) => ({ + userId: user.id, + tenantRoleId: roleId, + })), + }); + } + + // Legacy: also create UserRole for backward compat (uses first tenant role slug) + if (dto.roles?.length) { + await this.prisma.userRole.createMany({ + data: dto.roles.map((role) => ({ userId: user.id, role })), + }); + } + + // Auto-create custodial CoA accounts + this.journal.createCustodialAccounts( + tenantId, user.id, `${user.firstName} ${user.lastName}`, + ).catch(() => {}); + + const created = await this.prisma.user.findUnique({ + where: { id: user.id }, + include: this.userInclude, + }); + return this.formatUser(created); + } + + async update(tenantId: string, userId: string, dto: UpdateUserDto) { + const db = this.prisma.forTenant(tenantId); + const existing = await db.user.findFirst({ where: { id: userId } }); + if (!existing) throw new NotFoundException('User not found'); + + // Update basic fields + const updateData: any = {}; + if (dto.firstName) updateData.firstName = dto.firstName; + if (dto.lastName) updateData.lastName = dto.lastName; + + if (Object.keys(updateData).length > 0) { + await db.user.update({ + where: { id: userId }, + data: updateData, + }); + } + + // Update tenant roles if provided + if (dto.tenantRoleIds !== undefined) { + // Verify all role IDs belong to this tenant + const roles = await this.prisma.tenantRole.findMany({ + where: { id: { in: dto.tenantRoleIds }, tenantId, deletedAt: null }, + }); + if (roles.length !== dto.tenantRoleIds.length) { + throw new NotFoundException('One or more role IDs are invalid'); + } + + await this.prisma.userTenantRole.deleteMany({ where: { userId } }); + if (dto.tenantRoleIds.length > 0) { + await this.prisma.userTenantRole.createMany({ + data: dto.tenantRoleIds.map((roleId) => ({ + userId, + tenantRoleId: roleId, + })), + }); + } + } + + // Legacy role update (backward compat) + if (dto.roles) { + await this.prisma.userRole.deleteMany({ where: { userId } }); + await this.prisma.userRole.createMany({ + data: dto.roles.map((role) => ({ userId, role })), + }); + } + + const updated = await this.prisma.user.findUnique({ + where: { id: userId }, + include: this.userInclude, + }); + return this.formatUser(updated); + } + + async toggleActive(tenantId: string, userId: string) { + const db = this.prisma.forTenant(tenantId); + const existing = await db.user.findFirst({ where: { id: userId } }); + if (!existing) throw new NotFoundException('User not found'); + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { isActive: !existing.isActive }, + include: this.userInclude, + }); + return this.formatUser(user); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c5c41a8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "strictPropertyInitialization": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..bbcdfc3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + root: './', + include: ['src/**/*.spec.ts', 'test/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/main.ts', 'src/**/*.module.ts', 'src/**/*.spec.ts'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +});