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
+6
View File
@@ -763,6 +763,7 @@ export class AuthService {
organization: {
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
},
orgRole: { select: { permissions: true } },
},
});
const role = membership?.role ?? user.role;
@@ -790,6 +791,9 @@ export class AuthService {
subscription: sub,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}),
});
}
@@ -897,6 +901,7 @@ export class AuthService {
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
permissions?: AuthUserDto['permissions'];
}): AuthUserDto {
return {
id: user.id,
@@ -912,6 +917,7 @@ export class AuthService {
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
...(user.permissions ? { permissions: user.permissions } : {}),
};
}
}
+57 -2
View File
@@ -12,12 +12,18 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope';
import { buildSearchWhere, ci } from '../common/search';
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
import { BillingService } from '../billing/billing.service';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@Injectable()
export class ChequesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly treasury: TreasuryBridgeService,
private readonly billing: BillingService,
) {}
async list(
organizationId: string,
@@ -92,9 +98,13 @@ export class ChequesService {
? dto.status
: 'pending',
notes: dto.notes?.trim() ? dto.notes.trim() : null,
financialAccountId: dto.financialAccountId || null,
},
include: this.include,
});
if (row.status === 'cleared' && row.financialAccountId && await this.treasuryEnabled(organizationId)) {
await this.postCleared(row, actor.userId);
}
return this.toDto(row);
}
@@ -106,7 +116,6 @@ export class ChequesService {
): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, ...this.accessWhere(actor) },
select: { id: true },
});
if (!existing) throw new NotFoundException('چک پیدا نشد');
@@ -123,15 +132,61 @@ export class ChequesService {
if ('notes' in dto) {
data.notes = dto.notes?.trim() ? dto.notes.trim() : null;
}
if (dto.financialAccountId) data.financialAccountId = dto.financialAccountId;
const nextStatus = dto.status ?? existing.status;
const nextAccount = dto.financialAccountId || existing.financialAccountId;
const treasuryOn = await this.treasuryEnabled(organizationId);
if (treasuryOn && existing.status !== 'cleared' && nextStatus === 'cleared' && !nextAccount) {
throw new BadRequestException('برای وصول چک، حساب صندوق را انتخاب کنید.');
}
const row = await this.prisma.cheque.update({
where: { id },
data,
include: this.include,
});
if (
existing.status !== 'cleared' &&
row.status === 'cleared' &&
row.financialAccountId &&
await this.treasuryEnabled(organizationId)
) {
await this.postCleared(row, actor.userId);
}
if (existing.status === 'cleared' && row.status !== 'cleared') {
await this.treasury.voidCheque(row.id);
if (row.paymentId) await this.treasury.voidPayment(row.paymentId);
}
return this.toDto(row);
}
private async treasuryEnabled(organizationId: string) {
try {
await this.billing.requireModule(organizationId, 'treasury');
return true;
} catch {
return false;
}
}
private async postCleared(
row: { id: string; organizationId: string; financialAccountId: string | null; amount: Prisma.Decimal; direction: string; number: string },
actorUserId: string,
) {
if (!row.financialAccountId) return;
await this.treasury.postCash({
organizationId: row.organizationId,
accountId: row.financialAccountId,
amount: row.amount,
direction: row.direction === 'out' ? 'out' : 'in',
kind: row.direction === 'out' ? 'payment' : 'receipt',
occurredAt: new Date(),
memo: `وصول چک ${row.number}`,
actorUserId,
chequeId: row.id,
});
}
private accessWhere(
actor: ScopedActor,
): Prisma.ChequeWhereInput {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service';
@@ -8,7 +9,7 @@ import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller';
@Module({
imports: [AuthModule, NotificationsModule],
imports: [AuthModule, NotificationsModule, TreasuryModule],
controllers: [FinanceController],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
exports: [ChequesService, CommissionsService],
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { FinanceModule } from '../finance/finance.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule],
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule, TreasuryModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
+29
View File
@@ -22,6 +22,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service';
import { GamificationService } from '../gamification/gamification.service';
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -54,6 +55,7 @@ export class InvoicesService {
private readonly inventory: InventoryService,
private readonly commissions: CommissionsService,
private readonly game: GamificationService,
private readonly treasury: TreasuryBridgeService,
) {}
async list(
@@ -509,9 +511,16 @@ export class InvoicesService {
? dto.cheque.status
: 'pending',
notes: emptyToNull(dto.cheque.notes ?? null),
financialAccountId: dto.financialAccountId || null,
},
});
}
if (dto.financialAccountId) {
await tx.payment.update({
where: { id: payment.id },
data: { financialAccountId: dto.financialAccountId },
});
}
if (installmentId) {
await tx.invoiceInstallment.update({
where: { id: installmentId },
@@ -530,6 +539,25 @@ export class InvoicesService {
return payment.id;
});
const accountId = dto.financialAccountId || null;
const chequePending = method === 'check' && dto.cheque?.status !== 'cleared';
if (accountId && !chequePending) {
const linkedCheque = method === 'check'
? await this.prisma.cheque.findFirst({ where: { paymentId }, select: { id: true } })
: null;
await this.treasury.postCash({
organizationId,
accountId,
amount: dto.amount,
direction: 'in',
kind: 'receipt',
occurredAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
memo: 'پرداخت فاکتور',
actorUserId: actor.userId,
paymentId,
chequeId: linkedCheque?.id ?? null,
});
}
await this.recomputeStatus(invoiceId);
const row = await this.get(organizationId, invoiceId, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
@@ -574,6 +602,7 @@ export class InvoicesService {
});
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
await this.treasury.voidPayment(paymentId);
await this.prisma.$transaction(async (tx) => {
if (payment.installmentId) {
await tx.invoiceInstallment.update({
@@ -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 },