Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization. Co-authored-by: Cursor <cursoragent@cursor.com>
951 lines
30 KiB
TypeScript
951 lines
30 KiB
TypeScript
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 { buildSearchWhere, ci } from '../common/search';
|
|
import { type Actor, ownerWhere } from '../common/record-scope';
|
|
import { AutomationsService } from '../automations/automations.service';
|
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
|
import { InventoryService } from '../inventory/inventory.service';
|
|
import { CommissionsService } from '../finance/commissions.service';
|
|
import { GamificationService } from '../gamification/gamification.service';
|
|
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
|
|
|
|
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 },
|
|
include: { cheque: true },
|
|
},
|
|
commissionEntry: true,
|
|
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,
|
|
private readonly automations: AutomationsService,
|
|
private readonly messageAutomations: MessageAutomationsService,
|
|
private readonly inventory: InventoryService,
|
|
private readonly commissions: CommissionsService,
|
|
private readonly game: GamificationService,
|
|
private readonly treasury: TreasuryBridgeService,
|
|
) {}
|
|
|
|
async list(
|
|
organizationId: string,
|
|
actor: InvoiceActor,
|
|
opts: {
|
|
q?: string;
|
|
status?: string;
|
|
kind?: string;
|
|
dealId?: string;
|
|
personId?: string;
|
|
companyId?: string;
|
|
quoteId?: string;
|
|
from?: string;
|
|
to?: string;
|
|
amountMin?: number;
|
|
amountMax?: number;
|
|
page?: number;
|
|
limit?: number;
|
|
},
|
|
): Promise<PaginatedResponse<InvoiceDto>> {
|
|
const page = opts.page ?? 1;
|
|
const limit = opts.limit ?? 50;
|
|
const dateFilter = buildDateRangeFilter(opts.from, opts.to);
|
|
const where: Prisma.InvoiceWhereInput = {
|
|
organizationId,
|
|
...ownerWhere(actor, 'finance'),
|
|
...(opts.status ? { status: opts.status as never } : {}),
|
|
...(opts.kind ? { kind: opts.kind } : {}),
|
|
...(opts.dealId ? { dealId: opts.dealId } : {}),
|
|
...(opts.personId ? { personId: opts.personId } : {}),
|
|
...(opts.companyId ? { companyId: opts.companyId } : {}),
|
|
...(opts.quoteId ? { quoteId: opts.quoteId } : {}),
|
|
...(dateFilter ? dateFilter : {}),
|
|
...invoiceSearchWhere(opts.q),
|
|
};
|
|
|
|
const hasAmountFilter =
|
|
opts.amountMin != null || opts.amountMax != null;
|
|
|
|
if (hasAmountFilter) {
|
|
const rows = await this.prisma.invoice.findMany({
|
|
where,
|
|
include,
|
|
orderBy: { updatedAt: 'desc' },
|
|
});
|
|
const filtered = rows
|
|
.map((row) => this.toDto(row))
|
|
.filter((dto) => {
|
|
const amount = Math.abs(dto.payableAmount);
|
|
if (opts.amountMin != null && amount < opts.amountMin) return false;
|
|
if (opts.amountMax != null && amount > opts.amountMax) return false;
|
|
return true;
|
|
});
|
|
const total = filtered.length;
|
|
const items = filtered.slice((page - 1) * limit, page * limit);
|
|
return { items, total, page, limit };
|
|
}
|
|
|
|
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, 'finance') },
|
|
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 kind = dto.kind === 'credit' ? 'credit' : 'invoice';
|
|
|
|
const row = await this.prisma.invoice.create({
|
|
data: {
|
|
organizationId,
|
|
number,
|
|
title,
|
|
status,
|
|
kind,
|
|
currency,
|
|
notes,
|
|
discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0),
|
|
taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0),
|
|
issuedAt,
|
|
dueDate,
|
|
personId,
|
|
companyId,
|
|
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: {
|
|
create: lineItems.map((item, index) =>
|
|
this.lineCreate(item, index, currency),
|
|
),
|
|
},
|
|
}
|
|
: {}),
|
|
...(installments.length
|
|
? {
|
|
installments: {
|
|
create: installments.map((item, index) =>
|
|
this.installmentCreate(item, index, currency),
|
|
),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
include,
|
|
});
|
|
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);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
async update(
|
|
organizationId: string,
|
|
id: string,
|
|
actor: InvoiceActor,
|
|
dto: UpdateInvoiceRequest,
|
|
): Promise<InvoiceDto> {
|
|
const existing = await this.prisma.invoice.findFirst({
|
|
where: { id, organizationId, ...ownerWhere(actor, 'finance') },
|
|
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 (dto.kind !== undefined) {
|
|
data.kind = dto.kind === 'credit' ? 'credit' : 'invoice';
|
|
}
|
|
if (dto.discountAmount !== undefined) {
|
|
data.discountAmount = new Prisma.Decimal(dto.discountAmount);
|
|
}
|
|
if (dto.taxPercent !== undefined) {
|
|
data.taxPercent = new Prisma.Decimal(dto.taxPercent);
|
|
}
|
|
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 ('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 &&
|
|
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);
|
|
}
|
|
|
|
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' &&
|
|
row.status !== 'canceled';
|
|
if (leftDraft) {
|
|
await this.fireIssued(organizationId, row);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
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, 'finance') },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
currency: true,
|
|
organizationId: true,
|
|
personId: true,
|
|
ownerId: 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({
|
|
where: { id: installmentId, invoiceId },
|
|
include: { payment: true },
|
|
});
|
|
if (!installment) throw new BadRequestException('قسط معتبر نیست');
|
|
if (installment.payment) {
|
|
throw new BadRequestException('برای این قسط قبلاً پرداخت ثبت شده');
|
|
}
|
|
}
|
|
|
|
const paymentId = await this.prisma.$transaction(async (tx) => {
|
|
const payment = await tx.payment.create({
|
|
data: {
|
|
invoiceId,
|
|
amount: new Prisma.Decimal(dto.amount),
|
|
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
|
|
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,
|
|
personId: invoice.personId,
|
|
ownerId: invoice.ownerId,
|
|
amount: new Prisma.Decimal(dto.amount),
|
|
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
|
|
number: dto.cheque.number.trim(),
|
|
bank: dto.cheque.bank.trim(),
|
|
dueDate: new Date(dto.cheque.dueDate),
|
|
direction: 'in',
|
|
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
|
|
? dto.cheque.status
|
|
: 'pending',
|
|
notes: emptyToNull(dto.cheque.notes ?? null),
|
|
financialAccountId: dto.financialAccountId || null,
|
|
},
|
|
});
|
|
}
|
|
if (dto.financialAccountId) {
|
|
await tx.payment.update({
|
|
where: { id: payment.id },
|
|
data: { financialAccountId: dto.financialAccountId },
|
|
});
|
|
}
|
|
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() },
|
|
});
|
|
}
|
|
return payment.id;
|
|
});
|
|
|
|
const accountId = dto.financialAccountId || null;
|
|
const chequePending = method === 'check' && dto.cheque?.status !== 'cleared';
|
|
if (accountId && !chequePending) {
|
|
const linkedCheque = method === 'check'
|
|
? await this.prisma.cheque.findFirst({ where: { paymentId }, select: { id: true } })
|
|
: null;
|
|
await this.treasury.postCash({
|
|
organizationId,
|
|
accountId,
|
|
amount: dto.amount,
|
|
direction: 'in',
|
|
kind: 'receipt',
|
|
occurredAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
|
memo: 'پرداخت فاکتور',
|
|
actorUserId: actor.userId,
|
|
paymentId,
|
|
chequeId: linkedCheque?.id ?? null,
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
const paidCtx = {
|
|
organizationId,
|
|
entityType: 'invoice' as const,
|
|
entityId: row.id,
|
|
title: row.title || row.number,
|
|
personId: row.person?.id ?? null,
|
|
companyId: row.company?.id ?? null,
|
|
dealId: row.deal?.id ?? null,
|
|
ownerId: row.owner?.id ?? null,
|
|
};
|
|
await this.automations.runTrigger('invoice_paid', paidCtx);
|
|
await this.messageAutomations.onEvent('invoice_paid', paidCtx);
|
|
this.game.emit({
|
|
organizationId,
|
|
userId: actor.userId,
|
|
event: 'PAYMENT_REGISTERED',
|
|
entityType: 'invoice',
|
|
entityId: row.id,
|
|
entityTitle: paidCtx.title,
|
|
eventKey: paymentId,
|
|
facts: { amount: dto.amount },
|
|
});
|
|
return row;
|
|
}
|
|
|
|
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.treasury.voidPayment(paymentId);
|
|
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);
|
|
await this.commissions.syncInvoice(organizationId, 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 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 total = Math.abs(
|
|
roundMoney(subtotal - discountAmount + taxAmount),
|
|
);
|
|
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 fireIssued(organizationId: string, row: InvoiceDto) {
|
|
const ctx = {
|
|
organizationId,
|
|
entityType: 'invoice' as const,
|
|
entityId: row.id,
|
|
title: row.title || row.number,
|
|
personId: row.person?.id ?? null,
|
|
companyId: row.company?.id ?? null,
|
|
dealId: row.deal?.id ?? null,
|
|
ownerId: row.owner?.id ?? null,
|
|
};
|
|
await this.automations.runTrigger('invoice_issued', ctx);
|
|
await this.messageAutomations.onEvent('invoice_issued', ctx);
|
|
this.game.emit({
|
|
organizationId,
|
|
userId: row.owner?.id,
|
|
event: 'INVOICE_ISSUED',
|
|
entityType: 'invoice',
|
|
entityId: row.id,
|
|
entityTitle: ctx.title,
|
|
facts: { amount: row.payableAmount },
|
|
});
|
|
}
|
|
|
|
private async ensureExists(
|
|
organizationId: string,
|
|
id: string,
|
|
actor: InvoiceActor,
|
|
) {
|
|
const found = await this.prisma.invoice.findFirst({
|
|
where: { id, organizationId, ...ownerWhere(actor, 'finance') },
|
|
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 subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
|
const discountAmount = Number(row.discountAmount ?? 0);
|
|
const taxPercent = Number(row.taxPercent ?? 0);
|
|
const taxAmount = roundMoney(
|
|
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
|
|
);
|
|
const signedPayable = roundMoney(subtotal - discountAmount + taxAmount);
|
|
const kind = row.kind === 'credit' ? 'credit' : 'invoice';
|
|
const payableAmount = kind === 'credit' ? -signedPayable : signedPayable;
|
|
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,
|
|
cheque: p.cheque
|
|
? {
|
|
id: p.cheque.id,
|
|
paymentId: p.id,
|
|
invoiceId: row.id,
|
|
invoiceNumber: row.number,
|
|
invoiceTitle: row.title,
|
|
personId: row.person?.id ?? p.cheque.personId ?? null,
|
|
personName: row.person
|
|
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
|
: row.company?.name ?? null,
|
|
amount: Number(p.cheque.amount ?? p.amount),
|
|
currency: p.cheque.currency || p.currency,
|
|
number: p.cheque.number,
|
|
bank: p.cheque.bank,
|
|
dueDate: p.cheque.dueDate.toISOString(),
|
|
status: p.cheque.status,
|
|
direction: p.cheque.direction === 'out' ? 'out' : 'in',
|
|
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);
|
|
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,
|
|
kind,
|
|
currency: row.currency,
|
|
notes: row.notes,
|
|
discountAmount,
|
|
taxPercent,
|
|
issuedAt: row.issuedAt?.toISOString() ?? null,
|
|
dueDate: row.dueDate?.toISOString() ?? null,
|
|
totalAmount: subtotal,
|
|
subtotal,
|
|
taxAmount,
|
|
payableAmount,
|
|
paidAmount,
|
|
remainingAmount: Math.max(0, Math.abs(payableAmount) - 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,
|
|
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(),
|
|
};
|
|
}
|
|
}
|
|
|
|
function emptyToNull(value?: string | null): string | null {
|
|
if (value == null) return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length ? trimmed : null;
|
|
}
|
|
|
|
function roundMoney(value: number): number {
|
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
}
|
|
|
|
function invoiceSearchWhere(q?: string): Prisma.InvoiceWhereInput {
|
|
const search = buildSearchWhere<Prisma.InvoiceWhereInput>(q, (t) => [
|
|
{ title: ci(t) },
|
|
{ number: ci(t) },
|
|
{ notes: ci(t) },
|
|
{
|
|
person: {
|
|
OR: [{ firstName: ci(t) }, { lastName: ci(t) }, { mobile: { contains: t } }],
|
|
},
|
|
},
|
|
{ company: { name: ci(t) } },
|
|
]);
|
|
return search ? { AND: [search] } : {};
|
|
}
|
|
|
|
function buildDateRangeFilter(
|
|
from?: string,
|
|
to?: string,
|
|
): Prisma.InvoiceWhereInput | null {
|
|
if (!from && !to) return null;
|
|
const range: Prisma.DateTimeFilter = {};
|
|
if (from) range.gte = new Date(from);
|
|
if (to) range.lte = new Date(to);
|
|
return {
|
|
OR: [
|
|
{ issuedAt: range },
|
|
{ AND: [{ issuedAt: null }, { createdAt: range }] },
|
|
],
|
|
};
|
|
}
|