Add salesperson commissions on issued invoices and a cheque register.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 01:09:59 +03:30
co-authored by Cursor
parent 535c428270
commit 9a991fc1f1
22 changed files with 1238 additions and 11 deletions
@@ -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;
+52
View File
@@ -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())
+2
View File
@@ -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,
+120
View File
@@ -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<Actor, 'userId' | 'role'>,
opts?: { status?: string; q?: string },
): Promise<ChequeDto[]> {
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<Actor, 'userId' | 'role'>,
dto: UpdateChequeRequest,
): Promise<ChequeDto> {
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(),
};
}
}
+150
View File
@@ -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<Actor, 'userId' | 'role'>,
): Promise<CommissionEntryDto[]> {
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<Actor, 'userId' | 'role'>,
): Prisma.CommissionEntryWhereInput {
if (actor.role === 'owner' || actor.role === 'admin') return {};
return { userId: actor.userId };
}
private canSeeInvoice(
actor: Pick<Actor, 'userId' | 'role'>,
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(),
};
}
}
+28
View File
@@ -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;
}
@@ -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);
}
}
+13
View File
@@ -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 {}
+53
View File
@@ -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 {
+2 -1
View File
@@ -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],
+101 -6
View File
@@ -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<Actor, 'userId' | 'role'>;
@@ -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<InvoiceDto> {
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(),
};
+12
View File
@@ -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;
}
+16 -1
View File
@@ -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(
+26
View File
@@ -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<OrgUserDto> {
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(),
};
}