feat: gate treasury as an org module and group money inputs

Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:33:09 +03:30
co-authored by Cursor
parent ab538b8304
commit 8981a552fc
25 changed files with 509 additions and 33 deletions
@@ -0,0 +1,37 @@
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
CREATE TABLE "FinancialAuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"actorUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
CREATE TABLE "FinancialAttachment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+41
View File
@@ -211,6 +211,8 @@ model Organization {
financialPayrolls FinancialPayroll[]
financialRecurringItems FinancialRecurringItem[]
financialReconciliations FinancialReconciliation[]
financialAuditLogs FinancialAuditLog[]
financialAttachments FinancialAttachment[]
wooCommerceConnections WooCommerceConnection[]
montifyConnections MontifyConnection[]
voipConnections VoipConnection[]
@@ -1181,6 +1183,8 @@ model Payment {
method PaymentMethod @default(transfer)
paidAt DateTime @default(now())
notes String?
/// اگر پر باشد، این پرداخت یک‌بار در صندوق نشسته است
financialAccountId String?
installmentId String? @unique
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
cheque Cheque?
@@ -1840,6 +1844,8 @@ model Cheque {
/// in = دریافتی، out = پرداختی
direction String @default("in")
reminderNotifiedAt DateTime?
/// حساب صندوق برای لحظهٔ وصول؛ تا قبل از وصول موجودی را عوض نمی‌کند
financialAccountId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2071,6 +2077,7 @@ model FinancialDocument {
voidedAt DateTime?
voidReason String?
movements FinancialMovement[]
attachments FinancialAttachment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2094,6 +2101,8 @@ model FinancialMovement {
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
payrollId String?
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
paymentId String?
chequeId String?
/// in | out
direction String
amount Decimal @db.Decimal(18, 2)
@@ -2113,6 +2122,38 @@ model FinancialMovement {
@@index([receivableId])
@@index([installmentId])
@@index([payrollId])
@@index([paymentId])
@@index([chequeId])
}
model FinancialAuditLog {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
entityType String
entityId String
action String
before Json?
after Json?
actorUserId String?
createdAt DateTime @default(now())
@@index([organizationId, entityType, entityId, createdAt])
}
model FinancialAttachment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
documentId String
document FinancialDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
fileName String
mimeType String
storageKey String
createdById String?
createdAt DateTime @default(now())
@@index([documentId])
}
model FinancialPayroll {
+6
View File
@@ -763,6 +763,7 @@ export class AuthService {
organization: {
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
},
orgRole: { select: { permissions: true } },
},
});
const role = membership?.role ?? user.role;
@@ -790,6 +791,9 @@ export class AuthService {
subscription: sub,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}),
});
}
@@ -897,6 +901,7 @@ export class AuthService {
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
permissions?: AuthUserDto['permissions'];
}): AuthUserDto {
return {
id: user.id,
@@ -912,6 +917,7 @@ export class AuthService {
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
...(user.permissions ? { permissions: user.permissions } : {}),
};
}
}
+57 -2
View File
@@ -12,12 +12,18 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope';
import { buildSearchWhere, ci } from '../common/search';
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
import { BillingService } from '../billing/billing.service';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@Injectable()
export class ChequesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly treasury: TreasuryBridgeService,
private readonly billing: BillingService,
) {}
async list(
organizationId: string,
@@ -92,9 +98,13 @@ export class ChequesService {
? dto.status
: 'pending',
notes: dto.notes?.trim() ? dto.notes.trim() : null,
financialAccountId: dto.financialAccountId || null,
},
include: this.include,
});
if (row.status === 'cleared' && row.financialAccountId && await this.treasuryEnabled(organizationId)) {
await this.postCleared(row, actor.userId);
}
return this.toDto(row);
}
@@ -106,7 +116,6 @@ export class ChequesService {
): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, ...this.accessWhere(actor) },
select: { id: true },
});
if (!existing) throw new NotFoundException('چک پیدا نشد');
@@ -123,15 +132,61 @@ export class ChequesService {
if ('notes' in dto) {
data.notes = dto.notes?.trim() ? dto.notes.trim() : null;
}
if (dto.financialAccountId) data.financialAccountId = dto.financialAccountId;
const nextStatus = dto.status ?? existing.status;
const nextAccount = dto.financialAccountId || existing.financialAccountId;
const treasuryOn = await this.treasuryEnabled(organizationId);
if (treasuryOn && existing.status !== 'cleared' && nextStatus === 'cleared' && !nextAccount) {
throw new BadRequestException('برای وصول چک، حساب صندوق را انتخاب کنید.');
}
const row = await this.prisma.cheque.update({
where: { id },
data,
include: this.include,
});
if (
existing.status !== 'cleared' &&
row.status === 'cleared' &&
row.financialAccountId &&
await this.treasuryEnabled(organizationId)
) {
await this.postCleared(row, actor.userId);
}
if (existing.status === 'cleared' && row.status !== 'cleared') {
await this.treasury.voidCheque(row.id);
if (row.paymentId) await this.treasury.voidPayment(row.paymentId);
}
return this.toDto(row);
}
private async treasuryEnabled(organizationId: string) {
try {
await this.billing.requireModule(organizationId, 'treasury');
return true;
} catch {
return false;
}
}
private async postCleared(
row: { id: string; organizationId: string; financialAccountId: string | null; amount: Prisma.Decimal; direction: string; number: string },
actorUserId: string,
) {
if (!row.financialAccountId) return;
await this.treasury.postCash({
organizationId: row.organizationId,
accountId: row.financialAccountId,
amount: row.amount,
direction: row.direction === 'out' ? 'out' : 'in',
kind: row.direction === 'out' ? 'payment' : 'receipt',
occurredAt: new Date(),
memo: `وصول چک ${row.number}`,
actorUserId,
chequeId: row.id,
});
}
private accessWhere(
actor: ScopedActor,
): Prisma.ChequeWhereInput {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { ChequesService } from './cheques.service';
@@ -8,7 +9,7 @@ import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller';
@Module({
imports: [AuthModule, NotificationsModule],
imports: [AuthModule, NotificationsModule, TreasuryModule],
controllers: [FinanceController],
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
exports: [ChequesService, CommissionsService],
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { FinanceModule } from '../finance/finance.module';
import { TreasuryModule } from '../treasury/treasury.module';
import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule],
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule, TreasuryModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
+29
View File
@@ -22,6 +22,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service';
import { GamificationService } from '../gamification/gamification.service';
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -54,6 +55,7 @@ export class InvoicesService {
private readonly inventory: InventoryService,
private readonly commissions: CommissionsService,
private readonly game: GamificationService,
private readonly treasury: TreasuryBridgeService,
) {}
async list(
@@ -509,9 +511,16 @@ export class InvoicesService {
? dto.cheque.status
: 'pending',
notes: emptyToNull(dto.cheque.notes ?? null),
financialAccountId: dto.financialAccountId || null,
},
});
}
if (dto.financialAccountId) {
await tx.payment.update({
where: { id: payment.id },
data: { financialAccountId: dto.financialAccountId },
});
}
if (installmentId) {
await tx.invoiceInstallment.update({
where: { id: installmentId },
@@ -530,6 +539,25 @@ export class InvoicesService {
return payment.id;
});
const accountId = dto.financialAccountId || null;
const chequePending = method === 'check' && dto.cheque?.status !== 'cleared';
if (accountId && !chequePending) {
const linkedCheque = method === 'check'
? await this.prisma.cheque.findFirst({ where: { paymentId }, select: { id: true } })
: null;
await this.treasury.postCash({
organizationId,
accountId,
amount: dto.amount,
direction: 'in',
kind: 'receipt',
occurredAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
memo: 'پرداخت فاکتور',
actorUserId: actor.userId,
paymentId,
chequeId: linkedCheque?.id ?? null,
});
}
await this.recomputeStatus(invoiceId);
const row = await this.get(organizationId, invoiceId, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
@@ -574,6 +602,7 @@ export class InvoicesService {
});
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
await this.treasury.voidPayment(paymentId);
await this.prisma.$transaction(async (tx) => {
if (payment.installmentId) {
await tx.invoiceInstallment.update({
@@ -0,0 +1,29 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
@Injectable()
export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
const url = req.originalUrl || req.url || '';
const payroll = url.includes('/treasury/payroll');
if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } },
});
const permissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const allowed = payroll
? permissions.includes('treasury_payroll')
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
return true;
}
}
@@ -0,0 +1,112 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TreasuryBridgeService {
constructor(private readonly prisma: PrismaService) {}
async postCash(input: {
organizationId: string;
accountId: string;
amount: Prisma.Decimal | number | string;
direction: 'in' | 'out';
kind: 'receipt' | 'payment';
occurredAt: Date;
memo: string;
actorUserId?: string | null;
paymentId?: string | null;
chequeId?: string | null;
}) {
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: input.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب صندوق پیدا نشد.');
const amount = new Prisma.Decimal(input.amount);
if (!amount.greaterThan(0)) return;
if (input.paymentId) {
const existing = await this.prisma.financialMovement.findFirst({
where: { paymentId: input.paymentId, voidedAt: null },
});
if (existing) return;
}
if (input.chequeId) {
const existing = await this.prisma.financialMovement.findFirst({
where: { chequeId: input.chequeId, voidedAt: null },
});
if (existing) return;
}
if (input.direction === 'out') {
const balance = await this.balance(account.id);
if (balance.lessThan(amount)) {
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
}
}
await this.prisma.financialMovement.create({
data: {
organizationId: input.organizationId,
accountId: account.id,
direction: input.direction,
amount,
kind: input.kind,
occurredAt: input.occurredAt,
memo: input.memo,
paymentId: input.paymentId ?? null,
chequeId: input.chequeId ?? null,
createdById: input.actorUserId ?? null,
},
});
await this.audit(input.organizationId, input.actorUserId, 'cash_post', input.paymentId || input.chequeId || account.id, null, {
accountId: account.id,
amount: amount.toFixed(2),
direction: input.direction,
});
}
async voidPayment(paymentId: string) {
await this.prisma.financialMovement.updateMany({
where: { paymentId, voidedAt: null },
data: { voidedAt: new Date() },
});
}
async voidCheque(chequeId: string) {
await this.prisma.financialMovement.updateMany({
where: { chequeId, voidedAt: null },
data: { voidedAt: new Date() },
});
}
async audit(
organizationId: string,
actorUserId: string | null | undefined,
action: string,
entityId: string,
before: unknown,
after: unknown,
entityType = 'treasury',
) {
await this.prisma.financialAuditLog.create({
data: {
organizationId,
entityType,
entityId,
action,
before: before == null ? undefined : (before as Prisma.InputJsonValue),
after: after == null ? undefined : (after as Prisma.InputJsonValue),
actorUserId: actorUserId ?? null,
},
});
}
private async balance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null },
select: { direction: true, amount: true },
});
return rows.reduce(
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
new Prisma.Decimal(0),
);
}
}
+18 -5
View File
@@ -1,16 +1,19 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { TreasuryAccessGuard } from './treasury-access.guard';
import type { Actor } from '../common/record-scope';
import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Controller('treasury')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('owner', 'admin')
@UseGuards(JwtAuthGuard, OrgModuleGuard, TreasuryAccessGuard)
@RequireOrgModule('treasury')
export class TreasuryController {
constructor(
private readonly treasury: TreasuryService,
@@ -92,6 +95,16 @@ export class TreasuryController {
return this.treasury.createDocument(user, body);
}
@Post('documents/:id/attachments')
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
addAttachment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer },
) {
return this.treasury.addAttachment(user, id, file);
}
@Post('documents/:id/settle')
settle(
@CurrentUser() user: Actor,
+10 -2
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
import { TreasuryBridgeService } from './treasury-bridge.service';
import { TreasuryOpsService } from './treasury-ops.service';
import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@@ -9,7 +11,13 @@ import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule],
controllers: [TreasuryController],
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService],
exports: [TreasuryService],
providers: [
TreasuryService,
TreasuryReceivablesService,
TreasuryOpsService,
TreasuryBridgeService,
TreasuryAccessGuard,
],
exports: [TreasuryService, TreasuryBridgeService],
})
export class TreasuryModule {}
+34
View File
@@ -13,6 +13,7 @@ import type {
TreasuryDashboardDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import type { Actor } from '../common/record-scope';
import { TreasuryReceivablesService } from './treasury-receivables.service';
@@ -58,6 +59,7 @@ export class TreasuryService {
constructor(
private readonly prisma: PrismaService,
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
) {}
async ensureCategories(organizationId: string) {
@@ -507,6 +509,16 @@ export class TreasuryService {
data: { status, accountId: account.id },
}),
]);
await this.prisma.financialAuditLog.create({
data: {
organizationId: actor.organizationId,
entityType: 'document',
entityId: doc.id,
action: 'settle',
after: { amount: amount.toFixed(2), accountId: account.id, status },
actorUserId: actor.userId,
},
}).catch(() => undefined);
if (status === 'settled') {
await this.prisma.financialRecurringOccurrence.updateMany({
where: { documentId: doc.id, status: 'pending' },
@@ -543,6 +555,28 @@ export class TreasuryService {
return this.toDocumentDto(fresh);
}
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
const doc = await this.prisma.financialDocument.findFirst({
where: { id: documentId, organizationId: actor.organizationId },
select: { id: true },
});
if (!doc) throw new NotFoundException('سند پیدا نشد.');
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
await this.files.save(storageKey, file.buffer);
return this.prisma.financialAttachment.create({
data: {
organizationId: actor.organizationId,
documentId: doc.id,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
storageKey,
createdById: actor.userId,
},
select: { id: true, fileName: true, createdAt: true },
});
}
async dashboard(
actor: Actor,
range?: { from?: string; to?: string },
@@ -1,18 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
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/incomes', label: 'دریافت‌ها و درآمدها' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها' },
{ to: '/finance/payroll', label: 'حقوق' },
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
{ to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' },
];
]);
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
+5
View File
@@ -157,6 +157,11 @@ function cardAllowedForNav(card: {
to?: string;
}) {
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 (!moduleAllowed(card.module)) return false;
if (
+1 -1
View File
@@ -360,7 +360,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صندوق، درآمد و هزینه داخلی',
icon: 'dollar',
tint: 'mint',
admin: true,
module: 'treasury',
},
{
to: '/reservations',
+10
View File
@@ -557,6 +557,10 @@ router.beforeEach((to) => {
) {
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;
if (roles?.length) {
const role = auth.user?.role as UserRole | undefined;
@@ -566,6 +570,12 @@ router.beforeEach((to) => {
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
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 true;
+36
View File
@@ -97,10 +97,46 @@ async function load() {
async function setStatus(row: ChequeDto, next: ChequeStatus) {
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;
try {
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
status: next,
...(financialAccountId ? { financialAccountId } : {}),
});
items.value = items.value.map((item) => (item.id === row.id ? data : item));
} catch {
+12
View File
@@ -22,6 +22,7 @@ import type {
CreateQuoteRequest,
DealDto,
DealLineItemInput,
FinancialAccountDto,
InvoiceDto,
InvoiceInstallmentInput,
InvoiceStatus,
@@ -123,6 +124,8 @@ const lines = ref<LineDraft[]>([]);
const installments = ref<InstallmentDraft[]>([]);
const invoice = ref<InvoiceDto | null>(null);
const payAccountId = ref('');
const financeAccounts = ref<FinancialAccountDto[]>([]);
const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
@@ -463,6 +466,8 @@ async function load() {
error.value = '';
mobileStep.value = 1;
try {
const accounts = await api.get<FinancialAccountDto[]>('/treasury/accounts').catch(() => null);
financeAccounts.value = accounts?.data.filter((row) => row.isActive) ?? [];
title.value = defaultTitle.value;
status.value = 'draft';
notes.value = '';
@@ -740,6 +745,7 @@ async function submitPayment() {
notes: payNotes.value || undefined,
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
installmentId: payInstallmentId.value || null,
financialAccountId: payAccountId.value || null,
cheque:
payMethod.value === 'check'
? {
@@ -1316,6 +1322,12 @@ onMounted(load);
</option>
</select>
<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">
<input v-model="chequeNumber" placeholder="شماره چک" />
<input v-model="chequeBank" placeholder="بانک" />
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
@@ -70,7 +71,7 @@ onMounted(async () => {
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div>
<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>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
@@ -171,7 +172,7 @@ onMounted(() => {
<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.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.notes" /></UiField>
</div>
@@ -192,7 +193,7 @@ onMounted(() => {
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select>
</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>
</div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
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) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
@@ -120,7 +134,7 @@ onMounted(() => {
<UiCard title="سند جدید">
<div class="grid">
<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="دسته‌بندی">
<select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option>
@@ -128,7 +142,7 @@ onMounted(() => {
</select>
</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="حساب">
<select v-model="form.accountId" class="select">
<option value="">انتخاب حساب</option>
@@ -162,6 +176,10 @@ onMounted(() => {
<div class="side">
<b>{{ toman(row.amount) }}</b>
<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>
</article>
<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 small, .muted { color: #64748b; }
.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; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
@@ -90,10 +91,10 @@ onMounted(() => {
</select>
</UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
</div>
<UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard>
@@ -109,7 +110,7 @@ onMounted(() => {
</article>
<UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
<UiField label="حساب">
<select v-model="pay.accountId" class="select">
<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 type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
@@ -121,8 +122,8 @@ onMounted(() => {
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
<UiField label="پیش‌پرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
<UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
<UiField label="پیش‌پرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
<UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select">
<option value="">انتخاب</option>
@@ -141,7 +142,7 @@ onMounted(() => {
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom">
<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>
</div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
@@ -191,7 +192,7 @@ onMounted(() => {
<UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
<UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select">
<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 { api } from '@/lib/api';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
@@ -51,7 +52,7 @@ onMounted(() => {
<option value="income">درآمد</option>
</select>
</UiField>
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>