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
+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],