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
+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(),
};