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'; import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; import type { DashboardLocale } from '../business-settings/business-settings.types'; /** 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; function escapeHtmlAttr(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function stripHtmlToText(html: string): string { return html .replace(//gi, ' ') .replace(/<\/p>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\s+/g, ' ') .trim(); } @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 async assertPermission(businessId: bigint, userId: bigint, permission: string) { const allowed = await this.permissions.hasBusinessPermission(userId, businessId, permission); if (!allowed) { throw new ForbiddenException(`Missing permission: ${permission} for this business`); } } private async assertBusinessExists(businessId: bigint) { const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { id: true }, }); if (!business) { throw new NotFoundException('Business not found'); } } /** * Prefer business scope when the actor is a member of this business (business dashboard). * Super admins without membership use platform scope (super-admin → business billing). */ private async resolveInvoiceAccess( actor: AuthUser, businessId: bigint, ): Promise<{ mode: 'platform' } | { mode: 'business' }> { const membership = await this.prisma.businessUser.findUnique({ where: { businessId_userId: { businessId, userId: actor.id } }, select: { id: true }, }); if (membership) { return { mode: 'business' }; } if (await this.permissions.isSuperAdmin(actor.id)) { return { mode: 'platform' }; } return { mode: 'business' }; } private invoiceScopeWhere( businessId: bigint, mode: 'platform' | 'business', ): Prisma.InvoiceWhereInput { return mode === 'platform' ? { businessId, ownerScope: InvoiceOwnerScope.platform } : { ownerScope: InvoiceOwnerScope.business, OR: [{ issuerBusinessId: businessId }, { businessId }], }; } private templateScopeWhere( businessId: bigint, scope: InvoiceOwnerScope, ): Prisma.InvoiceTemplateWhereInput { return scope === InvoiceOwnerScope.platform ? { ownerScope: InvoiceOwnerScope.platform } : { ownerScope: InvoiceOwnerScope.business, businessId }; } /** * Resolves the user an invoice is billed to. * - Explicit userId: must exist; in business mode must be a business_customer or business_user of businessId. * - Missing in platform mode: defaults to the business owner, else any business_user. * - Missing in business mode: userId is required. */ private async resolveBilledUserId( businessId: bigint, dtoUserId: string | undefined, mode: 'platform' | 'business', ): Promise { if (dtoUserId !== undefined && dtoUserId.trim()) { const userId = BigInt(dtoUserId.trim()); const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true }, }); if (!user) { throw new BadRequestException('User not found'); } if (mode === 'business') { const [businessUser, businessCustomer] = await Promise.all([ this.prisma.businessUser.findUnique({ where: { businessId_userId: { businessId, userId } }, select: { id: true }, }), this.prisma.businessCustomer.findUnique({ where: { businessId_userId: { businessId, userId } }, select: { id: true }, }), ]); if (!businessUser && !businessCustomer) { throw new BadRequestException('User is not associated with this business'); } } return userId; } if (mode === 'business') { throw new BadRequestException('userId is required'); } const owner = await this.prisma.businessUser.findFirst({ where: { businessId, isOwner: true }, orderBy: { userId: 'asc' }, select: { userId: true }, }); if (owner) { return owner.userId; } const anyMember = await this.prisma.businessUser.findFirst({ where: { businessId }, orderBy: { userId: 'asc' }, select: { userId: true }, }); if (!anyMember) { throw new BadRequestException('Business has no users to bill'); } return anyMember.userId; } 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, settings: true, domains: { where: { isActive: true }, orderBy: [{ isPrimary: 'desc' as const }, { createdAt: 'asc' as const }], take: 1, select: { host: true }, }, }, }, issuer: { select: { id: true, firstName: true, lastName: true } }, user: { select: { id: true, firstName: true, lastName: true, cellNumber: 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 invoicePublicUrl( publicId: string, opts?: { ownerScope?: InvoiceOwnerScope; businessHost?: string | null }, ) { const normalizeHost = (raw: string) => raw .trim() .replace(/^https?:\/\//i, '') .replace(/\/$/, ''); // Local / explicit override (includes port), e.g. https://meshkee.app:5174 const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim(); if (base) { return `${base.replace(/\/$/, '')}/invoices/${publicId}`; } if (opts?.ownerScope === InvoiceOwnerScope.business) { const host = opts.businessHost ? normalizeHost(opts.businessHost) : ''; if (host) { // Dev-only: append port so links open the local public viewer. const devPort = process.env.INVOICE_PUBLIC_DEV_PORT?.trim(); if (process.env.NODE_ENV !== 'production' && devPort) { const protocol = process.env.INVOICE_PUBLIC_DEV_PROTOCOL?.trim() || 'https'; const devHost = process.env.INVOICE_PUBLIC_DEV_HOST?.trim() || host; return `${protocol}://${normalizeHost(devHost)}:${devPort}/invoices/${publicId}`; } return `https://${host}/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; userId: bigint; issuedAt: Date; createdAt: Date; updatedAt: Date; business?: { id: bigint; name: string; nameFa: string | null; settings?: Prisma.JsonValue; domains?: Array<{ host: string }>; }; issuer?: { id: bigint; firstName: string | null; lastName: string | null } | null; user?: { id: bigint; firstName: string | null; lastName: string | null; cellNumber: string; } | 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: this.invoicePublicUrl(row.publicId, { ownerScope: row.ownerScope, businessHost: row.business?.domains?.[0]?.host ?? null, }), issuedBy: row.issuedBy?.toString() ?? null, userId: row.userId.toString(), 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, user: row.user ? { id: row.user.id.toString(), firstName: row.user.firstName, lastName: row.user.lastName, cell: row.user.cellNumber, } : 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 }; } // --- Item templates (business-scoped) --- async listBusinessItemTemplates(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.read'); const rows = await this.prisma.invoiceItemTemplate.findMany({ where: { ownerScope: InvoiceOwnerScope.business, businessId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return { items: rows.map((row) => this.serializeTemplate(row)) }; } async createBusinessItemTemplate( businessIdRaw: string, dto: CreateInvoiceItemTemplateDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); 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.business, businessId, 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 updateBusinessItemTemplate( businessIdRaw: string, templateIdRaw: string, dto: UpdateInvoiceItemTemplateDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceItemTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId }, }); 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 deleteBusinessItemTemplate(businessIdRaw: string, templateIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceItemTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId }, }); if (!existing) { throw new NotFoundException('Invoice item template not found'); } await this.prisma.invoiceItemTemplate.delete({ where: { id: templateId } }); return { ok: true }; } // --- Invoice templates (business-scoped) --- async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.read'); const rows = await this.prisma.invoiceTemplate.findMany({ where: { ownerScope: InvoiceOwnerScope.business, businessId }, include: this.invoiceTemplateInclude, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return { items: rows.map((row) => this.serializeInvoiceTemplate(row)) }; } async getBusinessInvoiceTemplate( businessIdRaw: string, templateIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.read'); const templateId = BigInt(templateIdRaw); const row = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId }, include: this.invoiceTemplateInclude, }); if (!row) { throw new NotFoundException('Invoice template not found'); } return this.serializeInvoiceTemplate(row); } async createBusinessInvoiceTemplate( businessIdRaw: string, dto: CreateInvoiceTemplateDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); 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.business, businessId, 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 updateBusinessInvoiceTemplate( businessIdRaw: string, templateIdRaw: string, dto: UpdateInvoiceTemplateDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId }, }); 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 deleteBusinessInvoiceTemplate( businessIdRaw: string, templateIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); await this.assertPermission(businessId, actor.id, 'invoice_templates.manage'); const templateId = BigInt(templateIdRaw); const existing = await this.prisma.invoiceTemplate.findFirst({ where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId }, }); if (!existing) { throw new NotFoundException('Invoice template not found'); } await this.prisma.invoiceTemplate.delete({ where: { id: templateId } }); return { ok: true }; } // --- Public invoice viewer (platform + business) --- /** Public payload: no internal notes / issuer / sequential id / user contact info. */ private toPublicInvoicePayload(row: Parameters[0]) { const serialized = this.serializeInvoice(row, true); const locale: DashboardLocale = normalizeBusinessSettings( row.business && 'settings' in row.business ? row.business.settings : null, ).branding.defaultLocale; return { publicId: serialized.publicId, status: serialized.status, name: serialized.name, topText: serialized.topText, issuedAt: serialized.issuedAt, locale, business: serialized.business ? { ...serialized.business, displayName: locale === 'fa' && serialized.business.nameFa?.trim() ? serialized.business.nameFa.trim() : serialized.business.name, } : undefined, user: serialized.user ? { firstName: serialized.user.firstName, lastName: serialized.user.lastName } : null, items: serialized.items, keyPoints: serialized.keyPoints, accounts: serialized.accounts, subtotal: serialized.subtotal, total: serialized.total, publicUrl: serialized.publicUrl, }; } 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, status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] }, }, include: this.invoiceInclude, }); if (!row) { throw new NotFoundException('Invoice not found'); } return this.toPublicInvoicePayload(row); } /** HTML document for social crawlers (WhatsApp, etc.) — Open Graph + redirect to SPA. */ async buildPublicInvoiceOgHtml(publicIdRaw: string): Promise { const invoice = await this.getPublicInvoice(publicIdRaw); const title = escapeHtmlAttr( (invoice.name?.trim() || `Invoice ${invoice.publicId}`).slice(0, 120), ); const businessName = (invoice.business && 'displayName' in invoice.business && typeof invoice.business.displayName === 'string' && invoice.business.displayName.trim()) || invoice.business?.nameFa?.trim() || invoice.business?.name?.trim() || 'Meshkee'; const totalLabel = typeof invoice.total === 'number' ? `${invoice.total.toLocaleString('en-US')} IRT` : ''; const fromTop = stripHtmlToText(invoice.topText ?? '').slice(0, 140); const description = escapeHtmlAttr( fromTop || [businessName, totalLabel].filter(Boolean).join(' · ') || 'Meshkee invoice', ); const pageUrl = escapeHtmlAttr( invoice.publicUrl || `https://${process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'}/invoices/${invoice.publicId}`, ); const imageUrl = escapeHtmlAttr( process.env.INVOICE_OG_IMAGE_URL?.trim() || 'https://manage.meshkee.com/og-invoice.png', ); const locale = invoice.locale === 'en' ? 'en_US' : 'fa_IR'; return ` ${title}

