mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Add approved invoice status with lock after customer acceptance.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
9455cd9fab
commit
7244b70e90
@@ -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';
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1141,6 +1141,7 @@ enum InvoiceOwnerScope {
|
||||
enum InvoiceStatus {
|
||||
draft
|
||||
issued
|
||||
approved
|
||||
paid
|
||||
cancelled
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user