feat(crm): add contracts, cost sheets, invoices modules and API clients

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-11 16:40:25 +04:00
parent 8a20927044
commit 18c13cdf7c
12 changed files with 1483 additions and 10 deletions

View File

@@ -0,0 +1,65 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { contractsService } from './contracts.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class ContractsController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(contract, 'تم إنشاء العقد - Contract created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.findById(req.params.id);
res.json(ResponseFormatter.success(contract));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await contractsService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم تحديث العقد - Contract updated'));
} catch (error) {
next(error);
}
}
async updateStatus(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { status } = req.body;
const contract = await contractsService.updateStatus(req.params.id, status, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم تحديث حالة العقد - Contract status updated'));
} catch (error) {
next(error);
}
}
async markSigned(req: AuthRequest, res: Response, next: NextFunction) {
try {
const contract = await contractsService.markSigned(req.params.id, req.user!.id);
res.json(ResponseFormatter.success(contract, 'تم توقيع العقد - Contract signed'));
} catch (error) {
next(error);
}
}
}
export const contractsController = new ContractsController();

View File

@@ -0,0 +1,136 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateContractData {
dealId: string;
title: string;
type: string;
clientInfo: any;
companyInfo: any;
startDate: Date;
endDate?: Date;
value: number;
paymentTerms: any;
deliveryTerms: any;
terms: string;
}
class ContractsService {
async create(data: CreateContractData, userId: string) {
const contractNumber = await this.generateContractNumber();
const contract = await prisma.contract.create({
data: {
contractNumber,
dealId: data.dealId,
title: data.title,
type: data.type,
clientInfo: data.clientInfo || {},
companyInfo: data.companyInfo || {},
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
value: data.value,
paymentTerms: data.paymentTerms || {},
deliveryTerms: data.deliveryTerms || {},
terms: data.terms,
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: contract.id,
action: 'CREATE',
userId,
});
return contract;
}
async findById(id: string) {
const contract = await prisma.contract.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!contract) throw new AppError(404, 'العقد غير موجود - Contract not found');
return contract;
}
async findByDeal(dealId: string) {
return prisma.contract.findMany({
where: { dealId },
orderBy: { createdAt: 'desc' },
});
}
async updateStatus(id: string, status: string, userId: string) {
const contract = await prisma.contract.update({
where: { id },
data: { status },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'STATUS_CHANGE',
userId,
changes: { status },
});
return contract;
}
async markSigned(id: string, userId: string) {
const contract = await prisma.contract.update({
where: { id },
data: { status: 'ACTIVE', signedAt: new Date() },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'SIGN',
userId,
});
return contract;
}
async update(id: string, data: Partial<CreateContractData>, userId: string) {
const updateData: Record<string, any> = { ...data };
if (updateData.startDate) updateData.startDate = new Date(updateData.startDate);
if (updateData.endDate !== undefined) updateData.endDate = updateData.endDate ? new Date(updateData.endDate) : null;
const contract = await prisma.contract.update({
where: { id },
data: updateData,
include: { deal: { include: { contact: true, owner: true } } },
});
await AuditLogger.log({
entityType: 'CONTRACT',
entityId: id,
action: 'UPDATE',
userId,
});
return contract;
}
private async generateContractNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CTR-${year}-`;
const last = await prisma.contract.findFirst({
where: { contractNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { contractNumber: true },
});
let next = 1;
if (last) {
const parts = last.contractNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(6, '0')}`;
}
}
export const contractsService = new ContractsService();

View File

@@ -0,0 +1,55 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { costSheetsService } from './costSheets.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class CostSheetsController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(cs, 'تم إنشاء كشف التكلفة - Cost sheet created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.findById(req.params.id);
res.json(ResponseFormatter.success(cs));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await costSheetsService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async approve(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.approve(req.params.id, req.user!.id, req.user!.id);
res.json(ResponseFormatter.success(cs, 'تمت الموافقة على كشف التكلفة - Cost sheet approved'));
} catch (error) {
next(error);
}
}
async reject(req: AuthRequest, res: Response, next: NextFunction) {
try {
const cs = await costSheetsService.reject(req.params.id, req.user!.id);
res.json(ResponseFormatter.success(cs, 'تم رفض كشف التكلفة - Cost sheet rejected'));
} catch (error) {
next(error);
}
}
}
export const costSheetsController = new CostSheetsController();

