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,37 @@
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
CREATE TABLE "FinancialAuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"actorUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
CREATE TABLE "FinancialAttachment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+41
View File
@@ -211,6 +211,8 @@ model Organization {
financialPayrolls FinancialPayroll[]
financialRecurringItems FinancialRecurringItem[]
financialReconciliations FinancialReconciliation[]
financialAuditLogs FinancialAuditLog[]
financialAttachments FinancialAttachment[]
wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[]
voipConnections VoipConnection[]
@@ -1181,6 +1183,8 @@ model Payment {
method PaymentMethod @default(transfer)
paidAt DateTime @default(now())
notes String?
/// اگر پر باشد، این پرداخت یک‌بار در صندوق نشسته است
financialAccountId String?
installmentId String? @unique
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
cheque Cheque?
@@ -1840,6 +1844,8 @@ model Cheque {
/// in = دریافتی، out = پرداختی
direction String @default("in")
reminderNotifiedAt DateTime?
/// حساب صندوق برای لحظهٔ وصول؛ تا قبل از وصول موجودی را عوض نمی‌کند
financialAccountId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2071,6 +2077,7 @@ model FinancialDocument {
voidedAt DateTime?
voidReason String?
movements FinancialMovement[]
attachments FinancialAttachment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2094,6 +2101,8 @@ model FinancialMovement {
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
payrollId String?
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
paymentId String?
chequeId String?
/// in | out
direction String
amount Decimal @db.Decimal(18, 2)
@@ -2113,6 +2122,38 @@ model FinancialMovement {
@@index([receivableId])
@@index([installmentId])
@@index([payrollId])
@@index([paymentId])
@@index([chequeId])
}
model FinancialAuditLog {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
entityType String
entityId String
action String
before Json?
after Json?
actorUserId String?
createdAt DateTime @default(now())
@@index([organizationId, entityType, entityId, createdAt])
}
model FinancialAttachment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
documentId String
document FinancialDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
fileName String
mimeType String
storageKey String
createdById String?
createdAt DateTime @default(now())
@@index([documentId])
}
model FinancialPayroll {
+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 },