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:
2026-10-18 19:33:09 +03:30
co-authored by Cursor
parent ab538b8304
commit 8981a552fc
25 changed files with 509 additions and 33 deletions
@@ -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),
);
}
}
+18 -5
View File
@@ -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,
+10 -2
View File
@@ -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 {}
+34
View File
@@ -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 },