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; 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> { 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 { 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 { 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 { 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 { await this.ensureExists(organizationId, id, actor); await this.prisma.invoice.delete({ where: { id } }); } async addPayment( organizationId: string, invoiceId: string, actor: InvoiceActor, dto: CreatePaymentRequest, ): Promise { 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 { 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 { 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(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 }] }, ], }; }