View File

@@ -0,0 +1,113 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateCostSheetData {
dealId: string;
items: any[];
totalCost: number;
suggestedPrice: number;
profitMargin: number;
}
class CostSheetsService {
async create(data: CreateCostSheetData, userId: string) {
const latest = await prisma.costSheet.findFirst({
where: { dealId: data.dealId },
orderBy: { version: 'desc' },
select: { version: true },
});
const version = latest ? latest.version + 1 : 1;
const costSheetNumber = await this.generateCostSheetNumber();
const costSheet = await prisma.costSheet.create({
data: {
costSheetNumber,
dealId: data.dealId,
version,
items: data.items,
totalCost: data.totalCost,
suggestedPrice: data.suggestedPrice,
profitMargin: data.profitMargin,
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: costSheet.id,
action: 'CREATE',
userId,
});
return costSheet;
}
async findById(id: string) {
const cs = await prisma.costSheet.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!cs) throw new AppError(404, 'كشف التكلفة غير موجود - Cost sheet not found');
return cs;
}
async findByDeal(dealId: string) {
return prisma.costSheet.findMany({
where: { dealId },
orderBy: { version: 'desc' },
});
}
async approve(id: string, approvedBy: string, userId: string) {
const cs = await prisma.costSheet.update({
where: { id },
data: { status: 'APPROVED', approvedBy, approvedAt: new Date() },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: id,
action: 'APPROVE',
userId,
});
return cs;
}
async reject(id: string, userId: string) {
const cs = await prisma.costSheet.update({
where: { id },
data: { status: 'REJECTED', approvedBy: null, approvedAt: null },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'COST_SHEET',
entityId: id,
action: 'REJECT',
userId,
});
return cs;
}
private async generateCostSheetNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CS-${year}-`;
const last = await prisma.costSheet.findFirst({
where: { costSheetNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { costSheetNumber: true },
});
let next = 1;
if (last) {
const parts = last.costSheetNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(6, '0')}`;
}
}
export const costSheetsService = new CostSheetsService();

View File

@@ -1,6 +1,9 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { pipelinesController, dealsController, quotesController } from './crm.controller';
import { costSheetsController } from './costSheets.controller';
import { contractsController } from './contracts.controller';
import { invoicesController } from './invoices.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
@@ -171,5 +174,153 @@ router.post(
quotesController.send
);
// ============= COST SHEETS =============
router.get(
'/deals/:dealId/cost-sheets',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
costSheetsController.findByDeal
);
router.get(
'/cost-sheets/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
costSheetsController.findById
);
router.post(
'/cost-sheets',
authorize('crm', 'deals', 'create'),
[
body('dealId').isUUID(),
body('items').isArray(),
body('totalCost').isNumeric(),
body('suggestedPrice').isNumeric(),
body('profitMargin').isNumeric(),
validate,
],
costSheetsController.create
);
router.post(
'/cost-sheets/:id/approve',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
costSheetsController.approve
);
router.post(
'/cost-sheets/:id/reject',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
costSheetsController.reject
);
// ============= CONTRACTS =============
router.get(
'/deals/:dealId/contracts',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
contractsController.findByDeal
);
router.get(
'/contracts/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
contractsController.findById
);
router.post(
'/contracts',
authorize('crm', 'deals', 'create'),
[
body('dealId').isUUID(),
body('title').notEmpty().trim(),
body('type').notEmpty().trim(),
body('startDate').isISO8601(),
body('value').isNumeric(),
body('terms').notEmpty().trim(),
validate,
],
contractsController.create
);
router.put(
'/contracts/:id',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
contractsController.update
);
router.post(
'/contracts/:id/sign',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
contractsController.markSigned
);
// ============= INVOICES =============
router.get(
'/deals/:dealId/invoices',
authorize('crm', 'deals', 'read'),
param('dealId').isUUID(),
validate,
invoicesController.findByDeal
);
router.get(
'/invoices/:id',
authorize('crm', 'deals', 'read'),
param('id').isUUID(),
validate,
invoicesController.findById
);
router.post(
'/invoices',
authorize('crm', 'deals', 'create'),
[
body('items').isArray(),
body('subtotal').isNumeric(),
body('taxAmount').isNumeric(),
body('total').isNumeric(),
body('dueDate').isISO8601(),
validate,
],
invoicesController.create
);
router.put(
'/invoices/:id',
authorize('crm', 'deals', 'update'),
param('id').isUUID(),
validate,
invoicesController.update
);
router.post(
'/invoices/:id/record-payment',
authorize('crm', 'deals', 'update'),
[
param('id').isUUID(),
body('paidAmount').isNumeric(),
validate,
],
invoicesController.recordPayment
);
export default router;

