WhatsApp and other crawlers can scrape invoice title and image without running the SPA. Co-authored-by: Cursor <cursoragent@cursor.com>
1733 lines
56 KiB
TypeScript
1733 lines
56 KiB
TypeScript
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, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
function stripHtmlToText(html: string): string {
|
|
return html
|
|
.replace(/<br\s*\/?>/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<string> {
|
|
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<bigint> {
|
|
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<InvoicesService['serializeInvoice']>[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<string> {
|
|
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 `<!DOCTYPE html>
|
|
<html lang="${invoice.locale === 'en' ? 'en' : 'fa'}">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${title}</title>
|
|
<meta name="description" content="${description}" />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:site_name" content="Meshkee" />
|
|
<meta property="og:locale" content="${locale}" />
|
|
<meta property="og:title" content="${title}" />
|
|
<meta property="og:description" content="${description}" />
|
|
<meta property="og:url" content="${pageUrl}" />
|
|
<meta property="og:image" content="${imageUrl}" />
|
|
<meta name="twitter:card" content="summary" />
|
|
<meta name="twitter:title" content="${title}" />
|
|
<meta name="twitter:description" content="${description}" />
|
|
<meta name="twitter:image" content="${imageUrl}" />
|
|
<link rel="canonical" href="${pageUrl}" />
|
|
<meta http-equiv="refresh" content="0;url=${pageUrl}" />
|
|
</head>
|
|
<body>
|
|
<p><a href="${pageUrl}">${title}</a></p>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// --- 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 };
|
|
}
|
|
}
|