Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const INVOICE_STATUSES = [
|
||||
'draft',
|
||||
'issued',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
'canceled',
|
||||
] as const;
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
'cash',
|
||||
'transfer',
|
||||
'card',
|
||||
'check',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export class InvoiceLineItemInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class InvoiceInstallmentInputDto {
|
||||
@IsString()
|
||||
dueDate!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issuedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromDealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromQuoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceLineItemInputDto)
|
||||
@ArrayMinSize(0)
|
||||
lineItems?: InvoiceLineItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceInstallmentInputDto)
|
||||
installments?: InvoiceInstallmentInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
notes?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issuedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceLineItemInputDto)
|
||||
lineItems?: InvoiceLineItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceInstallmentInputDto)
|
||||
installments?: InvoiceInstallmentInputDto[];
|
||||
}
|
||||
|
||||
export class CreatePaymentDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PAYMENT_METHODS)
|
||||
method?: (typeof PAYMENT_METHODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
paidAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
installmentId?: string | null;
|
||||
}
|
||||
|
||||
export class ListInvoicesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreatePaymentDto,
|
||||
ListInvoicesQueryDto,
|
||||
UpdateInvoiceDto,
|
||||
} from './dto';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('invoices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: ListInvoicesQueryDto,
|
||||
) {
|
||||
return this.invoicesService.list(user.organizationId, user, {
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
dealId: query.dealId,
|
||||
personId: query.personId,
|
||||
quoteId: query.quoteId,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.invoicesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateInvoiceDto) {
|
||||
return this.invoicesService.create(user.organizationId, user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateInvoiceDto,
|
||||
) {
|
||||
return this.invoicesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.invoicesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(':id/payments')
|
||||
addPayment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreatePaymentDto,
|
||||
) {
|
||||
return this.invoicesService.addPayment(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/payments/:paymentId')
|
||||
removePayment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('paymentId') paymentId: string,
|
||||
) {
|
||||
return this.invoicesService.removePayment(
|
||||
user.organizationId,
|
||||
id,
|
||||
paymentId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
@@ -0,0 +1,641 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateInvoiceRequest,
|
||||
CreatePaymentRequest,
|
||||
DealLineItemInput,
|
||||
InvoiceDto,
|
||||
InvoiceInstallmentInput,
|
||||
PaginatedResponse,
|
||||
PaymentDto,
|
||||
UpdateInvoiceRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
|
||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
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 } },
|
||||
installments: {
|
||||
orderBy: { position: 'asc' as const },
|
||||
include: { payment: { select: { id: true } } },
|
||||
},
|
||||
} satisfies Prisma.InvoiceInclude;
|
||||
|
||||
type InvoiceRow = Prisma.InvoiceGetPayload<{ include: typeof include }>;
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
actor: InvoiceActor,
|
||||
opts: {
|
||||
q?: string;
|
||||
status?: string;
|
||||
dealId?: string;
|
||||
personId?: string;
|
||||
quoteId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<PaginatedResponse<InvoiceDto>> {
|
||||
const page = opts.page ?? 1;
|
||||
const limit = opts.limit ?? 50;
|
||||
const where: Prisma.InvoiceWhereInput = {
|
||||
organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(opts.status ? { status: opts.status as never } : {}),
|
||||
...(opts.dealId ? { dealId: opts.dealId } : {}),
|
||||
...(opts.personId ? { personId: opts.personId } : {}),
|
||||
...(opts.quoteId ? { quoteId: opts.quoteId } : {}),
|
||||
...(opts.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ notes: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.invoice.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.invoice.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<InvoiceDto> {
|
||||
const row = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateInvoiceRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
let title = dto.title.trim();
|
||||
let personId = emptyToNull(dto.personId ?? null);
|
||||
let companyId = emptyToNull(dto.companyId ?? null);
|
||||
let dealId = emptyToNull(dto.dealId ?? null);
|
||||
let quoteId = emptyToNull(dto.quoteId ?? null);
|
||||
let lineItems = dto.lineItems ?? [];
|
||||
let currency = (dto.currency ?? 'IRR').trim() || 'IRR';
|
||||
let notes = emptyToNull(dto.notes ?? null);
|
||||
let dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
let issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
|
||||
let status = (dto.status as never) || 'draft';
|
||||
|
||||
const fromQuoteId = emptyToNull(dto.fromQuoteId ?? null);
|
||||
if (fromQuoteId) {
|
||||
const quote = await this.prisma.quote.findFirst({
|
||||
where: { id: fromQuoteId, organizationId },
|
||||
include: { lineItems: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!quote) throw new BadRequestException('پیشفاکتور مبدأ معتبر نیست');
|
||||
quoteId = quote.id;
|
||||
dealId = dealId ?? quote.dealId;
|
||||
personId = personId ?? quote.personId;
|
||||
companyId = companyId ?? quote.companyId;
|
||||
if (!title || title === 'فاکتور') {
|
||||
title = `فاکتور — ${quote.title}`;
|
||||
}
|
||||
currency = quote.currency || currency;
|
||||
notes = notes ?? quote.notes;
|
||||
if (!lineItems.length && quote.lineItems.length) {
|
||||
lineItems = quote.lineItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
currency: item.currency,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const fromDealId = emptyToNull(dto.fromDealId ?? null);
|
||||
if (fromDealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: fromDealId, organizationId },
|
||||
include: { lineItems: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست');
|
||||
dealId = deal.id;
|
||||
personId = personId ?? deal.personId;
|
||||
companyId = companyId ?? deal.companyId;
|
||||
if (!title || title === 'فاکتور') {
|
||||
title = `فاکتور — ${deal.title}`;
|
||||
}
|
||||
currency = deal.currency || currency;
|
||||
notes = notes ?? deal.notes;
|
||||
if (!lineItems.length && deal.lineItems.length) {
|
||||
lineItems = deal.lineItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
currency: item.currency,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'draft' && !issuedAt) {
|
||||
issuedAt = new Date();
|
||||
}
|
||||
|
||||
await this.assertLinks(
|
||||
organizationId,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
quoteId,
|
||||
);
|
||||
if (lineItems.length) {
|
||||
await this.assertProducts(organizationId, lineItems);
|
||||
}
|
||||
|
||||
const number = await this.nextNumber(organizationId);
|
||||
const installments = dto.installments ?? [];
|
||||
|
||||
const row = await this.prisma.invoice.create({
|
||||
data: {
|
||||
organizationId,
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
currency,
|
||||
notes,
|
||||
issuedAt,
|
||||
dueDate,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
quoteId,
|
||||
ownerId: userId,
|
||||
...(lineItems.length
|
||||
? {
|
||||
lineItems: {
|
||||
create: lineItems.map((item, index) =>
|
||||
this.lineCreate(item, index, currency),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(installments.length
|
||||
? {
|
||||
installments: {
|
||||
create: installments.map((item, index) =>
|
||||
this.installmentCreate(item, index, currency),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
dto: UpdateInvoiceRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
const existing = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true, status: true, currency: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
|
||||
if (
|
||||
'personId' in dto ||
|
||||
'companyId' in dto ||
|
||||
'dealId' in dto ||
|
||||
'quoteId' in dto
|
||||
) {
|
||||
await this.assertLinks(
|
||||
organizationId,
|
||||
emptyToNull(dto.personId ?? null),
|
||||
emptyToNull(dto.companyId ?? null),
|
||||
emptyToNull(dto.dealId ?? null),
|
||||
emptyToNull(dto.quoteId ?? null),
|
||||
);
|
||||
}
|
||||
if (dto.lineItems?.length) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const data: Prisma.InvoiceUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.status !== undefined) data.status = dto.status as never;
|
||||
if (dto.currency !== undefined) {
|
||||
data.currency = dto.currency.trim() || 'IRR';
|
||||
}
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
|
||||
if ('issuedAt' in dto) {
|
||||
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
|
||||
}
|
||||
if ('dueDate' in dto) {
|
||||
data.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
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 (
|
||||
dto.status &&
|
||||
dto.status !== 'draft' &&
|
||||
dto.status !== 'canceled' &&
|
||||
!('issuedAt' in dto)
|
||||
) {
|
||||
data.issuedAt = new Date();
|
||||
}
|
||||
|
||||
const currency =
|
||||
(typeof data.currency === 'string' && data.currency) ||
|
||||
existing.currency ||
|
||||
'IRR';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.lineItems) {
|
||||
await tx.invoiceLineItem.deleteMany({ where: { invoiceId: id } });
|
||||
data.lineItems = {
|
||||
create: dto.lineItems.map((item, index) =>
|
||||
this.lineCreate(item, index, currency),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.installments) {
|
||||
const linkedPayments = await tx.payment.count({
|
||||
where: { invoiceId: id, installmentId: { not: null } },
|
||||
});
|
||||
if (linkedPayments > 0) {
|
||||
throw new BadRequestException(
|
||||
'برای تغییر اقساط، ابتدا پرداختهای متصل به قسط را حذف کنید',
|
||||
);
|
||||
}
|
||||
await tx.invoiceInstallment.deleteMany({ where: { invoiceId: id } });
|
||||
data.installments = {
|
||||
create: dto.installments.map((item, index) =>
|
||||
this.installmentCreate(item, index, currency),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(data).length) {
|
||||
await tx.invoice.update({ where: { id }, data });
|
||||
}
|
||||
});
|
||||
|
||||
if (dto.status === undefined || dto.status === 'issued') {
|
||||
await this.recomputeStatus(id);
|
||||
}
|
||||
|
||||
return this.get(organizationId, id, actor);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.prisma.invoice.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addPayment(
|
||||
organizationId: string,
|
||||
invoiceId: string,
|
||||
actor: InvoiceActor,
|
||||
dto: CreatePaymentRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true, status: true, currency: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
if (invoice.status === 'canceled') {
|
||||
throw new BadRequestException('روی فاکتور لغوشده نمیتوان پرداخت ثبت کرد');
|
||||
}
|
||||
|
||||
const installmentId = emptyToNull(dto.installmentId ?? null);
|
||||
if (installmentId) {
|
||||
const installment = await this.prisma.invoiceInstallment.findFirst({
|
||||
where: { id: installmentId, invoiceId },
|
||||
include: { payment: true },
|
||||
});
|
||||
if (!installment) throw new BadRequestException('قسط معتبر نیست');
|
||||
if (installment.payment) {
|
||||
throw new BadRequestException('برای این قسط قبلاً پرداخت ثبت شده');
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
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',
|
||||
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
||||
notes: emptyToNull(dto.notes ?? null),
|
||||
installmentId,
|
||||
},
|
||||
});
|
||||
if (installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: 'paid',
|
||||
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (invoice.status === 'draft') {
|
||||
await tx.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { status: 'issued', issuedAt: new Date() },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
return this.get(organizationId, invoiceId, actor);
|
||||
}
|
||||
|
||||
async removePayment(
|
||||
organizationId: string,
|
||||
invoiceId: string,
|
||||
paymentId: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<InvoiceDto> {
|
||||
await this.ensureExists(organizationId, invoiceId, actor);
|
||||
const payment = await this.prisma.payment.findFirst({
|
||||
where: { id: paymentId, invoiceId },
|
||||
});
|
||||
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (payment.installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
where: { id: payment.installmentId },
|
||||
data: { status: 'pending', paidAt: null },
|
||||
});
|
||||
}
|
||||
await tx.payment.delete({ where: { id: paymentId } });
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
return this.get(organizationId, invoiceId, actor);
|
||||
}
|
||||
|
||||
private async recomputeStatus(invoiceId: string) {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
lineItems: true,
|
||||
payments: true,
|
||||
},
|
||||
});
|
||||
if (!invoice || invoice.status === 'canceled' || invoice.status === 'draft') {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = invoice.lineItems.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
|
||||
0,
|
||||
);
|
||||
const paid = invoice.payments.reduce(
|
||||
(sum, item) => sum + Number(item.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
let status: 'issued' | 'partially_paid' | 'paid' = 'issued';
|
||||
if (paid <= 0) status = 'issued';
|
||||
else if (paid + 0.0001 >= total && total > 0) status = 'paid';
|
||||
else if (paid > 0) status = 'partially_paid';
|
||||
else status = 'issued';
|
||||
|
||||
if (invoice.status !== status) {
|
||||
await this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private lineCreate(
|
||||
item: DealLineItemInput,
|
||||
index: number,
|
||||
currency: string,
|
||||
) {
|
||||
return {
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
};
|
||||
}
|
||||
|
||||
private installmentCreate(
|
||||
item: InvoiceInstallmentInput,
|
||||
index: number,
|
||||
currency: string,
|
||||
) {
|
||||
return {
|
||||
position: index,
|
||||
dueDate: new Date(item.dueDate),
|
||||
amount: new Prisma.Decimal(item.amount),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
status: 'pending' as const,
|
||||
};
|
||||
}
|
||||
|
||||
private async nextNumber(organizationId: string): Promise<string> {
|
||||
const count = await this.prisma.invoice.count({ where: { organizationId } });
|
||||
return `FA-${String(count + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
) {
|
||||
const found = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
personId: string | null,
|
||||
companyId: string | null,
|
||||
dealId: string | null,
|
||||
quoteId: string | null,
|
||||
) {
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص معتبر نیست');
|
||||
}
|
||||
if (companyId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت معتبر نیست');
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله معتبر نیست');
|
||||
}
|
||||
if (quoteId) {
|
||||
const quote = await this.prisma.quote.findFirst({
|
||||
where: { id: quoteId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!quote) throw new BadRequestException('پیشفاکتور معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertProducts(
|
||||
organizationId: string,
|
||||
lines: DealLineItemInput[],
|
||||
) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
lines
|
||||
.map((l) => emptyToNull(l.productId ?? null))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return;
|
||||
const count = await this.prisma.product.count({
|
||||
where: { organizationId, id: { in: ids } },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از محصولات معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: InvoiceRow): InvoiceDto {
|
||||
const lineItems = row.lineItems.map((item) => {
|
||||
const quantity = Number(item.quantity);
|
||||
const unitPrice = Number(item.unitPrice);
|
||||
return {
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity,
|
||||
unitPrice,
|
||||
currency: item.currency,
|
||||
position: item.position,
|
||||
lineTotal: quantity * unitPrice,
|
||||
};
|
||||
});
|
||||
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const payments: PaymentDto[] = row.payments.map((p) => ({
|
||||
id: p.id,
|
||||
amount: Number(p.amount),
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
paidAt: p.paidAt.toISOString(),
|
||||
notes: p.notes,
|
||||
installmentId: p.installmentId,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
}));
|
||||
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const installments = row.installments.map((item) => ({
|
||||
id: item.id,
|
||||
position: item.position,
|
||||
dueDate: item.dueDate.toISOString(),
|
||||
amount: Number(item.amount),
|
||||
currency: item.currency,
|
||||
status: item.status,
|
||||
paidAt: item.paidAt?.toISOString() ?? null,
|
||||
paymentId: item.payment?.id ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
currency: row.currency,
|
||||
notes: row.notes,
|
||||
issuedAt: row.issuedAt?.toISOString() ?? null,
|
||||
dueDate: row.dueDate?.toISOString() ?? null,
|
||||
totalAmount,
|
||||
paidAmount,
|
||||
remainingAmount: Math.max(0, totalAmount - paidAmount),
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
quote: row.quote,
|
||||
owner: row.owner,
|
||||
lineItems,
|
||||
payments,
|
||||
installments,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
Reference in New Issue
Block a user