Add salesperson commissions on issued invoices and a cheque register.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user