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` |
|
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
|
||||||
| `OrderSource` | `website`, `admin` |
|
| `OrderSource` | `website`, `admin` |
|
||||||
| `InvoiceOwnerScope` | `platform`, `business` |
|
| `InvoiceOwnerScope` | `platform`, `business` |
|
||||||
| `InvoiceStatus` | `draft`, `issued`, `paid`, `cancelled` |
|
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
|
||||||
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
||||||
| `MediaType` | `image`, `video` |
|
| `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/POST | `/invoice-templates` |
|
||||||
| GET/PATCH/DELETE | `/invoice-templates/:templateId` |
|
| GET/PATCH/DELETE | `/invoice-templates/:templateId` |
|
||||||
| GET/POST | `/businesses/:businessId/invoices` |
|
| GET/POST | `/businesses/:businessId/invoices` |
|
||||||
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
|
| GET/PUT/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` (PUT = content; PATCH = status; content locked when `approved`) |
|
||||||
| GET | `/public/invoices/:publicId` (no auth; issued/paid only; opaque 12-digit id) |
|
| 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`.
|
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.*`.
|
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 {
|
enum InvoiceStatus {
|
||||||
draft
|
draft
|
||||||
issued
|
issued
|
||||||
|
approved
|
||||||
paid
|
paid
|
||||||
cancelled
|
cancelled
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
|||||||
/** Apex hosts that have a storefront deploy on the websites VM. */
|
/** Apex hosts that have a storefront deploy on the websites VM. */
|
||||||
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
|
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
|
||||||
'ali-mohammadi.ir': 'ali-mohammadi',
|
'ali-mohammadi.ir': 'ali-mohammadi',
|
||||||
|
'meshkee.com': 'meshkee',
|
||||||
};
|
};
|
||||||
|
|
||||||
type DomainRow = {
|
type DomainRow = {
|
||||||
|
|||||||
@@ -129,6 +129,44 @@ export class UpdateInvoiceStatusDto {
|
|||||||
notes?: string;
|
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 {
|
export class ListInvoicesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
CreateInvoiceItemTemplateDto,
|
CreateInvoiceItemTemplateDto,
|
||||||
CreateInvoiceTemplateDto,
|
CreateInvoiceTemplateDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
|
UpdateInvoiceDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
UpdateInvoiceTemplateDto,
|
UpdateInvoiceTemplateDto,
|
||||||
@@ -122,6 +124,17 @@ export class InvoicesController {
|
|||||||
return this.service.getOne(businessId, invoiceId, user);
|
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')
|
@Patch('businesses/:businessId/invoices/:invoiceId')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@@ -143,9 +156,15 @@ export class InvoicesController {
|
|||||||
return this.service.deleteInvoice(businessId, invoiceId, user);
|
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')
|
@Get('public/invoices/:publicId')
|
||||||
getPublic(@Param('publicId') publicId: string) {
|
getPublic(@Param('publicId') publicId: string) {
|
||||||
return this.service.getPublicInvoice(publicId);
|
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,
|
InvoiceKeyPointInputDto,
|
||||||
InvoiceTemplateItemInputDto,
|
InvoiceTemplateItemInputDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
|
UpdateInvoiceDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
UpdateInvoiceTemplateDto,
|
UpdateInvoiceTemplateDto,
|
||||||
@@ -743,7 +744,7 @@ export class InvoicesService {
|
|||||||
where: {
|
where: {
|
||||||
publicId,
|
publicId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
|
status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] },
|
||||||
},
|
},
|
||||||
include: this.invoiceInclude,
|
include: this.invoiceInclude,
|
||||||
});
|
});
|
||||||
@@ -930,6 +931,17 @@ export class InvoicesService {
|
|||||||
throw new NotFoundException('Invoice not found');
|
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({
|
const row = await this.prisma.invoice.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
@@ -942,6 +954,166 @@ export class InvoicesService {
|
|||||||
return this.serializeInvoice(row);
|
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) {
|
async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user