View File

@@ -0,0 +1,61 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { invoicesService } from './invoices.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class InvoicesController {
async create(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.create(req.body, req.user!.id);
res.status(201).json(
ResponseFormatter.success(invoice, 'تم إنشاء الفاتورة - Invoice created')
);
} catch (error) {
next(error);
}
}
async findById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.findById(req.params.id);
res.json(ResponseFormatter.success(invoice));
} catch (error) {
next(error);
}
}
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
try {
const list = await invoicesService.findByDeal(req.params.dealId);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async update(req: AuthRequest, res: Response, next: NextFunction) {
try {
const invoice = await invoicesService.update(req.params.id, req.body, req.user!.id);
res.json(ResponseFormatter.success(invoice, 'تم تحديث الفاتورة - Invoice updated'));
} catch (error) {
next(error);
}
}
async recordPayment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { paidAmount, paidDate } = req.body;
const invoice = await invoicesService.recordPayment(
req.params.id,
paidAmount,
paidDate ? new Date(paidDate) : new Date(),
req.user!.id
);
res.json(ResponseFormatter.success(invoice, 'تم تسجيل الدفع - Payment recorded'));
} catch (error) {
next(error);
}
}
}
export const invoicesController = new InvoicesController();

View File

@@ -0,0 +1,130 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
interface CreateInvoiceData {
dealId?: string;
items: any[];
subtotal: number;
taxAmount: number;
total: number;
dueDate: Date;
}
class InvoicesService {
async create(data: CreateInvoiceData, userId: string) {
const invoiceNumber = await this.generateInvoiceNumber();
const invoice = await prisma.invoice.create({
data: {
invoiceNumber,
dealId: data.dealId || null,
items: data.items,
subtotal: data.subtotal,
taxAmount: data.taxAmount,
total: data.total,
dueDate: new Date(data.dueDate),
status: 'DRAFT',
},
include: {
deal: { include: { contact: true, owner: true } },
},
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: invoice.id,
action: 'CREATE',
userId,
});
return invoice;
}
async findById(id: string) {
const invoice = await prisma.invoice.findUnique({
where: { id },
include: {
deal: { include: { contact: true, owner: true } },
},
});
if (!invoice) throw new AppError(404, 'الفاتورة غير موجودة - Invoice not found');
return invoice;
}
async findByDeal(dealId: string) {
return prisma.invoice.findMany({
where: { dealId },
orderBy: { createdAt: 'desc' },
});
}
async updateStatus(id: string, status: string, userId: string) {
const invoice = await prisma.invoice.update({
where: { id },
data: { status },
include: { deal: true },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'STATUS_CHANGE',
userId,
changes: { status },
});
return invoice;
}
async recordPayment(id: string, paidAmount: number, paidDate: Date, userId: string) {
const invoice = await prisma.invoice.update({
where: { id },
data: {
status: 'PAID',
paidAmount,
paidDate: new Date(paidDate),
},
include: { deal: true },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'PAYMENT_RECORDED',
userId,
changes: { paidAmount, paidDate },
});
return invoice;
}
async update(id: string, data: Partial<CreateInvoiceData>, userId: string) {
const updateData: Record<string, any> = { ...data };
if (updateData.dueDate) updateData.dueDate = new Date(updateData.dueDate);
const invoice = await prisma.invoice.update({
where: { id },
data: updateData,
include: { deal: { include: { contact: true, owner: true } } },
});
await AuditLogger.log({
entityType: 'INVOICE',
entityId: id,
action: 'UPDATE',
userId,
});
return invoice;
}
private async generateInvoiceNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `INV-${year}-`;
const last = await prisma.invoice.findFirst({
where: { invoiceNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { invoiceNumber: true },
});
let next = 1;
if (last) {
const parts = last.invoiceNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(6, '0')}`;
}
}
export const invoicesService = new InvoicesService();