Add approved invoice status with lock after customer acceptance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-28 11:54:16 +03:30
co-authored by Cursor
parent 9455cd9fab
commit 7244b70e90
7 changed files with 241 additions and 6 deletions
@@ -0,0 +1,3 @@
-- Add 'approved' to invoice_status (customer accepted the invoice; content becomes immutable).
ALTER TYPE invoice_status ADD VALUE IF NOT EXISTS 'approved';
+5 -4
View File
@@ -174,7 +174,7 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
| `OrderSource` | `website`, `admin` |
| `InvoiceOwnerScope` | `platform`, `business` |
| `InvoiceStatus` | `draft`, `issued`, `paid`, `cancelled` |
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
| `MediaType` | `image`, `video` |
@@ -588,8 +588,9 @@ Super admins issue invoices **to** a business. Schema is ready for future busine
| GET/POST | `/invoice-templates` |
| GET/PATCH/DELETE | `/invoice-templates/:templateId` |
| GET/POST | `/businesses/:businessId/invoices` |
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
| GET | `/public/invoices/:publicId` (no auth; issued/paid only; opaque 12-digit id) |
| GET/PUT/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` (PUT = content; PATCH = status; content locked when `approved`) |
| GET | `/public/invoices/:publicId` (no auth; issued/approved/paid; opaque 12-digit id) |
| POST | `/public/invoices/:publicId/approve` (no auth; `issued``approved`) |
Auth (admin routes): `JwtAuthGuard` + service `assertSuperAdmin`.
@@ -599,7 +600,7 @@ Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:publicId
Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`.
Module: `src/invoices/` · Migrations: `036``040`
Module: `src/invoices/` · Migrations: `036``041`
---
+1
View File
@@ -1141,6 +1141,7 @@ enum InvoiceOwnerScope {
enum InvoiceStatus {
draft
issued
approved
paid
cancelled
+1
View File
@@ -19,6 +19,7 @@ import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
/** Apex hosts that have a storefront deploy on the websites VM. */
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
'ali-mohammadi.ir': 'ali-mohammadi',
'meshkee.com': 'meshkee',
};
type DomainRow = {
+38
View File
@@ -129,6 +129,44 @@ export class UpdateInvoiceStatusDto {
notes?: string;
}
/** Full content replace for an existing invoice (items / key points / accounts). */
export class UpdateInvoiceDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => InvoiceItemInputDto)
items!: InvoiceItemInputDto[];
@IsOptional()
@IsString()
@MaxLength(255)
name?: string | null;
@IsOptional()
@IsString()
topText?: string | null;
@IsOptional()
@IsString()
notes?: string | null;
@IsOptional()
@IsString()
invoiceTemplateId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceAccountInputDto)
accounts?: InvoiceAccountInputDto[];
}
export class ListInvoicesDto {
@IsOptional()
@Type(() => Number)
+20 -1
View File
@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -17,6 +18,7 @@ import {
CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto,
ListInvoicesDto,
UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
UpdateInvoiceTemplateDto,
@@ -122,6 +124,17 @@ export class InvoicesController {
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(
@@ -143,9 +156,15 @@ export class InvoicesController {
return this.service.deleteInvoice(businessId, invoiceId, user);
}
/** Public invoice show page (no auth). Issued / paid platform invoices only. */
/** Public invoice show page (no auth). Issued / approved / paid platform invoices only. */
@Get('public/invoices/:publicId')
getPublic(@Param('publicId') publicId: string) {
return this.service.getPublicInvoice(publicId);
}
/** Public approve action (no auth). issued → approved. */
@Post('public/invoices/:publicId/approve')
approvePublic(@Param('publicId') publicId: string) {
return this.service.approvePublicInvoice(publicId);
}
}
+173 -1
View File
@@ -18,6 +18,7 @@ import {
InvoiceKeyPointInputDto,
InvoiceTemplateItemInputDto,
ListInvoicesDto,
UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
UpdateInvoiceTemplateDto,
@@ -743,7 +744,7 @@ export class InvoicesService {
where: {
publicId,
ownerScope: InvoiceOwnerScope.platform,
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] },
},
include: this.invoiceInclude,
});
@@ -930,6 +931,17 @@ export class InvoicesService {
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: {
@@ -942,6 +954,166 @@ export class InvoicesService {
return this.serializeInvoice(row);
}
async updateContent(
businessIdRaw: string,
invoiceIdRaw: string,
dto: UpdateInvoiceDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const invoiceId = BigInt(invoiceIdRaw);
const existing = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
});
if (!existing) {
throw new NotFoundException('Invoice not found');
}
if (existing.status === InvoiceStatus.approved) {
throw new BadRequestException('Approved invoices cannot be edited');
}
const items = dto.items.map((item) => this.normalizeItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
let invoiceTemplateId: bigint | null | undefined;
if (dto.invoiceTemplateId === null) {
invoiceTemplateId = null;
} else if (dto.invoiceTemplateId !== undefined) {
const trimmed = dto.invoiceTemplateId.trim();
invoiceTemplateId = trimmed ? BigInt(trimmed) : null;
if (invoiceTemplateId !== null) {
const template = await this.prisma.invoiceTemplate.findFirst({
where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform },
select: { id: true },
});
if (!template) {
throw new BadRequestException('Invoice template not found');
}
}
}
const row = await this.prisma.$transaction(async (tx) => {
await tx.invoiceItem.deleteMany({ where: { invoiceId } });
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
return tx.invoice.update({
where: { id: invoiceId },
data: {
...(dto.name !== undefined ? { name: dto.name?.trim() || null } : {}),
...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}),
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
...(invoiceTemplateId !== undefined ? { invoiceTemplateId } : {}),
items: {
create: items.map((item, index) => ({
templateId: item.templateId,
title: item.title,
duration: item.duration,
worktime: item.worktime,
description: item.description,
price: item.price,
discountedPrice: item.discountedPrice,
sortOrder: index,
})),
},
keyPoints: {
create: keyPoints.map((kp, index) => ({
text: kp.text,
sortOrder: index,
})),
},
accounts: {
create: accounts.map((acc, index) => ({
bankName: acc.bankName,
accountHolderName: acc.accountHolderName,
cardNumber: acc.cardNumber,
iban: acc.iban,
sortOrder: index,
})),
},
},
include: this.invoiceInclude,
});
});
return this.serializeInvoice(row);
}
async approvePublicInvoice(publicIdRaw: string) {
const publicId = publicIdRaw.trim();
if (!/^\d{6,32}$/.test(publicId)) {
throw new NotFoundException('Invoice not found');
}
const existing = await this.prisma.invoice.findFirst({
where: {
publicId,
ownerScope: InvoiceOwnerScope.platform,
},
});
if (
!existing ||
(existing.status !== InvoiceStatus.issued &&
existing.status !== InvoiceStatus.approved &&
existing.status !== InvoiceStatus.paid)
) {
throw new NotFoundException('Invoice not found');
}
if (existing.status === InvoiceStatus.approved || existing.status === InvoiceStatus.paid) {
const row = await this.prisma.invoice.findFirst({
where: { id: existing.id },
include: this.invoiceInclude,
});
if (!row) throw new NotFoundException('Invoice not found');
const serialized = this.serializeInvoice(row, true);
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
}
const row = await this.prisma.invoice.update({
where: { id: existing.id },
data: { status: InvoiceStatus.approved },
include: this.invoiceInclude,
});
const serialized = this.serializeInvoice(row, true);
return {
publicId: serialized.publicId,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
}
async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);