diff --git a/apps/api/prisma/migrations/20261018220000_treasury_links/migration.sql b/apps/api/prisma/migrations/20261018220000_treasury_links/migration.sql new file mode 100644 index 0000000..57bf38a --- /dev/null +++ b/apps/api/prisma/migrations/20261018220000_treasury_links/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 69c1911..b2bdf5e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 51a1009..586b780 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 } : {}), }; } } diff --git a/apps/api/src/finance/cheques.service.ts b/apps/api/src/finance/cheques.service.ts index 3cc4121..73e57a3 100644 --- a/apps/api/src/finance/cheques.service.ts +++ b/apps/api/src/finance/cheques.service.ts @@ -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 { 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 { diff --git a/apps/api/src/finance/finance.module.ts b/apps/api/src/finance/finance.module.ts index c9c81f9..0211522 100644 --- a/apps/api/src/finance/finance.module.ts +++ b/apps/api/src/finance/finance.module.ts @@ -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], diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts index 64695e8..ea0cd92 100644 --- a/apps/api/src/invoices/invoices.module.ts +++ b/apps/api/src/invoices/invoices.module.ts @@ -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], diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 4d9570a..c6bffcd 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -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; @@ -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({ diff --git a/apps/api/src/treasury/treasury-access.guard.ts b/apps/api/src/treasury/treasury-access.guard.ts new file mode 100644 index 0000000..63d3270 --- /dev/null +++ b/apps/api/src/treasury/treasury-access.guard.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/treasury/treasury-bridge.service.ts b/apps/api/src/treasury/treasury-bridge.service.ts new file mode 100644 index 0000000..68d2b3d --- /dev/null +++ b/apps/api/src/treasury/treasury-bridge.service.ts @@ -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), + ); + } +} diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 4c408b6..8ca599b 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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, diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index 2c7ce70..c48cca6 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -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 {} diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 4fe0ac0..c2ba22d 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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 }, diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index c7e46e6..65a61a8 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -1,18 +1,26 @@