Add platform invoices API with item templates and optional name.

Super admins can manage predefined invoice lines and issue invoices to businesses; schema is ready for future business-scoped use.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-24 20:59:35 +03:30
co-authored by Cursor
parent 136711dfb3
commit 426316d53c
10 changed files with 1094 additions and 3 deletions
+487
View File
@@ -0,0 +1,487 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
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,
InvoiceItemInputDto,
ListInvoicesDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
} from './dto/invoice.dto';
@Injectable()
export class InvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
private serializeTemplate(row: {
id: bigint;
ownerScope: InvoiceOwnerScope;
businessId: bigint | null;
title: string;
duration: string | null;
worktime: string | null;
description: string | null;
price: Prisma.Decimal;
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id.toString(),
ownerScope: row.ownerScope,
businessId: row.businessId?.toString() ?? null,
title: row.title,
duration: row.duration,
worktime: row.worktime,
description: row.description,
price: Number(row.price),
discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice),
sortOrder: row.sortOrder,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private serializeItem(row: {
id: bigint;
invoiceId: bigint;
templateId: bigint | null;
title: string;
duration: string | null;
worktime: string | null;
description: string | null;
price: Prisma.Decimal;
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
}) {
return {
id: row.id.toString(),
invoiceId: row.invoiceId.toString(),
templateId: row.templateId?.toString() ?? null,
title: row.title,
duration: row.duration,
worktime: row.worktime,
description: row.description,
price: Number(row.price),
discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice),
sortOrder: row.sortOrder,
};
}
private platformInvoicePublicUrl(invoiceId: bigint) {
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
return `https://${domain}/invoices/${invoiceId.toString()}`;
}
private serializeInvoice(
row: {
id: bigint;
businessId: bigint;
ownerScope: InvoiceOwnerScope;
issuerBusinessId: bigint | null;
status: InvoiceStatus;
name: string | null;
notes: string | null;
issuedBy: bigint | null;
issuedAt: Date;
createdAt: Date;
updatedAt: Date;
business?: { id: bigint; name: string; nameFa: string | null };
issuer?: { id: bigint; firstName: string | null; lastName: string | null } | null;
items?: Array<{
id: bigint;
invoiceId: bigint;
templateId: bigint | null;
title: string;
duration: string | null;
worktime: string | null;
description: string | null;
price: Prisma.Decimal;
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
}>;
},
includeItems = true,
) {
const items = includeItems && 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(),
businessId: row.businessId.toString(),
ownerScope: row.ownerScope,
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
status: row.status,
name: row.name,
notes: row.notes,
publicUrl:
row.ownerScope === InvoiceOwnerScope.platform
? this.platformInvoicePublicUrl(row.id)
: null,
issuedBy: row.issuedBy?.toString() ?? null,
issuedAt: row.issuedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
business: row.business
? {
id: row.business.id.toString(),
name: row.business.name,
nameFa: row.business.nameFa,
}
: undefined,
issuer: row.issuer
? {
id: row.issuer.id.toString(),
firstName: row.issuer.firstName,
lastName: row.issuer.lastName,
}
: null,
items,
subtotal: totals?.subtotal,
total: totals?.total,
};
}
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 };
}
// --- Invoices for a business ---
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const page = query.page ?? 1;
const pageSize = Math.min(query.pageSize ?? 20, 100);
const where: Prisma.InvoiceWhereInput = {
businessId,
ownerScope: InvoiceOwnerScope.platform,
...(query.status ? { status: query.status } : {}),
};
const [total, rows] = await this.prisma.$transaction([
this.prisma.invoice.count({ where }),
this.prisma.invoice.findMany({
where,
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map((row) => this.serializeInvoice(row)),
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const invoiceId = BigInt(invoiceIdRaw);
const row = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
if (!row) {
throw new NotFoundException('Invoice not found');
}
return this.serializeInvoice(row);
}
async createForBusiness(businessIdRaw: string, dto: CreateInvoiceDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const items = dto.items.map((item) => this.normalizeItemInput(item));
const row = await this.prisma.invoice.create({
data: {
businessId,
ownerScope: InvoiceOwnerScope.platform,
status: dto.status ?? InvoiceStatus.issued,
name: dto.name?.trim() || null,
notes: dto.notes?.trim() || null,
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,
})),
},
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
return this.serializeInvoice(row);
}
async updateStatus(
businessIdRaw: string,
invoiceIdRaw: string,
dto: UpdateInvoiceStatusDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const invoiceId = BigInt(invoiceIdRaw);
const existing = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
});
if (!existing) {
throw new NotFoundException('Invoice not found');
}
const row = await this.prisma.invoice.update({
where: { id: invoiceId },
data: {
status: dto.status,
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
return this.serializeInvoice(row);
}
async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const invoiceId = BigInt(invoiceIdRaw);
const existing = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
});
if (!existing) {
throw new NotFoundException('Invoice not found');
}
await this.prisma.invoice.delete({ where: { id: invoiceId } });
return { ok: true };
}
}