diff --git a/database/migrations/041_invoice_status_approved.sql b/database/migrations/041_invoice_status_approved.sql new file mode 100644 index 0000000..85d5b5a --- /dev/null +++ b/database/migrations/041_invoice_status_approved.sql @@ -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'; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index f32a9a6..9197132 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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` --- diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99e5d86..465ebe9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1141,6 +1141,7 @@ enum InvoiceOwnerScope { enum InvoiceStatus { draft issued + approved paid cancelled diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index bca0024..a4adf13 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -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 = { 'ali-mohammadi.ir': 'ali-mohammadi', + 'meshkee.com': 'meshkee', }; type DomainRow = { diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts index 253a208..f4b0d1d 100644 --- a/src/invoices/dto/invoice.dto.ts +++ b/src/invoices/dto/invoice.dto.ts @@ -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) diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index 99128ba..f3add4c 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -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); + } } diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 9e8ba75..4195eb1 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -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);