diff --git a/apps/api/prisma/migrations/20260910233000_commission_and_cheques/migration.sql b/apps/api/prisma/migrations/20260910233000_commission_and_cheques/migration.sql new file mode 100644 index 0000000..fec3c30 --- /dev/null +++ b/apps/api/prisma/migrations/20260910233000_commission_and_cheques/migration.sql @@ -0,0 +1,75 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "commissionPercent" DECIMAL(8,2) NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "commissionPercent" DECIMAL(8,2), +ADD COLUMN "commissionFixedAmount" DECIMAL(18,2); + +-- CreateTable +CREATE TABLE "CommissionEntry" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "percent" DECIMAL(8,2) NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'IRR', + "status" TEXT NOT NULL DEFAULT 'posted', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommissionEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cheque" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "paymentId" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "number" TEXT NOT NULL, + "bank" TEXT NOT NULL, + "dueDate" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cheque_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionEntry_invoiceId_key" ON "CommissionEntry"("invoiceId"); + +-- CreateIndex +CREATE INDEX "CommissionEntry_organizationId_userId_createdAt_idx" ON "CommissionEntry"("organizationId", "userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "CommissionEntry_status_idx" ON "CommissionEntry"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cheque_paymentId_key" ON "Cheque"("paymentId"); + +-- CreateIndex +CREATE INDEX "Cheque_organizationId_status_dueDate_idx" ON "Cheque"("organizationId", "status", "dueDate"); + +-- CreateIndex +CREATE INDEX "Cheque_invoiceId_idx" ON "Cheque"("invoiceId"); + +-- AddForeignKey +ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3eb78dd..f69c2de 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -183,6 +183,8 @@ model Organization { warehouses Warehouse[] stockMovements StockMovement[] partyOpenings PartyOpeningBalance[] + commissionEntries CommissionEntry[] + cheques Cheque[] } model User { @@ -209,6 +211,8 @@ model User { noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") notifications Notification[] salesGoals SalesGoal[] @relation("SalesGoalUser") + commissionPercent Decimal @default(0) @db.Decimal(8, 2) + commissionEntries CommissionEntry[] assignedTickets Ticket[] @relation("TicketAssignee") reportedTickets Ticket[] @relation("TicketReporter") ticketMessages TicketMessage[] @relation("TicketMessageAuthor") @@ -731,6 +735,10 @@ model Invoice { payments Payment[] installments InvoiceInstallment[] stockMovements StockMovement[] + commissionPercent Decimal? @db.Decimal(8, 2) + commissionFixedAmount Decimal? @db.Decimal(18, 2) + commissionEntry CommissionEntry? + cheques Cheque[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -772,6 +780,7 @@ model Payment { notes String? installmentId String? @unique installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull) + cheque Cheque? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1254,6 +1263,49 @@ model StockMovement { @@index([warehouseId]) } +/// پورسانت کارشناس بعد از صدور فاکتور +model CommissionEntry { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + invoiceId String @unique + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + percent Decimal @db.Decimal(8, 2) + amount Decimal @db.Decimal(18, 2) + currency String @default("IRR") + /// posted | void + status String @default("posted") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, userId, createdAt]) + @@index([status]) +} + +/// چک دریافتی وصل به پرداخت فاکتور +model Cheque { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + paymentId String @unique + payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) + invoiceId String + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + number String + bank String + dueDate DateTime + /// pending | cleared | bounced + status String @default("pending") + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, status, dueDate]) + @@index([invoiceId]) +} + /// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما) model PartyOpeningBalance { id String @id @default(cuid()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc3c60c..fee1483 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -28,6 +28,7 @@ import { AiModule } from './ai/ai.module'; import { MemoryModule } from './memory/memory.module'; import { TahesabModule } from './integrations/tahesab/tahesab.module'; import { InventoryModule } from './inventory/inventory.module'; +import { FinanceModule } from './finance/finance.module'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { InventoryModule } from './inventory/inventory.module'; QuotesModule, InvoicesModule, InventoryModule, + FinanceModule, SalesModule, TicketsModule, FaqModule, diff --git a/apps/api/src/finance/cheques.service.ts b/apps/api/src/finance/cheques.service.ts new file mode 100644 index 0000000..cad6d4a --- /dev/null +++ b/apps/api/src/finance/cheques.service.ts @@ -0,0 +1,120 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { ChequeDto, UpdateChequeRequest } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { type Actor, ownerWhere } from '../common/record-scope'; + +const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const; + +@Injectable() +export class ChequesService { + constructor(private readonly prisma: PrismaService) {} + + async list( + organizationId: string, + actor: Pick, + opts?: { status?: string; q?: string }, + ): Promise { + const rows = await this.prisma.cheque.findMany({ + where: { + organizationId, + invoice: ownerWhere(actor), + ...(opts?.status ? { status: opts.status } : {}), + ...(opts?.q?.trim() + ? { + OR: [ + { number: { contains: opts.q.trim(), mode: 'insensitive' } }, + { bank: { contains: opts.q.trim(), mode: 'insensitive' } }, + { + invoice: { + number: { contains: opts.q.trim(), mode: 'insensitive' }, + }, + }, + ], + } + : {}), + }, + include: this.include, + orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], + take: 300, + }); + return rows.map((row) => this.toDto(row)); + } + + async update( + organizationId: string, + id: string, + actor: Pick, + dto: UpdateChequeRequest, + ): Promise { + const existing = await this.prisma.cheque.findFirst({ + where: { id, organizationId, invoice: ownerWhere(actor) }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('چک پیدا نشد'); + + const data: Prisma.ChequeUncheckedUpdateInput = {}; + if (dto.number !== undefined) data.number = dto.number.trim(); + if (dto.bank !== undefined) data.bank = dto.bank.trim(); + if (dto.dueDate !== undefined) data.dueDate = new Date(dto.dueDate); + if (dto.status !== undefined) { + if (!CHEQUE_STATUSES.includes(dto.status as (typeof CHEQUE_STATUSES)[number])) { + throw new BadRequestException('وضعیت چک معتبر نیست'); + } + data.status = dto.status; + } + if ('notes' in dto) { + data.notes = dto.notes?.trim() ? dto.notes.trim() : null; + } + + const row = await this.prisma.cheque.update({ + where: { id }, + data, + include: this.include, + }); + return this.toDto(row); + } + + private include = { + payment: { select: { id: true, amount: true, currency: true, paidAt: true } }, + invoice: { + select: { + id: true, + number: true, + title: true, + person: { select: { firstName: true, lastName: true } }, + company: { select: { name: true } }, + }, + }, + } satisfies Prisma.ChequeInclude; + + private toDto( + row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>, + ): ChequeDto { + const personName = row.invoice.person + ? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim() + : row.invoice.company?.name ?? null; + return { + id: row.id, + paymentId: row.paymentId, + invoiceId: row.invoiceId, + invoiceNumber: row.invoice.number, + invoiceTitle: row.invoice.title, + personName, + amount: Number(row.payment.amount), + currency: row.payment.currency, + number: row.number, + bank: row.bank, + dueDate: row.dueDate.toISOString(), + status: row.status, + notes: row.notes, + paidAt: row.payment.paidAt.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/finance/commissions.service.ts b/apps/api/src/finance/commissions.service.ts new file mode 100644 index 0000000..e84d48f --- /dev/null +++ b/apps/api/src/finance/commissions.service.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import type { CommissionEntryDto } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; + +function roundMoney(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +@Injectable() +export class CommissionsService { + constructor(private readonly prisma: PrismaService) {} + + async list( + organizationId: string, + actor: Pick, + ): Promise { + const rows = await this.prisma.commissionEntry.findMany({ + where: { + organizationId, + ...this.userScope(actor), + }, + include: { + user: { select: { id: true, fullName: true } }, + invoice: { select: { id: true, number: true, title: true, ownerId: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + return rows + .filter((row) => this.canSeeInvoice(actor, row.invoice.ownerId)) + .map((row) => this.toDto(row)); + } + + async syncInvoice(organizationId: string, invoiceId: string) { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, organizationId }, + include: { + owner: { select: { id: true, commissionPercent: true } }, + lineItems: true, + commissionEntry: true, + }, + }); + if (!invoice) return; + + const shouldPost = + invoice.kind !== 'credit' && + invoice.status !== 'draft' && + invoice.status !== 'canceled' && + Boolean(invoice.ownerId); + + if (!shouldPost) { + if (invoice.commissionEntry) { + await this.prisma.commissionEntry.update({ + where: { id: invoice.commissionEntry.id }, + data: { status: 'void', amount: 0 }, + }); + } + return; + } + + const subtotal = invoice.lineItems.reduce( + (sum, item) => sum + Number(item.quantity) * Number(item.unitPrice), + 0, + ); + const discountAmount = Number(invoice.discountAmount ?? 0); + const taxPercent = Number(invoice.taxPercent ?? 0); + const taxAmount = roundMoney( + Math.max(0, subtotal - discountAmount) * (taxPercent / 100), + ); + const payable = Math.abs( + roundMoney(subtotal - discountAmount + taxAmount), + ); + + const fixed = invoice.commissionFixedAmount; + const overridePercent = invoice.commissionPercent; + const defaultPercent = Number(invoice.owner?.commissionPercent ?? 0); + const percent = + fixed != null + ? payable > 0 + ? roundMoney((Number(fixed) / payable) * 100) + : 0 + : Number(overridePercent ?? defaultPercent); + const amount = + fixed != null ? roundMoney(Number(fixed)) : roundMoney((payable * percent) / 100); + + await this.prisma.commissionEntry.upsert({ + where: { invoiceId: invoice.id }, + create: { + organizationId, + userId: invoice.ownerId!, + invoiceId: invoice.id, + percent: new Prisma.Decimal(percent), + amount: new Prisma.Decimal(amount), + currency: invoice.currency, + status: 'posted', + }, + update: { + userId: invoice.ownerId!, + percent: new Prisma.Decimal(percent), + amount: new Prisma.Decimal(amount), + currency: invoice.currency, + status: 'posted', + }, + }); + } + + private userScope( + actor: Pick, + ): Prisma.CommissionEntryWhereInput { + if (actor.role === 'owner' || actor.role === 'admin') return {}; + return { userId: actor.userId }; + } + + private canSeeInvoice( + actor: Pick, + ownerId: string | null, + ) { + if (actor.role === 'owner' || actor.role === 'admin') return true; + return ownerId === actor.userId; + } + + private toDto(row: { + id: string; + userId: string; + invoiceId: string; + percent: Prisma.Decimal; + amount: Prisma.Decimal; + currency: string; + status: string; + createdAt: Date; + user: { fullName: string }; + invoice: { number: string; title: string }; + }): CommissionEntryDto { + return { + id: row.id, + userId: row.userId, + userName: row.user.fullName, + invoiceId: row.invoiceId, + invoiceNumber: row.invoice.number, + invoiceTitle: row.invoice.title, + percent: Number(row.percent), + amount: Number(row.amount), + currency: row.currency, + status: row.status, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/finance/dto.ts b/apps/api/src/finance/dto.ts new file mode 100644 index 0000000..f19fa39 --- /dev/null +++ b/apps/api/src/finance/dto.ts @@ -0,0 +1,28 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; + +const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const; + +export class UpdateChequeDto { + @IsOptional() + @IsString() + @MaxLength(80) + number?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + bank?: string; + + @IsOptional() + @IsString() + dueDate?: string; + + @IsOptional() + @IsIn(CHEQUE_STATUSES) + status?: (typeof CHEQUE_STATUSES)[number]; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string | null; +} diff --git a/apps/api/src/finance/finance.controller.ts b/apps/api/src/finance/finance.controller.ts new file mode 100644 index 0000000..5a3f954 --- /dev/null +++ b/apps/api/src/finance/finance.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { UpdateChequeDto } from './dto'; +import { ChequesService } from './cheques.service'; +import { CommissionsService } from './commissions.service'; + +type AuthUser = { organizationId: string; userId: string; role: string }; + +@Controller() +@UseGuards(JwtAuthGuard) +export class FinanceController { + constructor( + private readonly cheques: ChequesService, + private readonly commissions: CommissionsService, + ) {} + + @Get('cheques') + listCheques( + @CurrentUser() user: AuthUser, + @Query('status') status?: string, + @Query('q') q?: string, + ) { + return this.cheques.list(user.organizationId, user, { status, q }); + } + + @Patch('cheques/:id') + updateCheque( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateChequeDto, + ) { + return this.cheques.update(user.organizationId, id, user, dto); + } + + @Get('commissions') + listCommissions(@CurrentUser() user: AuthUser) { + return this.commissions.list(user.organizationId, user); + } +} diff --git a/apps/api/src/finance/finance.module.ts b/apps/api/src/finance/finance.module.ts new file mode 100644 index 0000000..4de26d8 --- /dev/null +++ b/apps/api/src/finance/finance.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ChequesService } from './cheques.service'; +import { CommissionsService } from './commissions.service'; +import { FinanceController } from './finance.controller'; + +@Module({ + imports: [AuthModule], + controllers: [FinanceController], + providers: [ChequesService, CommissionsService], + exports: [ChequesService, CommissionsService], +}) +export class FinanceModule {} diff --git a/apps/api/src/invoices/dto.ts b/apps/api/src/invoices/dto.ts index 862f0ad..a914e2f 100644 --- a/apps/api/src/invoices/dto.ts +++ b/apps/api/src/invoices/dto.ts @@ -151,6 +151,18 @@ export class CreateInvoiceDto { @ValidateNested({ each: true }) @Type(() => InvoiceInstallmentInputDto) installments?: InvoiceInstallmentInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + commissionPercent?: number | null; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + commissionFixedAmount?: number | null; } export class UpdateInvoiceDto { @@ -225,6 +237,42 @@ export class UpdateInvoiceDto { @ValidateNested({ each: true }) @Type(() => InvoiceInstallmentInputDto) installments?: InvoiceInstallmentInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + commissionPercent?: number | null; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + commissionFixedAmount?: number | null; +} + +export class ChequeInputDto { + @IsString() + @MinLength(1) + @MaxLength(80) + number!: string; + + @IsString() + @MinLength(1) + @MaxLength(120) + bank!: string; + + @IsString() + dueDate!: string; + + @IsOptional() + @IsIn(['pending', 'cleared', 'bounced']) + status?: 'pending' | 'cleared' | 'bounced'; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; } export class CreatePaymentDto { @@ -254,6 +302,11 @@ export class CreatePaymentDto { @IsOptional() @IsString() installmentId?: string | null; + + @IsOptional() + @ValidateNested() + @Type(() => ChequeInputDto) + cheque?: ChequeInputDto; } export class ListInvoicesQueryDto { diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts index 2313ca8..9059294 100644 --- a/apps/api/src/invoices/invoices.module.ts +++ b/apps/api/src/invoices/invoices.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AutomationsModule } from '../automations/automations.module'; +import { FinanceModule } from '../finance/finance.module'; import { InventoryModule } from '../inventory/inventory.module'; import { InvoicesController } from './invoices.controller'; import { InvoicesService } from './invoices.service'; @Module({ - imports: [AutomationsModule, InventoryModule], + imports: [AutomationsModule, InventoryModule, FinanceModule], controllers: [InvoicesController], providers: [InvoicesService], exports: [InvoicesService], diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index f45296e..83c67af 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -18,6 +18,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { type Actor, ownerWhere } from '../common/record-scope'; import { AutomationsService } from '../automations/automations.service'; import { InventoryService } from '../inventory/inventory.service'; +import { CommissionsService } from '../finance/commissions.service'; type InvoiceActor = Pick; @@ -28,7 +29,11 @@ const include = { quote: { select: { id: true, number: true, title: true } }, owner: { select: { id: true, fullName: true } }, lineItems: { orderBy: { position: 'asc' as const } }, - payments: { orderBy: { paidAt: 'desc' as const } }, + payments: { + orderBy: { paidAt: 'desc' as const }, + include: { cheque: true }, + }, + commissionEntry: true, installments: { orderBy: { position: 'asc' as const }, include: { payment: { select: { id: true } } }, @@ -43,6 +48,7 @@ export class InvoicesService { private readonly prisma: PrismaService, private readonly automations: AutomationsService, private readonly inventory: InventoryService, + private readonly commissions: CommissionsService, ) {} async list( @@ -249,6 +255,14 @@ export class InvoicesService { dealId, quoteId, ownerId: userId, + commissionPercent: + dto.commissionPercent != null + ? new Prisma.Decimal(dto.commissionPercent) + : null, + commissionFixedAmount: + dto.commissionFixedAmount != null + ? new Prisma.Decimal(dto.commissionFixedAmount) + : null, ...(lineItems.length ? { lineItems: { @@ -270,8 +284,13 @@ export class InvoicesService { }, include, }); - const created = this.toDto(row); - await this.inventory.syncInvoiceStock(organizationId, created.id); + await this.inventory.syncInvoiceStock(organizationId, row.id); + await this.commissions.syncInvoice(organizationId, row.id); + const fresh = await this.prisma.invoice.findFirst({ + where: { id: row.id }, + include, + }); + const created = this.toDto(fresh ?? row); if (created.status !== 'draft' && created.status !== 'canceled') { await this.fireIssued(organizationId, created); } @@ -334,6 +353,18 @@ export class InvoicesService { if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('quoteId' in dto) data.quoteId = emptyToNull(dto.quoteId ?? null); + if ('commissionPercent' in dto) { + data.commissionPercent = + dto.commissionPercent == null + ? null + : new Prisma.Decimal(dto.commissionPercent); + } + if ('commissionFixedAmount' in dto) { + data.commissionFixedAmount = + dto.commissionFixedAmount == null + ? null + : new Prisma.Decimal(dto.commissionFixedAmount); + } if ( dto.status && @@ -387,6 +418,7 @@ export class InvoicesService { const row = await this.get(organizationId, id, actor); await this.inventory.syncInvoiceStock(organizationId, row.id); + await this.commissions.syncInvoice(organizationId, row.id); const leftDraft = existing.status === 'draft' && row.status !== 'draft' && @@ -414,13 +446,23 @@ export class InvoicesService { ): Promise { const invoice = await this.prisma.invoice.findFirst({ where: { id: invoiceId, organizationId, ...ownerWhere(actor) }, - select: { id: true, status: true, currency: true }, + select: { id: true, status: true, currency: true, organizationId: true }, }); if (!invoice) throw new NotFoundException('فاکتور پیدا نشد'); if (invoice.status === 'canceled') { throw new BadRequestException('روی فاکتور لغوشده نمی‌توان پرداخت ثبت کرد'); } + const method = (dto.method as string) || 'transfer'; + if (method === 'check') { + const cheque = dto.cheque; + if (!cheque?.number?.trim() || !cheque.bank?.trim() || !cheque.dueDate) { + throw new BadRequestException( + 'برای پرداخت چکی شماره، بانک و تاریخ سررسید لازم است', + ); + } + } + const installmentId = emptyToNull(dto.installmentId ?? null); if (installmentId) { const installment = await this.prisma.invoiceInstallment.findFirst({ @@ -434,17 +476,33 @@ export class InvoicesService { } await this.prisma.$transaction(async (tx) => { - await tx.payment.create({ + const payment = await tx.payment.create({ data: { invoiceId, amount: new Prisma.Decimal(dto.amount), currency: (dto.currency ?? invoice.currency).trim() || 'IRR', - method: (dto.method as never) || 'transfer', + method: method as never, paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(), notes: emptyToNull(dto.notes ?? null), installmentId, }, }); + if (method === 'check' && dto.cheque) { + await tx.cheque.create({ + data: { + organizationId: invoice.organizationId, + paymentId: payment.id, + invoiceId, + number: dto.cheque.number.trim(), + bank: dto.cheque.bank.trim(), + dueDate: new Date(dto.cheque.dueDate), + status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced' + ? dto.cheque.status + : 'pending', + notes: emptyToNull(dto.cheque.notes ?? null), + }, + }); + } if (installmentId) { await tx.invoiceInstallment.update({ where: { id: installmentId }, @@ -465,6 +523,7 @@ export class InvoicesService { await this.recomputeStatus(invoiceId); const row = await this.get(organizationId, invoiceId, actor); await this.inventory.syncInvoiceStock(organizationId, row.id); + await this.commissions.syncInvoice(organizationId, row.id); if (invoice.status === 'draft') { await this.fireIssued(organizationId, row); } @@ -504,6 +563,7 @@ export class InvoicesService { }); await this.recomputeStatus(invoiceId); + await this.commissions.syncInvoice(organizationId, invoiceId); return this.get(organizationId, invoiceId, actor); } @@ -698,6 +758,28 @@ export class InvoicesService { paidAt: p.paidAt.toISOString(), notes: p.notes, installmentId: p.installmentId, + cheque: p.cheque + ? { + id: p.cheque.id, + paymentId: p.id, + invoiceId: row.id, + invoiceNumber: row.number, + invoiceTitle: row.title, + personName: row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : row.company?.name ?? null, + amount: Number(p.amount), + currency: p.currency, + number: p.cheque.number, + bank: p.cheque.bank, + dueDate: p.cheque.dueDate.toISOString(), + status: p.cheque.status, + notes: p.cheque.notes, + paidAt: p.paidAt.toISOString(), + createdAt: p.cheque.createdAt.toISOString(), + updatedAt: p.cheque.updatedAt.toISOString(), + } + : null, createdAt: p.createdAt.toISOString(), })); const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0); @@ -743,6 +825,19 @@ export class InvoicesService { lineItems, payments, installments, + commissionPercent: + row.commissionPercent == null ? null : Number(row.commissionPercent), + commissionFixedAmount: + row.commissionFixedAmount == null + ? null + : Number(row.commissionFixedAmount), + commission: row.commissionEntry + ? { + percent: Number(row.commissionEntry.percent), + amount: Number(row.commissionEntry.amount), + status: row.commissionEntry.status, + } + : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index ba56da5..950daa4 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -1,8 +1,12 @@ +import { Type } from 'class-transformer'; import { IsEmail, IsIn, + IsNumber, IsString, + Max, MaxLength, + Min, MinLength, } from 'class-validator'; @@ -31,3 +35,11 @@ export class UpdateUserRoleDto { @IsIn(ALL_ROLES) role!: (typeof ALL_ROLES)[number]; } + +export class UpdateUserCommissionDto { + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + commissionPercent!: number; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 2c347ec..14a248f 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -12,7 +12,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; -import { InviteUserDto, UpdateUserRoleDto } from './dto'; +import { InviteUserDto, UpdateUserCommissionDto, UpdateUserRoleDto } from './dto'; import { UsersService } from './users.service'; @Controller('users') @@ -34,6 +34,21 @@ export class UsersController { return this.usersService.invite(user.organizationId, user.role, dto); } + @Patch(':id/commission') + @Roles('owner', 'admin') + updateCommission( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateUserCommissionDto, + ) { + return this.usersService.updateCommission( + user.organizationId, + user.role, + id, + dto.commissionPercent, + ); + } + @Patch(':id/role') @Roles('owner', 'admin') updateRole( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index c3cbb2f..9bc88f0 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared'; import * as bcrypt from 'bcryptjs'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() @@ -56,6 +57,29 @@ export class UsersService { return this.toDto(row); } + async updateCommission( + organizationId: string, + actorRole: UserRole, + targetId: string, + percent: number, + ): Promise { + if (actorRole !== 'owner' && actorRole !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند پورسانت را تعیین کند'); + } + if (percent < 0 || percent > 100) { + throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد'); + } + const target = await this.prisma.user.findFirst({ + where: { id: targetId, organizationId }, + }); + if (!target) throw new NotFoundException('کاربر پیدا نشد'); + const row = await this.prisma.user.update({ + where: { id: target.id }, + data: { commissionPercent: new Prisma.Decimal(percent) }, + }); + return this.toDto(row); + } + async updateRole( organizationId: string, actor: { userId: string; role: UserRole }, @@ -100,6 +124,7 @@ export class UsersService { email: string; fullName: string; role: UserRole; + commissionPercent?: { toString(): string } | number; createdAt: Date; }): OrgUserDto { return { @@ -107,6 +132,7 @@ export class UsersService { email: row.email, fullName: row.fullName, role: row.role, + commissionPercent: Number(row.commissionPercent ?? 0), createdAt: row.createdAt.toISOString(), }; } diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index dada177..18d3bdd 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -36,7 +36,7 @@ const settingsOpen = ref(false); const globalSearch = ref(''); const activityBadge = ref(0); -const salesPaths = ['/quotes', '/invoices', '/inventory', '/sales/forecast', '/reports', '/notes', '/tickets']; +const salesPaths = ['/quotes', '/invoices', '/inventory', '/cheques', '/commissions', '/sales/forecast', '/reports', '/notes', '/tickets']; const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty']; const settingsPaths = [ '/settings/team', @@ -64,6 +64,8 @@ const extraActive = computed(() => '/quotes', '/invoices', '/inventory', + '/cheques', + '/commissions', '/sales/forecast', '/notes', '/tickets', @@ -392,6 +394,8 @@ onUnmounted(() => { @click="closeMenu" >فاکتورها انبار + چک‌ها + پورسانت هدف و پیش‌بینی یادداشت‌ها تیکت‌ها @@ -480,6 +484,8 @@ onUnmounted(() => { @click="closeMenu" >فاکتورها انبار + چک‌ها + پورسانت هدف و پیش‌بینی یادداشت‌ها تیکت‌ها diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 1cc8407..989f3a4 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -128,6 +128,16 @@ export const router = createRouter({ name: 'inventory', component: () => import('@/views/InventoryView.vue'), }, + { + path: 'cheques', + name: 'cheques', + component: () => import('@/views/ChequesView.vue'), + }, + { + path: 'commissions', + name: 'commissions', + component: () => import('@/views/CommissionsView.vue'), + }, { path: 'pipeline', name: 'pipeline', diff --git a/apps/web/src/views/ChequesView.vue b/apps/web/src/views/ChequesView.vue new file mode 100644 index 0000000..100046d --- /dev/null +++ b/apps/web/src/views/ChequesView.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/web/src/views/CommissionsView.vue b/apps/web/src/views/CommissionsView.vue new file mode 100644 index 0000000..8666907 --- /dev/null +++ b/apps/web/src/views/CommissionsView.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/apps/web/src/views/InvoiceFormView.vue b/apps/web/src/views/InvoiceFormView.vue index b174bd1..1e65bcd 100644 --- a/apps/web/src/views/InvoiceFormView.vue +++ b/apps/web/src/views/InvoiceFormView.vue @@ -14,6 +14,7 @@ import MoneyInput from '@/components/MoneyInput.vue'; import UiIcon from '@/components/UiIcon.vue'; import BrandName from '@/components/brand/BrandName.vue'; import { brand } from '@mento/shared'; +import { useAuthStore } from '@/stores/auth'; import type { CreateInvoiceRequest, CreatePaymentRequest, @@ -24,6 +25,7 @@ import type { InvoiceInstallmentInput, InvoiceStatus, OrganizationDto, + OrgUserDto, PaginatedResponse, PaymentMethod, PersonDto, @@ -125,6 +127,15 @@ const payMethod = ref('transfer'); const payNotes = ref(''); const payInstallmentId = ref(''); const payDate = ref(formatLocalDateInput(new Date())); +const chequeNumber = ref(''); +const chequeBank = ref(''); +const chequeDue = ref(''); +const chequeStatus = ref<'pending' | 'cleared' | 'bounced'>('pending'); +const commissionMode = ref<'percent' | 'amount'>('percent'); +const commissionPercent = ref(''); +const commissionFixedAmount = ref(''); +const defaultCommissionPercent = ref(0); +const auth = useAuthStore(); const invoiceStatusLabel: Record = { draft: 'پیش‌نویس', @@ -249,6 +260,16 @@ const payableAmount = computed(() => docMode.value === 'credit' ? -payableSigned.value : payableSigned.value, ); +const commissionPreview = computed(() => { + const base = Math.abs(payableAmount.value); + if (commissionMode.value === 'amount') { + const n = Number(commissionFixedAmount.value); + return Number.isFinite(n) ? n : 0; + } + const n = Number(commissionPercent.value || defaultCommissionPercent.value); + return Number.isFinite(n) ? Math.round((base * n) / 100) : 0; +}); + function formatMoney(amount: number) { return formatMoneyLib(amount, currency.value); } @@ -461,6 +482,9 @@ async function load() { loadedInvoiceKind.value = null; installments.value = []; lines.value = []; + commissionMode.value = 'percent'; + commissionPercent.value = ''; + commissionFixedAmount.value = ''; validityMode.value = 'days'; validityDays.value = 30; @@ -473,6 +497,16 @@ async function load() { ]); people.value = peopleRes.data.items; products.value = productsRes.data; + try { + const { data: team } = await api.get('/users'); + const me = team.find((u) => u.id === auth.user?.id); + defaultCommissionPercent.value = Number(me?.commissionPercent ?? 0); + if (!commissionPercent.value && defaultCommissionPercent.value) { + commissionPercent.value = String(defaultCommissionPercent.value); + } + } catch { + /* optional */ + } enabledCurrencies.value = orgRes.data.enabledCurrencies?.length ? orgRes.data.enabledCurrencies : [orgRes.data.baseCurrency || 'IRR']; @@ -525,6 +559,18 @@ async function load() { if (data.remainingAmount > 0) { payAmount.value = String(data.remainingAmount); } + if (data.commissionFixedAmount != null) { + commissionMode.value = 'amount'; + commissionFixedAmount.value = String(data.commissionFixedAmount); + commissionPercent.value = String( + data.commissionPercent ?? defaultCommissionPercent.value, + ); + } else { + commissionMode.value = 'percent'; + commissionPercent.value = String( + data.commissionPercent ?? defaultCommissionPercent.value, + ); + } } if (!lines.value.length) lines.value = [newLine()]; } else { @@ -570,6 +616,18 @@ function invoicePayload(): CreateInvoiceRequest { lineItems: toLineInputs(), installments: toInstallmentInputs(), currency: currency.value || 'IRR', + commissionPercent: + commissionMode.value === 'percent' + ? commissionPercent.value === '' + ? null + : Number(commissionPercent.value) + : null, + commissionFixedAmount: + commissionMode.value === 'amount' + ? commissionFixedAmount.value === '' + ? null + : Number(commissionFixedAmount.value) + : null, }; } @@ -671,6 +729,10 @@ async function submitPayment() { error.value = 'مبلغ پرداخت را وارد کنید.'; return; } + if (payMethod.value === 'check' && (!chequeNumber.value.trim() || !chequeBank.value.trim() || !chequeDue.value)) { + error.value = 'شماره، بانک و سررسید چک را وارد کنید.'; + return; + } paying.value = true; error.value = ''; try { @@ -680,6 +742,15 @@ async function submitPayment() { notes: payNotes.value || undefined, paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined, installmentId: payInstallmentId.value || null, + cheque: + payMethod.value === 'check' + ? { + number: chequeNumber.value.trim(), + bank: chequeBank.value.trim(), + dueDate: new Date(chequeDue.value).toISOString(), + status: chequeStatus.value, + } + : undefined, }; const { data } = await api.post( `/invoices/${id.value}/payments`, @@ -688,6 +759,10 @@ async function submitPayment() { invoice.value = data; payNotes.value = ''; payInstallmentId.value = ''; + chequeNumber.value = ''; + chequeBank.value = ''; + chequeDue.value = ''; + chequeStatus.value = 'pending'; await load(); } catch (e) { error.value = axiosMessage(e, 'ثبت پرداخت ناموفق بود'); @@ -1128,6 +1203,45 @@ onMounted(load); مبلغ قابل پرداخت {{ formatMoney(payableAmount) }} +
+
+ + +
+ + +

+ برآورد پورسانت: {{ formatMoney(commissionPreview) }} + +

+
@@ -1210,6 +1324,16 @@ onMounted(load); +
+ + + + +