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[] financialPayrolls FinancialPayroll[]
financialRecurringItems FinancialRecurringItem[] financialRecurringItems FinancialRecurringItem[]
financialReconciliations FinancialReconciliation[] financialReconciliations FinancialReconciliation[]
financialAuditLogs FinancialAuditLog[]
financialAttachments FinancialAttachment[]
wooCommerceConnections WooCommerceConnection[] wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[] montifyConnections MontifyConnection[]
voipConnections VoipConnection[] voipConnections VoipConnection[]
@@ -1181,6 +1183,8 @@ model Payment {
method PaymentMethod @default(transfer) method PaymentMethod @default(transfer)
paidAt DateTime @default(now()) paidAt DateTime @default(now())
notes String? notes String?
/// اگر پر باشد، این پرداخت یک‌بار در صندوق نشسته است
financialAccountId String?
installmentId String? @unique installmentId String? @unique
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull) installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
cheque Cheque? cheque Cheque?
@@ -1840,6 +1844,8 @@ model Cheque {
/// in = دریافتی، out = پرداختی /// in = دریافتی، out = پرداختی
direction String @default("in") direction String @default("in")
reminderNotifiedAt DateTime? reminderNotifiedAt DateTime?
/// حساب صندوق برای لحظهٔ وصول؛ تا قبل از وصول موجودی را عوض نمی‌کند
financialAccountId String?
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2071,6 +2077,7 @@ model FinancialDocument {
voidedAt DateTime? voidedAt DateTime?
voidReason String? voidReason String?
movements FinancialMovement[] movements FinancialMovement[]
attachments FinancialAttachment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2094,6 +2101,8 @@ model FinancialMovement {
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull) installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
payrollId String? payrollId String?
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull) payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
paymentId String?
chequeId String?
/// in | out /// in | out
direction String direction String
amount Decimal @db.Decimal(18, 2) amount Decimal @db.Decimal(18, 2)
@@ -2113,6 +2122,38 @@ model FinancialMovement {
@@index([receivableId]) @@index([receivableId])
@@index([installmentId]) @@index([installmentId])
@@index([payrollId]) @@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 { model FinancialPayroll {
+6
View File
@@ -763,6 +763,7 @@ export class AuthService {
organization: { organization: {
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true }, select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
}, },
orgRole: { select: { permissions: true } },
}, },
}); });
const role = membership?.role ?? user.role; const role = membership?.role ?? user.role;
@@ -790,6 +791,9 @@ export class AuthService {
subscription: sub, subscription: sub,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
@@ -897,6 +901,7 @@ export class AuthService {
isOrgOwner?: boolean; isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive']; priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs']; viewAs?: AuthUserDto['viewAs'];
permissions?: AuthUserDto['permissions'];
}): AuthUserDto { }): AuthUserDto {
return { return {
id: user.id, id: user.id,
@@ -912,6 +917,7 @@ export class AuthService {
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}), ...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}), ...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}), ...(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 { PrismaService } from '../prisma/prisma.service';
import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope'; import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope';
import { buildSearchWhere, ci } from '../common/search'; 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; const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@Injectable() @Injectable()
export class ChequesService { export class ChequesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly treasury: TreasuryBridgeService,
private readonly billing: BillingService,
) {}
async list( async list(
organizationId: string, organizationId: string,
@@ -92,9 +98,13 @@ export class ChequesService {
? dto.status ? dto.status
: 'pending', : 'pending',
notes: dto.notes?.trim() ? dto.notes.trim() : null, notes: dto.notes?.trim() ? dto.notes.trim() : null,
financialAccountId: dto.financialAccountId || null,
}, },
include: this.include, include: this.include,
}); });
if (row.status === 'cleared' && row.financialAccountId && await this.treasuryEnabled(organizationId)) {
await this.postCleared(row, actor.userId);
}
return this.toDto(row); return this.toDto(row);
} }
@@ -106,7 +116,6 @@ export class ChequesService {
): Promise<ChequeDto> { ): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({ const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, ...this.accessWhere(actor) }, where: { id, organizationId, ...this.accessWhere(actor) },
select: { id: true },
}); });
if (!existing) throw new NotFoundException('چک پیدا نشد'); if (!existing) throw new NotFoundException('چک پیدا نشد');
@@ -123,15 +132,61 @@ export class ChequesService {
if ('notes' in dto) { if ('notes' in dto) {
data.notes = dto.notes?.trim() ? dto.notes.trim() : null; 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({ const row = await this.prisma.cheque.update({
where: { id }, where: { id },
data, data,
include: this.include, 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); 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( private accessWhere(
actor: ScopedActor, actor: ScopedActor,
): Prisma.ChequeWhereInput { ): Prisma.ChequeWhereInput {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service'; import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service'; import { ChequesService } from './cheques.service';
@@ -8,7 +9,7 @@ import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller'; import { FinanceController } from './finance.controller';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule, TreasuryModule],
controllers: [FinanceController], controllers: [FinanceController],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService], providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
exports: [ChequesService, CommissionsService], exports: [ChequesService, CommissionsService],
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module'; import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { FinanceModule } from '../finance/finance.module'; import { FinanceModule } from '../finance/finance.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { InventoryModule } from '../inventory/inventory.module'; import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller'; import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service'; import { InvoicesService } from './invoices.service';
@Module({ @Module({
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule], imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule, TreasuryModule],
controllers: [InvoicesController], controllers: [InvoicesController],
providers: [InvoicesService], providers: [InvoicesService],
exports: [InvoicesService], exports: [InvoicesService],
+29
View File
@@ -22,6 +22,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { InventoryService } from '../inventory/inventory.service'; import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service'; import { CommissionsService } from '../finance/commissions.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>; type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -54,6 +55,7 @@ export class InvoicesService {
private readonly inventory: InventoryService, private readonly inventory: InventoryService,
private readonly commissions: CommissionsService, private readonly commissions: CommissionsService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly treasury: TreasuryBridgeService,
) {} ) {}
async list( async list(
@@ -509,9 +511,16 @@ export class InvoicesService {
? dto.cheque.status ? dto.cheque.status
: 'pending', : 'pending',
notes: emptyToNull(dto.cheque.notes ?? null), 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) { if (installmentId) {
await tx.invoiceInstallment.update({ await tx.invoiceInstallment.update({
where: { id: installmentId }, where: { id: installmentId },
@@ -530,6 +539,25 @@ export class InvoicesService {
return payment.id; 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); await this.recomputeStatus(invoiceId);
const row = await this.get(organizationId, invoiceId, actor); const row = await this.get(organizationId, invoiceId, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id); await this.inventory.syncInvoiceStock(organizationId, row.id);
@@ -574,6 +602,7 @@ export class InvoicesService {
}); });
if (!payment) throw new NotFoundException('پرداخت پیدا نشد'); if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
await this.treasury.voidPayment(paymentId);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (payment.installmentId) { if (payment.installmentId) {
await tx.invoiceInstallment.update({ 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 { 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 { 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 type { Actor } from '../common/record-scope';
import { TreasuryOpsService } from './treasury-ops.service'; import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@Controller('treasury') @Controller('treasury')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard, TreasuryAccessGuard)
@Roles('owner', 'admin') @RequireOrgModule('treasury')
export class TreasuryController { export class TreasuryController {
constructor( constructor(
private readonly treasury: TreasuryService, private readonly treasury: TreasuryService,
@@ -92,6 +95,16 @@ export class TreasuryController {
return this.treasury.createDocument(user, body); 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') @Post('documents/:id/settle')
settle( settle(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+10 -2
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
import { TreasuryBridgeService } from './treasury-bridge.service';
import { TreasuryOpsService } from './treasury-ops.service'; import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@@ -9,7 +11,13 @@ import { TreasuryService } from './treasury.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule],
controllers: [TreasuryController], controllers: [TreasuryController],
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService], providers: [
exports: [TreasuryService], TreasuryService,
TreasuryReceivablesService,
TreasuryOpsService,
TreasuryBridgeService,
TreasuryAccessGuard,
],
exports: [TreasuryService, TreasuryBridgeService],
}) })
export class TreasuryModule {} export class TreasuryModule {}
+34
View File
@@ -13,6 +13,7 @@ import type {
TreasuryDashboardDto, TreasuryDashboardDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
@@ -58,6 +59,7 @@ export class TreasuryService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly receivables: TreasuryReceivablesService, private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
) {} ) {}
async ensureCategories(organizationId: string) { async ensureCategories(organizationId: string) {
@@ -507,6 +509,16 @@ export class TreasuryService {
data: { status, accountId: account.id }, 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') { if (status === 'settled') {
await this.prisma.financialRecurringOccurrence.updateMany({ await this.prisma.financialRecurringOccurrence.updateMany({
where: { documentId: doc.id, status: 'pending' }, where: { documentId: doc.id, status: 'pending' },
@@ -543,6 +555,28 @@ export class TreasuryService {
return this.toDocumentDto(fresh); 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( async dashboard(
actor: Actor, actor: Actor,
range?: { from?: string; to?: string }, range?: { from?: string; to?: string },
@@ -1,18 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const links = [ const auth = useAuthStore();
const canPayroll = computed(() =>
auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')),
);
const links = computed(() => [
{ to: '/finance', label: 'داشبورد', exact: true }, { to: '/finance', label: 'داشبورد', exact: true },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' }, { to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' }, { to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات' }, { to: '/finance/receivables', label: 'اقساط و مطالبات' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها' }, { to: '/finance/accounts', label: 'صندوق و حساب‌ها' },
{ to: '/finance/payroll', label: 'حقوق' }, ...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
{ to: '/finance/recurring', label: 'تکرارشونده' }, { to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' }, { to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' }, { to: '/finance/categories', label: 'دسته‌بندی‌ها' },
]; ]);
function on(path: string, exact?: boolean) { function on(path: string, exact?: boolean) {
if (exact) return route.path === path; if (exact) return route.path === path;
+5
View File
@@ -157,6 +157,11 @@ function cardAllowedForNav(card: {
to?: string; to?: string;
}) { }) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false; if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| Boolean(auth.user?.permissions?.includes('treasury'))
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
if (card.to?.startsWith('/finance')) return canTreasury;
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false; if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
if (!moduleAllowed(card.module)) return false; if (!moduleAllowed(card.module)) return false;
if ( if (
+1 -1
View File
@@ -360,7 +360,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صندوق، درآمد و هزینه داخلی', desc: 'صندوق، درآمد و هزینه داخلی',
icon: 'dollar', icon: 'dollar',
tint: 'mint', tint: 'mint',
admin: true, module: 'treasury',
}, },
{ {
to: '/reservations', to: '/reservations',
+10
View File
@@ -557,6 +557,10 @@ router.beforeEach((to) => {
) { ) {
return { name: 'home' }; return { name: 'home' };
} }
if (String(to.path).startsWith('/finance')) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('treasury')) return { name: 'home' };
}
const roles = to.meta.roles as UserRole[] | undefined; const roles = to.meta.roles as UserRole[] | undefined;
if (roles?.length) { if (roles?.length) {
const role = auth.user?.role as UserRole | undefined; const role = auth.user?.role as UserRole | undefined;
@@ -566,6 +570,12 @@ router.beforeEach((to) => {
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) { if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
return true; return true;
} }
if (String(to.path).startsWith('/finance')) {
const perms = auth.user?.permissions ?? [];
const payroll = to.path.startsWith('/finance/payroll');
if (payroll && perms.includes('treasury_payroll')) return true;
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
}
return { name: 'home' }; return { name: 'home' };
} }
return true; return true;
+36
View File
@@ -97,10 +97,46 @@ async function load() {
async function setStatus(row: ChequeDto, next: ChequeStatus) { async function setStatus(row: ChequeDto, next: ChequeStatus) {
if (row.status === next) return; if (row.status === next) return;
let financialAccountId: string | undefined;
if (next === 'cleared' && row.status !== 'cleared') {
const accountsRes = await api
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
.catch(() => null);
if (!accountsRes) {
savingId.value = row.id;
try {
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
items.value = items.value.map((item) => (item.id === row.id ? data : item));
} catch {
error.value = 'تغییر وضعیت چک ناموفق بود.';
} finally {
savingId.value = '';
}
return;
}
const accounts = accountsRes.data;
const active = accounts.filter((item) => item.isActive);
if (!active.length) {
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
return;
}
if (active.length === 1) {
financialAccountId = active[0]!.id;
} else {
const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`);
const match = active.find((item) => item.name.trim() === picked?.trim());
if (!match) {
error.value = 'حساب صندوق انتخاب نشد.';
return;
}
financialAccountId = match.id;
}
}
savingId.value = row.id; savingId.value = row.id;
try { try {
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
status: next, status: next,
...(financialAccountId ? { financialAccountId } : {}),
}); });
items.value = items.value.map((item) => (item.id === row.id ? data : item)); items.value = items.value.map((item) => (item.id === row.id ? data : item));
} catch { } catch {
+12
View File
@@ -22,6 +22,7 @@ import type {
CreateQuoteRequest, CreateQuoteRequest,
DealDto, DealDto,
DealLineItemInput, DealLineItemInput,
FinancialAccountDto,
InvoiceDto, InvoiceDto,
InvoiceInstallmentInput, InvoiceInstallmentInput,
InvoiceStatus, InvoiceStatus,
@@ -123,6 +124,8 @@ const lines = ref<LineDraft[]>([]);
const installments = ref<InstallmentDraft[]>([]); const installments = ref<InstallmentDraft[]>([]);
const invoice = ref<InvoiceDto | null>(null); const invoice = ref<InvoiceDto | null>(null);
const payAccountId = ref('');
const financeAccounts = ref<FinancialAccountDto[]>([]);
const payAmount = ref(''); const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer'); const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref(''); const payNotes = ref('');
@@ -463,6 +466,8 @@ async function load() {
error.value = ''; error.value = '';
mobileStep.value = 1; mobileStep.value = 1;
try { try {
const accounts = await api.get<FinancialAccountDto[]>('/treasury/accounts').catch(() => null);
financeAccounts.value = accounts?.data.filter((row) => row.isActive) ?? [];
title.value = defaultTitle.value; title.value = defaultTitle.value;
status.value = 'draft'; status.value = 'draft';
notes.value = ''; notes.value = '';
@@ -740,6 +745,7 @@ async function submitPayment() {
notes: payNotes.value || undefined, notes: payNotes.value || undefined,
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined, paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
installmentId: payInstallmentId.value || null, installmentId: payInstallmentId.value || null,
financialAccountId: payAccountId.value || null,
cheque: cheque:
payMethod.value === 'check' payMethod.value === 'check'
? { ? {
@@ -1316,6 +1322,12 @@ onMounted(load);
</option> </option>
</select> </select>
<input v-model="payNotes" placeholder="یادداشت پرداخت" /> <input v-model="payNotes" placeholder="یادداشت پرداخت" />
<select v-if="financeAccounts.length" v-model="payAccountId">
<option value="">بدون اثر روی صندوق</option>
<option v-for="account in financeAccounts" :key="account.id" :value="account.id">
واریز به {{ account.name }}
</option>
</select>
<div v-if="payMethod === 'check'" class="cheque-fields"> <div v-if="payMethod === 'check'" class="cheque-fields">
<input v-model="chequeNumber" placeholder="شماره چک" /> <input v-model="chequeNumber" placeholder="شماره چک" />
<input v-model="chequeBank" placeholder="بانک" /> <input v-model="chequeBank" placeholder="بانک" />
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared'; import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
@@ -70,7 +71,7 @@ onMounted(async () => {
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article> <article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div> </div>
<div v-if="account" class="reconcile"> <div v-if="account" class="reconcile">
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField> <UiField label="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></UiField>
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField> <UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton> <UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton> <UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared'; import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
@@ -171,7 +172,7 @@ onMounted(() => {
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField> <UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField> <UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField> <UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField> <UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField> <UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField> <UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
</div> </div>
@@ -192,7 +193,7 @@ onMounted(() => {
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option> <option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select> </select>
</UiField> </UiField>
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField> <UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField> <UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
</div> </div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p> <p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
@@ -90,6 +91,19 @@ async function createDoc() {
} }
} }
async function upload(id: string, event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const body = new FormData();
body.append('file', file);
try {
await api.post(`/treasury/documents/${id}/attachments`, body);
ok.value = 'پیوست ذخیره شد.';
} catch (e) {
error.value = apiMessage(e);
}
}
async function voidDoc(id: string) { async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try { try {
@@ -120,7 +134,7 @@ onMounted(() => {
<UiCard title="سند جدید"> <UiCard title="سند جدید">
<div class="grid"> <div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField> <UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.amount" /></UiField> <UiField label="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دسته‌بندی"> <UiField label="دسته‌بندی">
<select v-model="form.categoryId" class="select"> <select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option> <option value="">بدون دسته</option>
@@ -128,7 +142,7 @@ onMounted(() => {
</select> </select>
</UiField> </UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField> <UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></UiField> <UiField :label="settleLabel"><MoneyInput v-model="form.settleAmount" :decimals="0" /></UiField>
<UiField label="حساب"> <UiField label="حساب">
<select v-model="form.accountId" class="select"> <select v-model="form.accountId" class="select">
<option value="">انتخاب حساب</option> <option value="">انتخاب حساب</option>
@@ -162,6 +176,10 @@ onMounted(() => {
<div class="side"> <div class="side">
<b>{{ toman(row.amount) }}</b> <b>{{ toman(row.amount) }}</b>
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button> <button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
<label v-if="row.status !== 'void'" class="file">
پیوست
<input type="file" @change="upload(row.id, $event)" />
</label>
</div> </div>
</article> </article>
<p v-if="!rows.length" class="muted">سندی نیست.</p> <p v-if="!rows.length" class="muted">سندی نیست.</p>
@@ -177,7 +195,9 @@ onMounted(() => {
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; } .list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.list small, .muted { color: #64748b; } .list small, .muted { color: #64748b; }
.side { display: grid; justify-items: end; gap: 0.3rem; } .side { display: grid; justify-items: end; gap: 0.3rem; }
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; } .side button, .file { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
.file { color: #0f766e; font-weight: 700; }
.file input { display: none; }
.err { color: #b91c1c; font-weight: 700; } .err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; } .ok { color: #0f766e; font-weight: 700; }
</style> </style>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared'; import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
@@ -90,10 +91,10 @@ onMounted(() => {
</select> </select>
</UiField> </UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField> <UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField> <UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField> <UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField> <UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField> <UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
</div> </div>
<UiButton @click="createRow">ثبت حقوق</UiButton> <UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard> </UiCard>
@@ -109,7 +110,7 @@ onMounted(() => {
</article> </article>
<UiCard v-if="pay.id" title="پرداخت حقوق"> <UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid"> <div class="grid">
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField> <UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
<UiField label="حساب"> <UiField label="حساب">
<select v-model="pay.accountId" class="select"> <select v-model="pay.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option> <option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared'; import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
@@ -121,8 +122,8 @@ onMounted(() => {
<div class="grid"> <div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField> <UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField> <UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField> <UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
<UiField label="پیش‌پرداخت"><UiInput v-model="form.prepaidAmount" /></UiField> <UiField label="پیش‌پرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
<UiField label="حساب پیش‌پرداخت"> <UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select"> <select v-model="form.accountId" class="select">
<option value="">انتخاب</option> <option value="">انتخاب</option>
@@ -141,7 +142,7 @@ onMounted(() => {
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label> <label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom"> <div v-if="custom" class="custom">
<div v-for="(line, index) in customLines" :key="index" class="grid"> <div v-for="(line, index) in customLines" :key="index" class="grid">
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField> <UiField label="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField> <UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
</div> </div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton> <UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
@@ -191,7 +192,7 @@ onMounted(() => {
<UiCard v-if="collect.installmentId" title="دریافت قسط"> <UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid"> <div class="grid">
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField> <UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
<UiField label="حساب مقصد"> <UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select"> <select v-model="collect.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option> <option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
@@ -51,7 +52,7 @@ onMounted(() => {
<option value="income">درآمد</option> <option value="income">درآمد</option>
</select> </select>
</UiField> </UiField>
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField> <UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دوره"> <UiField label="دوره">
<select v-model="form.interval" class="select"> <select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option> <option value="monthly">ماهانه</option>
+16 -2
View File
@@ -54,6 +54,8 @@ export interface AuthUserDto {
/** صفحهٔ تیم برای مالک واقعی باز بماند */ /** صفحهٔ تیم برای مالک واقعی باز بماند */
canOpenTeamSettings: boolean; canOpenTeamSettings: boolean;
} | null; } | null;
/** مجوزهای نقش سازمانی؛ مالک و مدیر همهٔ بخش‌ها را دارند */
permissions?: RolePermissionKey[];
} }
export type BillingPlanCode = export type BillingPlanCode =
@@ -154,7 +156,8 @@ export type OrgModuleKey =
| "conversations" | "conversations"
| "routines" | "routines"
| "gamification" | "gamification"
| "prior_data_archive"; | "prior_data_archive"
| "treasury";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers", "customers",
@@ -196,6 +199,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"routines", "routines",
"gamification", "gamification",
"prior_data_archive", "prior_data_archive",
"treasury",
]; ];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = { export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -238,6 +242,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
routines: "کارهای روتین", routines: "کارهای روتین",
gamification: "باشگاه امتیازات", gamification: "باشگاه امتیازات",
prior_data_archive: "بایگانی اطلاعات قبل", prior_data_archive: "بایگانی اطلاعات قبل",
treasury: "مدیریت مالی",
}; };
export const ORG_MODULE_GROUPS: Array<{ export const ORG_MODULE_GROUPS: Array<{
@@ -273,6 +278,7 @@ export const ORG_MODULE_GROUPS: Array<{
"project_boards", "project_boards",
"inventory", "inventory",
"cheques", "cheques",
"treasury",
"attachments", "attachments",
"loyalty", "loyalty",
"reports", "reports",
@@ -434,7 +440,9 @@ export type RolePermissionKey =
| "reports" | "reports"
| "audit" | "audit"
| "conversations" | "conversations"
| "conversations_groups"; | "conversations_groups"
| "treasury"
| "treasury_payroll";
export const ROLE_PERMISSION_ITEMS: Array<{ export const ROLE_PERMISSION_ITEMS: Array<{
key: RolePermissionKey; key: RolePermissionKey;
@@ -450,6 +458,8 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "audit", label: "لاگ فعالیت کاربران" }, { key: "audit", label: "لاگ فعالیت کاربران" },
{ key: "conversations", label: "گفتگو" }, { key: "conversations", label: "گفتگو" },
{ key: "conversations_groups", label: "ساخت گروه گفتگو" }, { key: "conversations_groups", label: "ساخت گروه گفتگو" },
{ key: "treasury", label: "مدیریت مالی" },
{ key: "treasury_payroll", label: "حقوق و دستمزد" },
]; ];
export interface OrgRoleMemberDto { export interface OrgRoleMemberDto {
@@ -2358,6 +2368,7 @@ export interface CreateChequeRequest {
status?: ChequeStatus | string; status?: ChequeStatus | string;
direction?: ChequeDirection | string; direction?: ChequeDirection | string;
notes?: string; notes?: string;
financialAccountId?: string | null;
} }
export interface UpdateChequeRequest { export interface UpdateChequeRequest {
@@ -2366,6 +2377,7 @@ export interface UpdateChequeRequest {
dueDate?: string; dueDate?: string;
status?: ChequeStatus | string; status?: ChequeStatus | string;
notes?: string | null; notes?: string | null;
financialAccountId?: string | null;
} }
export interface PaymentDto { export interface PaymentDto {
@@ -2497,6 +2509,8 @@ export interface CreatePaymentRequest {
paidAt?: string; paidAt?: string;
notes?: string; notes?: string;
installmentId?: string | null; installmentId?: string | null;
/** اگر پر باشد، پرداخت غیرچکی یا چک وصول‌شده در این حساب صندوق می‌نشیند */
financialAccountId?: string | null;
cheque?: ChequeInput; cheque?: ChequeInput;
} }