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
+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 {}