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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user