Add treasury document photos, table list, and manage permission.

Income/expense lists are table-or-card with detail, edit, void, and delete; edit/delete require owner, admin, or the new finance manage permission.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-15 12:42:01 +03:30
co-authored by Cursor
parent 3696713443
commit 4409ded0ab
10 changed files with 1383 additions and 451 deletions
@@ -181,6 +181,33 @@ export class TreasuryController {
});
}
@Get('documents/:id')
getDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.getDocument(user, id);
}
@Patch('documents/:id')
updateDocument(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body()
body: {
title?: string;
notes?: string | null;
categoryId?: string | null;
method?: string | null;
bookedAt?: string;
amount?: string | number;
},
) {
return this.treasury.updateDocument(user, id, body);
}
@Delete('documents/:id')
deleteDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteDocument(user, id);
}
@Get('attachments/:id')
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
const file = await this.treasury.openAttachment(user, id);
+123 -3
View File
@@ -4,7 +4,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import {
canManageTreasuryDocuments,
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
@@ -506,13 +510,87 @@ export class TreasuryService {
}
: {}),
},
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
include: {
category: true,
account: true,
movements: true,
attachments: { select: { id: true, fileName: true, mimeType: true } },
},
orderBy: { bookedAt: 'desc' },
take: 200,
});
return rows.map((row) => this.toDocumentDto(row));
}
async getDocument(actor: Actor, id: string): Promise<FinancialDocumentDto> {
const doc = await this.requireScopedDocument(actor, id);
return this.toDocumentDto(doc);
}
async updateDocument(
actor: Actor,
id: string,
input: {
title?: string;
notes?: string | null;
categoryId?: string | null;
method?: string | null;
bookedAt?: string;
amount?: string | number;
},
): Promise<FinancialDocumentDto> {
await this.assertDocumentsManage(actor);
const doc = await this.requireScopedDocument(actor, id);
if (doc.status === 'void' || doc.voidedAt) {
throw new BadRequestException('سند باطل‌شده قابل ویرایش نیست.');
}
const data: Prisma.FinancialDocumentUncheckedUpdateInput = {};
if (input.title !== undefined) {
const title = input.title.trim();
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
data.title = title;
}
if (input.notes !== undefined) data.notes = input.notes?.trim() || null;
if (input.categoryId !== undefined) data.categoryId = input.categoryId || null;
if (input.method !== undefined) data.method = input.method || null;
if (input.bookedAt !== undefined) {
const bookedAt = startOfDay(input.bookedAt);
if (!bookedAt) throw new BadRequestException('تاریخ نامعتبر است.');
data.bookedAt = bookedAt;
}
if (input.amount !== undefined) {
const active = doc.movements.filter((row) => !row.voidedAt);
if (active.length > 0 || doc.status !== 'open') {
throw new BadRequestException('مبلغ فقط برای سند باز بدون دریافت/پرداخت قابل تغییر است.');
}
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
data.amount = amount;
}
await this.prisma.financialDocument.update({ where: { id: doc.id }, data });
return this.getDocument(actor, id);
}
async deleteDocument(actor: Actor, id: string): Promise<{ ok: true }> {
await this.assertDocumentsManage(actor);
const doc = await this.requireScopedDocument(actor, id);
const active = doc.movements.filter((row) => !row.voidedAt);
if (doc.status !== 'void' && active.length > 0) {
throw new BadRequestException('ابتدا سند را ابطال کنید، سپس حذف کنید.');
}
const attachments = await this.prisma.financialAttachment.findMany({
where: { documentId: doc.id, organizationId: actor.organizationId },
select: { id: true, storageKey: true },
});
for (const file of attachments) {
const bytes = await this.files.sizeOf(file.storageKey);
await this.files.remove(file.storageKey);
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
}
await this.prisma.financialDocument.delete({ where: { id: doc.id } });
return { ok: true };
}
async createDocument(
actor: Actor,
input: {
@@ -1443,6 +1521,47 @@ export class TreasuryService {
);
}
private async actorPermissions(actor: Actor): Promise<string[]> {
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: {
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
});
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
return permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
}
private async assertDocumentsManage(actor: Actor) {
if (canManageTreasuryDocuments(actor.role, await this.actorPermissions(actor))) return;
throw new ForbiddenException('اجازه ویرایش یا حذف اسناد را ندارید.');
}
private async requireScopedDocument(actor: Actor, id: string) {
const scope = await this.accountScope(actor);
const doc = await this.prisma.financialDocument.findFirst({
where: {
id,
organizationId: actor.organizationId,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: {
category: true,
account: true,
movements: true,
attachments: { select: { id: true, fileName: true, mimeType: true } },
},
});
if (!doc) throw new NotFoundException('سند پیدا نشد.');
return doc;
}
private async accountScope(actor: Actor): Promise<string[] | null> {
if (actor.role === 'owner' || actor.role === 'admin') return null;
const membership = await this.prisma.membership.findFirst({
@@ -1566,7 +1685,8 @@ export class TreasuryService {
category?: { name: string } | null;
account?: { name: string } | null;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
attachments?: Array<{ id: string; fileName: string }>;
attachments?: Array<{ id: string; fileName: string; mimeType?: string }>;
voidedAt?: Date | null;
}): FinancialDocumentDto {
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);