fix(treasury): scope finance access and hide it when the module is off

Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 10:02:31 +03:30
co-authored by Cursor
parent 14f4e9d7d8
commit 1ccbefa7a3
26 changed files with 895 additions and 135 deletions
+54 -9
View File
@@ -1,7 +1,16 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
parseUserTreasuryAccess,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
/**
* حقوق فقط با treasury_payroll.
* بقیه مسیرها با treasury باز می‌شوند؛ اگر بخش ریز انتخاب شده باشد فقط همان‌ها.
*/
@Injectable()
export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
@@ -10,20 +19,56 @@ export class TreasuryAccessGuard implements CanActivate {
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 } } },
select: {
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
});
const permissions = Array.isArray(membership?.orgRole?.permissions)
const rolePermissions = 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('دسترسی مالی برای نقش شما مجاز نیست.');
const permissions = permissionsForTreasury(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
const url = req.originalUrl || req.url || '';
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
return true;
}
}
function permissionsForTreasury(
rolePermissions: string[],
override: ReturnType<typeof parseUserTreasuryAccess>,
): string[] {
if (!override) return rolePermissions;
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
if (url.includes('/treasury/payroll')) return 'treasury_payroll';
if (url.includes('/treasury/receivables') || url.includes('/treasury/installments')) {
return 'treasury_receivables';
}
if (url.includes('/treasury/recurring')) return 'treasury_recurring';
if (url.includes('/treasury/reports')) return 'treasury_reports';
if (url.includes('/treasury/audit')) return 'treasury_audit';
if (url.includes('/treasury/categories')) return 'treasury_categories';
if (
url.includes('/treasury/accounts') ||
url.includes('/treasury/transfers') ||
url.includes('/treasury/reconciliations') ||
url.includes('/treasury/adjustments')
) {
return 'treasury_accounts';
}
if (url.includes('/treasury/dashboard')) return 'treasury_dashboard';
return 'treasury_documents';
}
+12 -1
View File
@@ -61,7 +61,18 @@ export class TreasuryController {
updateAccount(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
@Body()
body: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
) {
return this.treasury.updateAccount(user, id, body);
}
+144 -20
View File
@@ -1,8 +1,10 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
@@ -129,8 +131,12 @@ export class TreasuryService {
actor: Actor,
range?: { from?: string; to?: string },
): Promise<FinancialAccountDto[]> {
const scope = await this.accountScope(actor);
const accounts = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId },
where: {
organizationId: actor.organizationId,
...(scope ? { id: { in: scope } } : {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
@@ -196,19 +202,89 @@ export class TreasuryService {
async updateAccount(
actor: Actor,
id: string,
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
input: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
): Promise<FinancialAccountDto> {
const existing = await this.requireAccount(actor.organizationId, id);
const row = await this.prisma.financialAccount.update({
where: { id: existing.id },
data: {
...(input.name?.trim() ? { name: input.name.trim() } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
...(input.responsibleName !== undefined
? { responsibleName: input.responsibleName?.trim() || null }
: {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
},
const existing = await this.requireAccount(actor.organizationId, id, actor);
if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
throw new BadRequestException('نوع حساب نامعتبر است.');
}
const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
if (opening?.isNegative()) {
throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.');
}
const openingDate =
input.openingDate !== undefined
? (startOfDay(input.openingDate) ?? existing.openingDate)
: null;
const row = await this.prisma.$transaction(async (tx) => {
const updated = await tx.financialAccount.update({
where: { id: existing.id },
data: {
...(input.name?.trim() ? { name: input.name.trim() } : {}),
...(input.type ? { type: input.type } : {}),
...(input.bankName !== undefined ? { bankName: input.bankName?.trim() || null } : {}),
...(input.accountRef !== undefined ? { accountRef: input.accountRef?.trim() || null } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
...(input.responsibleName !== undefined
? { responsibleName: input.responsibleName?.trim() || null }
: {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
...(opening ? { openingBalance: opening } : {}),
...(openingDate ? { openingDate } : {}),
},
});
if (opening || openingDate) {
const nextAmount = opening ?? existing.openingBalance;
const nextDate = openingDate ?? existing.openingDate;
const openings = await tx.financialMovement.findMany({
where: { accountId: existing.id, kind: 'opening', voidedAt: null },
orderBy: { createdAt: 'asc' },
});
if (nextAmount.isZero()) {
if (openings.length) {
await tx.financialMovement.updateMany({
where: { id: { in: openings.map((row) => row.id) } },
data: { voidedAt: new Date() },
});
}
} else if (openings[0]) {
await tx.financialMovement.update({
where: { id: openings[0].id },
data: { amount: nextAmount, occurredAt: nextDate, direction: 'in' },
});
const extra = openings.slice(1).map((row) => row.id);
if (extra.length) {
await tx.financialMovement.updateMany({
where: { id: { in: extra } },
data: { voidedAt: new Date() },
});
}
} else {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: existing.id,
direction: 'in',
amount: nextAmount,
kind: 'opening',
occurredAt: nextDate,
memo: 'مانده افتتاحیه',
createdById: actor.userId,
},
});
}
}
return updated;
});
return this.toAccountDto(row);
}
@@ -218,7 +294,7 @@ export class TreasuryService {
accountId: string,
range?: { from?: string; to?: string },
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
const account = await this.requireAccount(actor.organizationId, accountId);
const account = await this.requireAccount(actor.organizationId, accountId, actor);
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const rows = await this.prisma.financialMovement.findMany({
@@ -303,8 +379,8 @@ export class TreasuryService {
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
}
const [source, target] = await Promise.all([
this.requireAccount(actor.organizationId, input.fromAccountId),
this.requireAccount(actor.organizationId, input.toAccountId),
this.requireAccount(actor.organizationId, input.fromAccountId, actor),
this.requireAccount(actor.organizationId, input.toAccountId, actor),
]);
if (!source.isActive || !target.isActive) {
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
@@ -354,10 +430,12 @@ export class TreasuryService {
kind: 'income' | 'expense',
q?: string,
): Promise<FinancialDocumentDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
...(q?.trim()
? {
OR: [
@@ -408,7 +486,7 @@ export class TreasuryService {
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
}
if (input.accountId) {
const account = await this.requireAccount(actor.organizationId, input.accountId);
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (input.kind === 'expense' && settle.greaterThan(0)) {
const balance = await this.accountBalance(account.id);
@@ -482,7 +560,7 @@ export class TreasuryService {
if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
}
const account = await this.requireAccount(actor.organizationId, input.accountId);
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (doc.kind === 'expense') {
const balance = await this.accountBalance(account.id);
@@ -644,9 +722,14 @@ export class TreasuryService {
): Promise<TreasuryDashboardDto> {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const scope = await this.accountScope(actor);
const accountFilter = scope
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
: {};
const bookedWhere = {
organizationId: actor.organizationId,
status: { not: 'void' },
...accountFilter,
...(from || to
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}),
@@ -654,6 +737,7 @@ export class TreasuryService {
const cashWhere = {
organizationId: actor.organizationId,
voidedAt: null,
...(scope ? { accountId: { in: scope } } : {}),
...(from || to
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}),
@@ -680,12 +764,13 @@ export class TreasuryService {
where: {
organizationId: actor.organizationId,
status: { in: ['open', 'partial'] },
...accountFilter,
},
include: { movements: true },
}),
this.listAccounts(actor),
this.prisma.financialDocument.findMany({
where: { organizationId: actor.organizationId },
where: { organizationId: actor.organizationId, ...accountFilter },
include: { category: true, account: true, movements: true },
orderBy: { createdAt: 'desc' },
take: 8,
@@ -754,11 +839,50 @@ export class TreasuryService {
);
}
private async requireAccount(organizationId: string, id: string) {
private async accountScope(actor: Actor): Promise<string[] | null> {
if (actor.role === 'owner' || actor.role === 'admin') return null;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: {
user: { select: { fullName: true } },
orgRole: { select: { treasuryAccountIds: true, treasuryOwnAccounts: true } },
treasuryAccessOverride: true,
},
});
const override = parseUserTreasuryAccess(membership?.treasuryAccessOverride);
if (override && !override.enabled) return [];
const extra = override
? override.accountIds
: (membership?.orgRole?.treasuryAccountIds ?? []);
const own = override
? override.ownAccounts
: Boolean(membership?.orgRole?.treasuryOwnAccounts);
if (!own && extra.length === 0) return null;
const ids = new Set(extra);
const name = membership?.user.fullName?.trim();
if (own && name) {
const rows = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId, responsibleName: { not: null } },
select: { id: true, responsibleName: true },
});
for (const row of rows) {
if (row.responsibleName?.trim() === name) ids.add(row.id);
}
}
return [...ids];
}
private async requireAccount(organizationId: string, id: string, actor?: Actor) {
const row = await this.prisma.financialAccount.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('حساب پیدا نشد.');
if (actor) {
const scope = await this.accountScope(actor);
if (scope && !scope.includes(row.id)) {
throw new ForbiddenException('به این حساب دسترسی ندارید.');
}
}
return row;
}