Add business-scoped invoices billed to users.

Introduce invoices.user_id, business template/invoice APIs, and tenant-domain public URLs so business dashboards can issue invoices without platform-scope template mismatches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-11 15:17:03 +03:30
co-authored by Cursor
parent e59db25814
commit 5734a73c9c
8 changed files with 899 additions and 235 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ export class CustomersService {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const days = Math.min(Math.max(daysRaw ?? 30, 1), 366);
const from = startOfLocalDay(days - 1);
const [registeredRows, activeRows] = await Promise.all([
+14
View File
@@ -80,6 +80,11 @@ export class InvoiceAccountInputDto {
}
export class CreateInvoiceDto {
/** Billed user. Optional for platform (defaults to business owner); required for business-scoped. */
@IsOptional()
@IsString()
userId?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@@ -131,6 +136,10 @@ export class UpdateInvoiceStatusDto {
/** Full content replace for an existing invoice (items / key points / accounts). */
export class UpdateInvoiceDto {
@IsOptional()
@IsString()
userId?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@@ -183,6 +192,11 @@ export class ListInvoicesDto {
@IsOptional()
@IsEnum(InvoiceStatus)
status?: InvoiceStatus;
/** Filter invoices billed to this user (business dashboard deep link). */
@IsOptional()
@IsString()
userId?: string;
}
export class CreateInvoiceItemTemplateDto {
+163 -64
View File
@@ -11,6 +11,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import {
@@ -93,70 +95,7 @@ export class InvoicesController {
return this.service.deletePlatformInvoiceTemplate(templateId, user);
}
// Business invoices
@Get('businesses/:businessId/invoices')
@UseGuards(JwtAuthGuard)
list(
@Param('businessId') businessId: string,
@Query() query: ListInvoicesDto,
@CurrentUser() user: AuthUser,
) {
return this.service.listForBusiness(businessId, query, user);
}
@Post('businesses/:businessId/invoices')
@UseGuards(JwtAuthGuard)
create(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createForBusiness(businessId, dto, user);
}
@Get('businesses/:businessId/invoices/:invoiceId')
@UseGuards(JwtAuthGuard)
getOne(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, invoiceId, user);
}
@Put('businesses/:businessId/invoices/:invoiceId')
@UseGuards(JwtAuthGuard)
updateContent(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@Body() dto: UpdateInvoiceDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateContent(businessId, invoiceId, dto, user);
}
@Patch('businesses/:businessId/invoices/:invoiceId')
@UseGuards(JwtAuthGuard)
updateStatus(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@Body() dto: UpdateInvoiceStatusDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateStatus(businessId, invoiceId, dto, user);
}
@Delete('businesses/:businessId/invoices/:invoiceId')
@UseGuards(JwtAuthGuard)
remove(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteInvoice(businessId, invoiceId, user);
}
/** Public invoice show page (no auth). Issued / approved / paid platform invoices only. */
/** Public invoice show page (no auth). Issued / approved / paid invoices only (platform or business). */
@Get('public/invoices/:publicId')
getPublic(@Param('publicId') publicId: string) {
return this.service.getPublicInvoice(publicId);
@@ -168,3 +107,163 @@ export class InvoicesController {
return this.service.approvePublicInvoice(publicId);
}
}
/**
* Business-scoped invoices + invoice templates. Super admins pass BusinessPermissionGuard
* (they hold all permissions); InvoicesService still branches platform vs business scope by
* checking `isSuperAdmin` internally.
*/
@Controller('businesses/:businessId')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BusinessInvoicesController {
constructor(private readonly service: InvoicesService) {}
// Invoices
@Get('invoices')
@RequireBusinessPermission('invoices.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListInvoicesDto,
@CurrentUser() user: AuthUser,
) {
return this.service.listForBusiness(businessId, query, user);
}
@Post('invoices')
@RequireBusinessPermission('invoices.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createForBusiness(businessId, dto, user);
}
@Get('invoices/:invoiceId')
@RequireBusinessPermission('invoices.read')
getOne(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, invoiceId, user);
}
@Put('invoices/:invoiceId')
@RequireBusinessPermission('invoices.update')
updateContent(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@Body() dto: UpdateInvoiceDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateContent(businessId, invoiceId, dto, user);
}
@Patch('invoices/:invoiceId')
@RequireBusinessPermission('invoices.update')
updateStatus(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@Body() dto: UpdateInvoiceStatusDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateStatus(businessId, invoiceId, dto, user);
}
@Delete('invoices/:invoiceId')
@RequireBusinessPermission('invoices.delete')
remove(
@Param('businessId') businessId: string,
@Param('invoiceId') invoiceId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteInvoice(businessId, invoiceId, user);
}
// Invoice item templates (business-scoped)
@Get('invoice-item-templates')
@RequireBusinessPermission('invoice_templates.read')
listItemTemplates(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.listBusinessItemTemplates(businessId, user);
}
@Post('invoice-item-templates')
@RequireBusinessPermission('invoice_templates.manage')
createItemTemplate(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceItemTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createBusinessItemTemplate(businessId, dto, user);
}
@Patch('invoice-item-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
updateItemTemplate(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceItemTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateBusinessItemTemplate(businessId, templateId, dto, user);
}
@Delete('invoice-item-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
deleteItemTemplate(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteBusinessItemTemplate(businessId, templateId, user);
}
// Invoice templates (business-scoped)
@Get('invoice-templates')
@RequireBusinessPermission('invoice_templates.read')
listInvoiceTemplates(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.listBusinessInvoiceTemplates(businessId, user);
}
@Post('invoice-templates')
@RequireBusinessPermission('invoice_templates.manage')
createInvoiceTemplate(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createBusinessInvoiceTemplate(businessId, dto, user);
}
@Get('invoice-templates/:templateId')
@RequireBusinessPermission('invoice_templates.read')
getInvoiceTemplate(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getBusinessInvoiceTemplate(businessId, templateId, user);
}
@Patch('invoice-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
updateInvoiceTemplate(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateBusinessInvoiceTemplate(businessId, templateId, dto, user);
}
@Delete('invoice-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
deleteInvoiceTemplate(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteBusinessInvoiceTemplate(businessId, templateId, user);
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { InvoicesController } from './invoices.controller';
import { BusinessInvoicesController, InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AuthModule],
controllers: [InvoicesController],
controllers: [InvoicesController, BusinessInvoicesController],
providers: [InvoicesService],
})
export class InvoicesModule {}
+602 -115
View File
@@ -52,6 +52,130 @@ export class InvoicesService {
}
}
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;
@@ -85,8 +209,21 @@ export class InvoicesService {
}
private readonly invoiceInclude = {
business: { select: { id: true, name: true, nameFa: true } },
business: {
select: {
id: true,
name: true,
nameFa: 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 }] },
@@ -244,7 +381,23 @@ export class InvoicesService {
};
}
private platformInvoicePublicUrl(publicId: string) {
private invoicePublicUrl(
publicId: string,
opts?: { ownerScope?: InvoiceOwnerScope; businessHost?: string | null },
) {
const normalizeHost = (raw: string) =>
raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '');
if (opts?.ownerScope === InvoiceOwnerScope.business) {
const host = opts.businessHost ? normalizeHost(opts.businessHost) : '';
if (host) {
return `https://${host}/invoices/${publicId}`;
}
}
const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
if (base) {
return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
@@ -266,11 +419,23 @@ export class InvoicesService {
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 };
business?: {
id: bigint;
name: string;
nameFa: string | null;
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;
@@ -328,11 +493,12 @@ export class InvoicesService {
topText: row.topText,
notes: row.notes,
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
publicUrl:
row.ownerScope === InvoiceOwnerScope.platform
? this.platformInvoicePublicUrl(row.publicId)
: 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,
@@ -350,6 +516,14 @@ export class InvoicesService {
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
@@ -732,7 +906,362 @@ export class InvoicesService {
return { ok: true };
}
// --- Public invoice viewer (platform) ---
// --- 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);
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
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();
@@ -743,7 +1272,6 @@ export class InvoicesService {
const row = await this.prisma.invoice.findFirst({
where: {
publicId,
ownerScope: InvoiceOwnerScope.platform,
status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] },
},
include: this.invoiceInclude,
@@ -753,44 +1281,26 @@ export class InvoicesService {
throw new NotFoundException('Invoice not found');
}
const serialized = this.serializeInvoice(row, true);
// Public payload: no internal notes / issuer / sequential id
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
return this.toPublicInvoicePayload(row);
}
// --- 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');
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 = {
businessId,
ownerScope: InvoiceOwnerScope.platform,
...this.invoiceScopeWhere(businessId, access.mode),
...(query.status ? { status: query.status } : {}),
...(query.userId ? { userId: BigInt(query.userId) } : {}),
};
const [total, rows] = await this.prisma.$transaction([
@@ -814,17 +1324,16 @@ export class InvoicesService {
}
async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
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,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
include: this.invoiceInclude,
});
@@ -836,17 +1345,16 @@ export class InvoicesService {
}
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');
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));
@@ -854,26 +1362,31 @@ export class InvoicesService {
? 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, ownerScope: InvoiceOwnerScope.platform },
where: { id: invoiceTemplateId, ...this.templateScopeWhere(businessId, ownerScope) },
select: { id: true },
});
if (!template) {
throw new BadRequestException('Invoice template not found');
}
// 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: InvoiceOwnerScope.platform,
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,
invoiceTemplateId: resolvedInvoiceTemplateId,
issuedBy: actor.id,
items: {
create: items.map((item, index) => ({
@@ -915,17 +1428,16 @@ export class InvoicesService {
dto: UpdateInvoiceStatusDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
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,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
});
if (!existing) {
throw new NotFoundException('Invoice not found');
@@ -960,17 +1472,16 @@ export class InvoicesService {
dto: UpdateInvoiceDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
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,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
});
if (!existing) {
throw new NotFoundException('Invoice not found');
@@ -983,6 +1494,11 @@ export class InvoicesService {
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;
@@ -991,12 +1507,14 @@ export class InvoicesService {
invoiceTemplateId = trimmed ? BigInt(trimmed) : null;
if (invoiceTemplateId !== null) {
const template = await this.prisma.invoiceTemplate.findFirst({
where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform },
where: {
id: invoiceTemplateId,
...this.templateScopeWhere(businessId, existing.ownerScope),
},
select: { id: true },
});
if (!template) {
throw new BadRequestException('Invoice template not found');
}
// Content is already snapshotted; drop stale / out-of-scope template refs.
invoiceTemplateId = template?.id ?? null;
}
}
@@ -1008,6 +1526,7 @@ export class InvoicesService {
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 } : {}),
@@ -1054,10 +1573,7 @@ export class InvoicesService {
}
const existing = await this.prisma.invoice.findFirst({
where: {
publicId,
ownerScope: InvoiceOwnerScope.platform,
},
where: { publicId },
});
if (
!existing ||
@@ -1074,21 +1590,7 @@ export class InvoicesService {
include: this.invoiceInclude,
});
if (!row) throw new NotFoundException('Invoice not found');
const serialized = this.serializeInvoice(row, true);
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
return this.toPublicInvoicePayload(row);
}
const row = await this.prisma.invoice.update({
@@ -1097,35 +1599,20 @@ export class InvoicesService {
include: this.invoiceInclude,
});
const serialized = this.serializeInvoice(row, true);
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
return this.toPublicInvoicePayload(row);
}
async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
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,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
});
if (!existing) {
throw new NotFoundException('Invoice not found');