feat: gate treasury as an org module and group money inputs
Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryAccessGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||
const actor = req.user;
|
||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||
const url = req.originalUrl || req.url || '';
|
||||
const payroll = url.includes('/treasury/payroll');
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
const allowed = payroll
|
||||
? permissions.includes('treasury_payroll')
|
||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryBridgeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async postCash(input: {
|
||||
organizationId: string;
|
||||
accountId: string;
|
||||
amount: Prisma.Decimal | number | string;
|
||||
direction: 'in' | 'out';
|
||||
kind: 'receipt' | 'payment';
|
||||
occurredAt: Date;
|
||||
memo: string;
|
||||
actorUserId?: string | null;
|
||||
paymentId?: string | null;
|
||||
chequeId?: string | null;
|
||||
}) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: input.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب صندوق پیدا نشد.');
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
if (!amount.greaterThan(0)) return;
|
||||
if (input.paymentId) {
|
||||
const existing = await this.prisma.financialMovement.findFirst({
|
||||
where: { paymentId: input.paymentId, voidedAt: null },
|
||||
});
|
||||
if (existing) return;
|
||||
}
|
||||
if (input.chequeId) {
|
||||
const existing = await this.prisma.financialMovement.findFirst({
|
||||
where: { chequeId: input.chequeId, voidedAt: null },
|
||||
});
|
||||
if (existing) return;
|
||||
}
|
||||
if (input.direction === 'out') {
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
await this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
accountId: account.id,
|
||||
direction: input.direction,
|
||||
amount,
|
||||
kind: input.kind,
|
||||
occurredAt: input.occurredAt,
|
||||
memo: input.memo,
|
||||
paymentId: input.paymentId ?? null,
|
||||
chequeId: input.chequeId ?? null,
|
||||
createdById: input.actorUserId ?? null,
|
||||
},
|
||||
});
|
||||
await this.audit(input.organizationId, input.actorUserId, 'cash_post', input.paymentId || input.chequeId || account.id, null, {
|
||||
accountId: account.id,
|
||||
amount: amount.toFixed(2),
|
||||
direction: input.direction,
|
||||
});
|
||||
}
|
||||
|
||||
async voidPayment(paymentId: string) {
|
||||
await this.prisma.financialMovement.updateMany({
|
||||
where: { paymentId, voidedAt: null },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async voidCheque(chequeId: string) {
|
||||
await this.prisma.financialMovement.updateMany({
|
||||
where: { chequeId, voidedAt: null },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async audit(
|
||||
organizationId: string,
|
||||
actorUserId: string | null | undefined,
|
||||
action: string,
|
||||
entityId: string,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
entityType = 'treasury',
|
||||
) {
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId,
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
before: before == null ? undefined : (before as Prisma.InputJsonValue),
|
||||
after: after == null ? undefined : (after as Prisma.InputJsonValue),
|
||||
actorUserId: actorUserId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async balance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Controller('treasury')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, TreasuryAccessGuard)
|
||||
@RequireOrgModule('treasury')
|
||||
export class TreasuryController {
|
||||
constructor(
|
||||
private readonly treasury: TreasuryService,
|
||||
@@ -92,6 +95,16 @@ export class TreasuryController {
|
||||
return this.treasury.createDocument(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/attachments')
|
||||
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
|
||||
addAttachment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer },
|
||||
) {
|
||||
return this.treasury.addAttachment(user, id, file);
|
||||
}
|
||||
|
||||
@Post('documents/:id/settle')
|
||||
settle(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
import { TreasuryBridgeService } from './treasury-bridge.service';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
@@ -9,7 +11,13 @@ import { TreasuryService } from './treasury.service';
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService],
|
||||
exports: [TreasuryService],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
TreasuryReceivablesService,
|
||||
TreasuryOpsService,
|
||||
TreasuryBridgeService,
|
||||
TreasuryAccessGuard,
|
||||
],
|
||||
exports: [TreasuryService, TreasuryBridgeService],
|
||||
})
|
||||
export class TreasuryModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
|
||||
@@ -58,6 +59,7 @@ export class TreasuryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
@@ -507,6 +509,16 @@ export class TreasuryService {
|
||||
data: { status, accountId: account.id },
|
||||
}),
|
||||
]);
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'document',
|
||||
entityId: doc.id,
|
||||
action: 'settle',
|
||||
after: { amount: amount.toFixed(2), accountId: account.id, status },
|
||||
actorUserId: actor.userId,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
if (status === 'settled') {
|
||||
await this.prisma.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: doc.id, status: 'pending' },
|
||||
@@ -543,6 +555,28 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id: documentId, organizationId: actor.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!doc) throw new NotFoundException('سند پیدا نشد.');
|
||||
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
|
||||
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
return this.prisma.financialAttachment.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
documentId: doc.id,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
storageKey,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
select: { id: true, fileName: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async dashboard(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
|
||||
Reference in New Issue
Block a user