${title}

`; } // --- Invoices for a business --- async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.read'); } const page = query.page ?? 1; const pageSize = Math.min(query.pageSize ?? 20, 100); const where: Prisma.InvoiceWhereInput = { ...this.invoiceScopeWhere(businessId, access.mode), ...(query.status ? { status: query.status } : {}), ...(query.userId ? { userId: BigInt(query.userId) } : {}), }; 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) { const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.read'); } const row = await this.prisma.invoice.findFirst({ where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) }, include: this.invoiceInclude, }); if (!row) { throw new NotFoundException('Invoice not found'); } return this.serializeInvoice(row); } async createForBusiness(businessIdRaw: string, dto: CreateInvoiceDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertBusinessExists(businessId); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.create'); } const userId = await this.resolveBilledUserId(businessId, dto.userId, access.mode); 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; const ownerScope = access.mode === 'platform' ? InvoiceOwnerScope.platform : InvoiceOwnerScope.business; let resolvedInvoiceTemplateId: bigint | null = null; if (invoiceTemplateId !== null) { const template = await this.prisma.invoiceTemplate.findFirst({ where: { id: invoiceTemplateId, ...this.templateScopeWhere(businessId, ownerScope) }, select: { id: true }, }); // Content is already snapshotted; drop stale / out-of-scope template refs. resolvedInvoiceTemplateId = template?.id ?? null; } const row = await this.prisma.invoice.create({ data: { publicId: await this.generateUniquePublicId(), businessId, ownerScope, issuerBusinessId: access.mode === 'business' ? businessId : null, userId, status: dto.status ?? InvoiceStatus.issued, name: dto.name?.trim() || null, topText: dto.topText?.trim() || null, notes: dto.notes?.trim() || null, invoiceTemplateId: resolvedInvoiceTemplateId, 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, ) { const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.update'); } const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) }, }); 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, ) { const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.update'); } const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) }, }); 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 userId: bigint | undefined; if (dto.userId !== undefined) { userId = await this.resolveBilledUserId(businessId, dto.userId, access.mode); } 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, ...this.templateScopeWhere(businessId, existing.ownerScope), }, select: { id: true }, }); // Content is already snapshotted; drop stale / out-of-scope template refs. invoiceTemplateId = template?.id ?? null; } } 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: { ...(userId !== undefined ? { userId } : {}), ...(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 }, }); 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'); return this.toPublicInvoicePayload(row); } const row = await this.prisma.invoice.update({ where: { id: existing.id }, data: { status: InvoiceStatus.approved }, include: this.invoiceInclude, }); return this.toPublicInvoicePayload(row); } async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const invoiceId = BigInt(invoiceIdRaw); const access = await this.resolveInvoiceAccess(actor, businessId); if (access.mode === 'business') { await this.assertPermission(businessId, actor.id, 'invoices.delete'); } const existing = await this.prisma.invoice.findFirst({ where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) }, }); if (!existing) { throw new NotFoundException('Invoice not found'); } await this.prisma.invoice.delete({ where: { id: invoiceId } }); return { ok: true }; } }