import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { randomInt } from 'crypto'; import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateInvoiceDto, CreateInvoiceItemTemplateDto, CreateInvoiceTemplateDto, InvoiceAccountInputDto, InvoiceItemInputDto, InvoiceKeyPointInputDto, InvoiceTemplateItemInputDto, ListInvoicesDto, UpdateInvoiceDto, UpdateInvoiceItemTemplateDto, UpdateInvoiceStatusDto, UpdateInvoiceTemplateDto, } from './dto/invoice.dto'; /** 12-digit unguessable public link token (not the sequential PK). */ const PUBLIC_ID_MIN = 100_000_000_000; const PUBLIC_ID_MAX = 999_999_999_999; @Injectable() export class InvoicesService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} private async generateUniquePublicId(): Promise { for (let attempt = 0; attempt < 12; attempt += 1) { const candidate = String(randomInt(PUBLIC_ID_MIN, PUBLIC_ID_MAX + 1)); const existing = await this.prisma.invoice.findUnique({ where: { publicId: candidate }, select: { id: true }, }); if (!existing) return candidate; } throw new BadRequestException('Unable to allocate a public invoice id'); } private async assertSuperAdmin(actor: AuthUser) { if (!(await this.permissions.isSuperAdmin(actor.id))) { throw new ForbiddenException('Super admin access required'); } } private serializeTemplate(row: { id: bigint; ownerScope: InvoiceOwnerScope; businessId: bigint | null; title: string; duration: string | null; worktime: string | null; description: string | null; price: Prisma.Decimal; discountedPrice: Prisma.Decimal | null; sortOrder: number; isActive: boolean; createdAt: Date; updatedAt: Date; }) { return { id: row.id.toString(), ownerScope: row.ownerScope, businessId: row.businessId?.toString() ?? null, title: row.title, duration: row.duration, worktime: row.worktime, description: row.description, price: Number(row.price), discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice), sortOrder: row.sortOrder, isActive: row.isActive, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } private readonly invoiceInclude = { business: { select: { id: true, name: true, nameFa: true } }, issuer: { select: { id: true, firstName: true, lastName: true } }, items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }; private readonly invoiceTemplateInclude = { items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }; private serializeKeyPoint(row: { id: bigint; text: string; sortOrder: number; }) { return { id: row.id.toString(), text: row.text, sortOrder: row.sortOrder, }; } private serializeAccount(row: { id: bigint; bankName: string; accountHolderName: string | null; cardNumber: string | null; iban: string | null; sortOrder: number; }) { return { id: row.id.toString(), bankName: row.bankName, accountHolderName: row.accountHolderName, cardNumber: row.cardNumber, iban: row.iban, sortOrder: row.sortOrder, }; } private serializeTemplateItem(row: { id: bigint; templateId: bigint; itemTemplateId: bigint | null; title: string; duration: string | null; worktime: string | null; description: string | null; price: Prisma.Decimal; discountedPrice: Prisma.Decimal | null; sortOrder: number; }) { return { id: row.id.toString(), templateId: row.templateId.toString(), itemTemplateId: row.itemTemplateId?.toString() ?? null, title: row.title, duration: row.duration, worktime: row.worktime, description: row.description, price: Number(row.price), discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice), sortOrder: row.sortOrder, }; } private serializeInvoiceTemplate( row: { id: bigint; ownerScope: InvoiceOwnerScope; businessId: bigint | null; name: string; topText: string | null; sortOrder: number; isActive: boolean; createdAt: Date; updatedAt: Date; items?: Array<{ id: bigint; templateId: bigint; itemTemplateId: bigint | null; title: string; duration: string | null; worktime: string | null; description: string | null; price: Prisma.Decimal; discountedPrice: Prisma.Decimal | null; sortOrder: number; }>; keyPoints?: Array<{ id: bigint; text: string; sortOrder: number; }>; accounts?: Array<{ id: bigint; bankName: string; accountHolderName: string | null; cardNumber: string | null; iban: string | null; sortOrder: number; }>; }, includeNested = true, ) { return { id: row.id.toString(), ownerScope: row.ownerScope, businessId: row.businessId?.toString() ?? null, name: row.name, topText: row.topText, sortOrder: row.sortOrder, isActive: row.isActive, createdAt: row.createdAt, updatedAt: row.updatedAt, items: includeNested && row.items ? row.items.map((item) => this.serializeTemplateItem(item)) : undefined, keyPoints: includeNested && row.keyPoints ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) : undefined, accounts: includeNested && row.accounts ? row.accounts.map((acc) => this.serializeAccount(acc)) : undefined, }; } private serializeItem(row: { id: bigint; invoiceId: bigint; templateId: bigint | null; title: string; duration: string | null; worktime: string | null; description: string | null; price: Prisma.Decimal; discountedPrice: Prisma.Decimal | null; sortOrder: number; }) { return { id: row.id.toString(), invoiceId: row.invoiceId.toString(), templateId: row.templateId?.toString() ?? null, title: row.title, duration: row.duration, worktime: row.worktime, description: row.description, price: Number(row.price), discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice), sortOrder: row.sortOrder, }; } private platformInvoicePublicUrl(publicId: string) { const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim(); if (base) { return `${base.replace(/\/$/, '')}/invoices/${publicId}`; } const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'; return `https://${domain}/invoices/${publicId}`; } private serializeInvoice( row: { id: bigint; publicId: string; businessId: bigint; ownerScope: InvoiceOwnerScope; issuerBusinessId: bigint | null; status: InvoiceStatus; name: string | null; topText: string | null; notes: string | null; invoiceTemplateId: bigint | null; issuedBy: bigint | null; issuedAt: Date; createdAt: Date; updatedAt: Date; business?: { id: bigint; name: string; nameFa: string | null }; issuer?: { id: bigint; firstName: string | null; lastName: string | null } | null; items?: Array<{ id: bigint; invoiceId: bigint; templateId: bigint | null; title: string; duration: string | null; worktime: string | null; description: string | null; price: Prisma.Decimal; discountedPrice: Prisma.Decimal | null; sortOrder: number; }>; keyPoints?: Array<{ id: bigint; text: string; sortOrder: number; }>; accounts?: Array<{ id: bigint; bankName: string; accountHolderName: string | null; cardNumber: string | null; iban: string | null; sortOrder: number; }>; }, includeNested = true, ) { const items = includeNested && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined; const totals = items ? items.reduce( (acc, item) => { const effective = item.discountedPrice !== null && item.discountedPrice < item.price ? item.discountedPrice : item.price; return { subtotal: acc.subtotal + item.price, total: acc.total + effective, }; }, { subtotal: 0, total: 0 }, ) : undefined; return { id: row.id.toString(), publicId: row.publicId, businessId: row.businessId.toString(), ownerScope: row.ownerScope, issuerBusinessId: row.issuerBusinessId?.toString() ?? null, status: row.status, name: row.name, topText: row.topText, notes: row.notes, invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null, publicUrl: row.ownerScope === InvoiceOwnerScope.platform ? this.platformInvoicePublicUrl(row.publicId) : null, issuedBy: row.issuedBy?.toString() ?? null, issuedAt: row.issuedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, business: row.business ? { id: row.business.id.toString(), name: row.business.name, nameFa: row.business.nameFa, } : undefined, issuer: row.issuer ? { id: row.issuer.id.toString(), firstName: row.issuer.firstName, lastName: row.issuer.lastName, } : null, items, keyPoints: includeNested && row.keyPoints ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) : undefined, accounts: includeNested && row.accounts ? row.accounts.map((acc) => this.serializeAccount(acc)) : undefined, subtotal: totals?.subtotal, total: totals?.total, }; } private normalizeKeyPointInput(keyPoint: InvoiceKeyPointInputDto) { const text = keyPoint.text.trim(); if (!text) { throw new BadRequestException('Each key point requires text'); } return { text }; } private normalizeAccountInput(account: InvoiceAccountInputDto) { const bankName = account.bankName.trim(); if (!bankName) { throw new BadRequestException('Each account requires a bank name'); } return { bankName, accountHolderName: account.accountHolderName?.trim() || null, cardNumber: account.cardNumber?.trim() || null, iban: account.iban?.trim() || null, }; } private normalizeTemplateItemInput(item: InvoiceTemplateItemInputDto) { const title = item.title.trim(); if (!title) { throw new BadRequestException('Each invoice template item requires a title'); } const discountedPrice = item.discountedPrice === undefined || item.discountedPrice === null ? null : item.discountedPrice; if (discountedPrice !== null && discountedPrice > item.price) { throw new BadRequestException('Discounted price cannot exceed price'); } return { itemTemplateId: item.itemTemplateId?.trim() ? BigInt(item.itemTemplateId) : null, title, duration: item.duration?.trim() || null, worktime: item.worktime?.trim() || null, description: item.description?.trim() || null, price: item.price, discountedPrice, }; } private normalizeItemInput(item: InvoiceItemInputDto) { const title = item.title.trim(); if (!title) { throw new BadRequestException('Each invoice item requires a title'); } const discountedPrice = item.discountedPrice === undefined || item.discountedPrice === null ? null : item.discountedPrice; if (discountedPrice !== null && discountedPrice > item.price) { throw new BadRequestException('Discounted price cannot exceed price'); } return { templateId: item.templateId?.trim() ? BigInt(item.templateId) : null, title, duration: item.duration?.trim() || null, worktime: item.worktime?.trim() || null, description: item.description?.trim() || null, price: item.price, discountedPrice, }; } // --- Templates (platform settings for now) --- async listPlatformTemplates(actor: AuthUser) { await this.assertSuperAdmin(actor); const rows = await this.prisma.invoiceItemTemplate.findMany({ where: { ownerScope: InvoiceOwnerScope.platform }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return { items: rows.map((row) => this.serializeTemplate(row)) }; } async createPlatformTemplate(dto: CreateInvoiceItemTemplateDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const title = dto.title.trim(); if (!title) { throw new BadRequestException('Title is required'); } const discountedPrice = dto.discountedPrice === undefined || dto.discountedPrice === null ? null : dto.discountedPrice; if (discountedPrice !== null && discountedPrice > dto.price) { throw new BadRequestException('Discounted price cannot exceed price'); } const row = await this.prisma.invoiceItemTemplate.create({ data: { ownerScope: InvoiceOwnerScope.platform, title, duration: dto.duration?.trim() || null, worktime: dto.worktime?.trim() || null, description: dto.description?.trim() || null, price: dto.price, discountedPrice, sortOrder: dto.sortOrder ?? 0, }, }); return this.serializeTemplate(row); } async updatePlatformTemplate( templateIdRaw: string, dto: UpdateInvoiceItemTemplateDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceItemTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, }); if (!existing) { throw new NotFoundException('Invoice item template not found'); } const nextPrice = dto.price ?? Number(existing.price); const nextDiscounted = dto.discountedPrice === undefined ? existing.discountedPrice === null ? null : Number(existing.discountedPrice) : dto.discountedPrice; if (nextDiscounted !== null && nextDiscounted > nextPrice) { throw new BadRequestException('Discounted price cannot exceed price'); } const row = await this.prisma.invoiceItemTemplate.update({ where: { id: templateId }, data: { ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.duration !== undefined ? { duration: dto.duration?.trim() || null } : {}), ...(dto.worktime !== undefined ? { worktime: dto.worktime?.trim() || null } : {}), ...(dto.description !== undefined ? { description: dto.description?.trim() || null } : {}), ...(dto.price !== undefined ? { price: dto.price } : {}), ...(dto.discountedPrice !== undefined ? { discountedPrice: dto.discountedPrice } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), }, }); return this.serializeTemplate(row); } async deletePlatformTemplate(templateIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceItemTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, }); if (!existing) { throw new NotFoundException('Invoice item template not found'); } await this.prisma.invoiceItemTemplate.delete({ where: { id: templateId } }); return { ok: true }; } // --- Invoice templates (platform settings) --- async listPlatformInvoiceTemplates(actor: AuthUser) { await this.assertSuperAdmin(actor); const rows = await this.prisma.invoiceTemplate.findMany({ where: { ownerScope: InvoiceOwnerScope.platform }, include: this.invoiceTemplateInclude, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return { items: rows.map((row) => this.serializeInvoiceTemplate(row)) }; } async getPlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const templateId = BigInt(templateIdRaw); const row = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, include: this.invoiceTemplateInclude, }); if (!row) { throw new NotFoundException('Invoice template not found'); } return this.serializeInvoiceTemplate(row); } async createPlatformInvoiceTemplate(dto: CreateInvoiceTemplateDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const name = dto.name.trim(); if (!name) { throw new BadRequestException('Name is required'); } const items = dto.items.map((item) => this.normalizeTemplateItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const row = await this.prisma.invoiceTemplate.create({ data: { ownerScope: InvoiceOwnerScope.platform, name, topText: dto.topText?.trim() || null, sortOrder: dto.sortOrder ?? 0, items: { create: items.map((item, index) => ({ itemTemplateId: item.itemTemplateId, title: item.title, duration: item.duration, worktime: item.worktime, description: item.description, price: item.price, discountedPrice: item.discountedPrice, sortOrder: index, })), }, keyPoints: { create: keyPoints.map((kp, index) => ({ text: kp.text, sortOrder: index, })), }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, accountHolderName: acc.accountHolderName, cardNumber: acc.cardNumber, iban: acc.iban, sortOrder: index, })), }, }, include: this.invoiceTemplateInclude, }); return this.serializeInvoiceTemplate(row); } async updatePlatformInvoiceTemplate( templateIdRaw: string, dto: UpdateInvoiceTemplateDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, }); if (!existing) { throw new NotFoundException('Invoice template not found'); } const replaceItems = dto.items !== undefined ? dto.items.map((item) => this.normalizeTemplateItemInput(item)) : null; const replaceKeyPoints = dto.keyPoints !== undefined ? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp)) : null; const replaceAccounts = dto.accounts !== undefined ? dto.accounts.map((acc) => this.normalizeAccountInput(acc)) : null; const row = await this.prisma.$transaction(async (tx) => { if (replaceItems !== null) { await tx.invoiceTemplateItem.deleteMany({ where: { templateId } }); } if (replaceKeyPoints !== null) { await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } }); } if (replaceAccounts !== null) { await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } }); } return tx.invoiceTemplate.update({ where: { id: templateId }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...(replaceItems !== null ? { items: { create: replaceItems.map((item, index) => ({ itemTemplateId: item.itemTemplateId, title: item.title, duration: item.duration, worktime: item.worktime, description: item.description, price: item.price, discountedPrice: item.discountedPrice, sortOrder: index, })), }, } : {}), ...(replaceKeyPoints !== null ? { keyPoints: { create: replaceKeyPoints.map((kp, index) => ({ text: kp.text, sortOrder: index, })), }, } : {}), ...(replaceAccounts !== null ? { accounts: { create: replaceAccounts.map((acc, index) => ({ bankName: acc.bankName, accountHolderName: acc.accountHolderName, cardNumber: acc.cardNumber, iban: acc.iban, sortOrder: index, })), }, } : {}), }, include: this.invoiceTemplateInclude, }); }); return this.serializeInvoiceTemplate(row); } async deletePlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, }); if (!existing) { throw new NotFoundException('Invoice template not found'); } await this.prisma.invoiceTemplate.delete({ where: { id: templateId } }); return { ok: true }; } // --- Public invoice viewer (platform) --- async getPublicInvoice(publicIdRaw: string) { const publicId = publicIdRaw.trim(); if (!/^\d{6,32}$/.test(publicId)) { throw new NotFoundException('Invoice not found'); } const row = await this.prisma.invoice.findFirst({ where: { publicId, ownerScope: InvoiceOwnerScope.platform, status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] }, }, include: this.invoiceInclude, }); if (!row) { throw new NotFoundException('Invoice not found'); } const serialized = this.serializeInvoice(row, true); // Public payload: no internal notes / issuer / sequential id return { publicId: serialized.publicId, status: serialized.status, name: serialized.name, topText: serialized.topText, issuedAt: serialized.issuedAt, business: serialized.business, items: serialized.items, keyPoints: serialized.keyPoints, accounts: serialized.accounts, subtotal: serialized.subtotal, total: serialized.total, publicUrl: serialized.publicUrl, }; } // --- Invoices for a business --- async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { id: true }, }); if (!business) { throw new NotFoundException('Business not found'); } const page = query.page ?? 1; const pageSize = Math.min(query.pageSize ?? 20, 100); const where: Prisma.InvoiceWhereInput = { businessId, ownerScope: InvoiceOwnerScope.platform, ...(query.status ? { status: query.status } : {}), }; const [total, rows] = await this.prisma.$transaction([ this.prisma.invoice.count({ where }), this.prisma.invoice.findMany({ where, include: this.invoiceInclude, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), ]); return { items: rows.map((row) => this.serializeInvoice(row)), page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }; } async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const row = await this.prisma.invoice.findFirst({ where: { id: invoiceId, businessId, ownerScope: InvoiceOwnerScope.platform, }, include: this.invoiceInclude, }); if (!row) { throw new NotFoundException('Invoice not found'); } return this.serializeInvoice(row); } async createForBusiness(businessIdRaw: string, dto: CreateInvoiceDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { id: true }, }); if (!business) { throw new NotFoundException('Business not found'); } const items = dto.items.map((item) => this.normalizeItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const invoiceTemplateId = dto.invoiceTemplateId?.trim() ? BigInt(dto.invoiceTemplateId) : null; if (invoiceTemplateId !== null) { const template = await this.prisma.invoiceTemplate.findFirst({ where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform }, select: { id: true }, }); if (!template) { throw new BadRequestException('Invoice template not found'); } } const row = await this.prisma.invoice.create({ data: { publicId: await this.generateUniquePublicId(), businessId, ownerScope: InvoiceOwnerScope.platform, status: dto.status ?? InvoiceStatus.issued, name: dto.name?.trim() || null, topText: dto.topText?.trim() || null, notes: dto.notes?.trim() || null, invoiceTemplateId, issuedBy: actor.id, items: { create: items.map((item, index) => ({ templateId: item.templateId, title: item.title, duration: item.duration, worktime: item.worktime, description: item.description, price: item.price, discountedPrice: item.discountedPrice, sortOrder: index, })), }, keyPoints: { create: keyPoints.map((kp, index) => ({ text: kp.text, sortOrder: index, })), }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, accountHolderName: acc.accountHolderName, cardNumber: acc.cardNumber, iban: acc.iban, sortOrder: index, })), }, }, include: this.invoiceInclude, }); return this.serializeInvoice(row); } async updateStatus( businessIdRaw: string, invoiceIdRaw: string, dto: UpdateInvoiceStatusDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, businessId, ownerScope: InvoiceOwnerScope.platform, }, }); if (!existing) { throw new NotFoundException('Invoice not found'); } if ( existing.status === InvoiceStatus.approved && dto.status !== InvoiceStatus.approved && dto.status !== InvoiceStatus.paid && dto.status !== InvoiceStatus.cancelled ) { throw new BadRequestException( 'Approved invoices can only be marked paid or cancelled', ); } const row = await this.prisma.invoice.update({ where: { id: invoiceId }, data: { status: dto.status, ...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}), }, include: this.invoiceInclude, }); return this.serializeInvoice(row); } async updateContent( businessIdRaw: string, invoiceIdRaw: string, dto: UpdateInvoiceDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, businessId, ownerScope: InvoiceOwnerScope.platform, }, }); if (!existing) { throw new NotFoundException('Invoice not found'); } if (existing.status === InvoiceStatus.approved) { throw new BadRequestException('Approved invoices cannot be edited'); } const items = dto.items.map((item) => this.normalizeItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); let invoiceTemplateId: bigint | null | undefined; if (dto.invoiceTemplateId === null) { invoiceTemplateId = null; } else if (dto.invoiceTemplateId !== undefined) { const trimmed = dto.invoiceTemplateId.trim(); invoiceTemplateId = trimmed ? BigInt(trimmed) : null; if (invoiceTemplateId !== null) { const template = await this.prisma.invoiceTemplate.findFirst({ where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform }, select: { id: true }, }); if (!template) { throw new BadRequestException('Invoice template not found'); } } } const row = await this.prisma.$transaction(async (tx) => { await tx.invoiceItem.deleteMany({ where: { invoiceId } }); await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } }); await tx.invoiceAccount.deleteMany({ where: { invoiceId } }); return tx.invoice.update({ where: { id: invoiceId }, data: { ...(dto.name !== undefined ? { name: dto.name?.trim() || null } : {}), ...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}), ...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}), ...(invoiceTemplateId !== undefined ? { invoiceTemplateId } : {}), items: { create: items.map((item, index) => ({ templateId: item.templateId, title: item.title, duration: item.duration, worktime: item.worktime, description: item.description, price: item.price, discountedPrice: item.discountedPrice, sortOrder: index, })), }, keyPoints: { create: keyPoints.map((kp, index) => ({ text: kp.text, sortOrder: index, })), }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, accountHolderName: acc.accountHolderName, cardNumber: acc.cardNumber, iban: acc.iban, sortOrder: index, })), }, }, include: this.invoiceInclude, }); }); return this.serializeInvoice(row); } async approvePublicInvoice(publicIdRaw: string) { const publicId = publicIdRaw.trim(); if (!/^\d{6,32}$/.test(publicId)) { throw new NotFoundException('Invoice not found'); } const existing = await this.prisma.invoice.findFirst({ where: { publicId, ownerScope: InvoiceOwnerScope.platform, }, }); if ( !existing || (existing.status !== InvoiceStatus.issued && existing.status !== InvoiceStatus.approved && existing.status !== InvoiceStatus.paid) ) { throw new NotFoundException('Invoice not found'); } if (existing.status === InvoiceStatus.approved || existing.status === InvoiceStatus.paid) { const row = await this.prisma.invoice.findFirst({ where: { id: existing.id }, include: this.invoiceInclude, }); if (!row) throw new NotFoundException('Invoice not found'); const serialized = this.serializeInvoice(row, true); return { publicId: serialized.publicId, status: serialized.status, name: serialized.name, topText: serialized.topText, issuedAt: serialized.issuedAt, business: serialized.business, items: serialized.items, keyPoints: serialized.keyPoints, accounts: serialized.accounts, subtotal: serialized.subtotal, total: serialized.total, publicUrl: serialized.publicUrl, }; } const row = await this.prisma.invoice.update({ where: { id: existing.id }, data: { status: InvoiceStatus.approved }, include: this.invoiceInclude, }); const serialized = this.serializeInvoice(row, true); return { publicId: serialized.publicId, status: serialized.status, name: serialized.name, topText: serialized.topText, issuedAt: serialized.issuedAt, business: serialized.business, items: serialized.items, keyPoints: serialized.keyPoints, accounts: serialized.accounts, subtotal: serialized.subtotal, total: serialized.total, publicUrl: serialized.publicUrl, }; } async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, businessId, ownerScope: InvoiceOwnerScope.platform, }, }); if (!existing) { throw new NotFoundException('Invoice not found'); } await this.prisma.invoice.delete({ where: { id: invoiceId } }); return { ok: true }; } }