From 5734a73c9c26f43b4eb0d1e1c24321c46de6e2ea Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 11 Aug 2026 15:17:03 +0330 Subject: [PATCH] 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 --- database/migrations/058_invoice_user_id.sql | 46 ++ docs/PROJECT_CONTEXT.md | 50 +- prisma/schema.prisma | 74 +- src/customers/customers.service.ts | 2 +- src/invoices/dto/invoice.dto.ts | 14 + src/invoices/invoices.controller.ts | 227 +++++-- src/invoices/invoices.module.ts | 4 +- src/invoices/invoices.service.ts | 717 ++++++++++++++++---- 8 files changed, 899 insertions(+), 235 deletions(-) create mode 100644 database/migrations/058_invoice_user_id.sql diff --git a/database/migrations/058_invoice_user_id.sql b/database/migrations/058_invoice_user_id.sql new file mode 100644 index 0000000..a04b12d --- /dev/null +++ b/database/migrations/058_invoice_user_id.sql @@ -0,0 +1,46 @@ +-- Invoices bill a User (not a Business). business_id remains tenant/context. + +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS user_id BIGINT; + +-- Backfill: business owner, else any business_users row for that business +UPDATE invoices i +SET user_id = COALESCE( + ( + SELECT bu.user_id + FROM business_users bu + WHERE bu.business_id = i.business_id + AND bu.is_owner = TRUE + ORDER BY bu.user_id + LIMIT 1 + ), + ( + SELECT bu.user_id + FROM business_users bu + WHERE bu.business_id = i.business_id + ORDER BY bu.user_id + LIMIT 1 + ) +) +WHERE i.user_id IS NULL; + +-- Drop any invoices that still have no resolvable user (orphan businesses) +DELETE FROM invoices WHERE user_id IS NULL; + +ALTER TABLE invoices + ALTER COLUMN user_id SET NOT NULL; + +DO $$ BEGIN + ALTER TABLE invoices + ADD CONSTRAINT invoices_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS idx_invoices_user_created + ON invoices (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_invoices_issuer_business_created + ON invoices (issuer_business_id, created_at DESC) + WHERE issuer_business_id IS NOT NULL; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 7f154a5..1c6f7bd 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 9, 2026 +> Last updated: August 11, 2026 ## What This Project Is @@ -160,11 +160,13 @@ Example super admin: `+989121111111` / `password` | `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices | | `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts | | `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links | +| `041_invoice_status_approved.sql` | Invoice status `approved` | | `049_user_name_en.sql` | Optional `users.first_name_en` / `last_name_en` for EN display names | | `052_user_products.sql` | Customer stock listings (`user_products`) + technical values; `cities.level` adds `district`; `media_entity_type` adds `user_product` | | `053_cities_country_optional_province.sql` | City may hang under country (province optional); seed Iraq/Turkey/UAE + major cities | | `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) | | `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` | +| `058_invoice_user_id.sql` | `invoices.user_id` billed user (backfill from business owner) | Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations. @@ -217,9 +219,11 @@ Business 1──* Cart (per customer) 1──* CartItem → ProductVariant Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order) User 1──* Cart, Order (as customer) -Business 1──* Invoice (billed party) 1──* InvoiceItem -InvoiceItemTemplate (platform or per-business predefined lines) -Invoice.issuedBy → User +Business 1──* Invoice (tenant context) 1──* InvoiceItem +User 1──* Invoice (billed party via user_id) +InvoiceItemTemplate / InvoiceTemplate (platform or per-business) +Invoice.issuedBy → User (issuer) +Invoice.issuerBusinessId → Business (when owner_scope=business) Media 1──* MediaAttachment (polymorphic: entityType + entityId) ``` @@ -624,9 +628,9 @@ See `.env.example` for the full list. Key groups: --- -## Invoices (platform / super-admin) +## Invoices (platform + business) -Super admins issue invoices **to** a business. Schema is ready for future business-scoped issuing (`owner_scope=business`). +Invoices bill a **User** (`user_id`). `business_id` is tenant/context. Super-admin issues platform invoices (`owner_scope=platform`, default billed user = business owner). Business admins issue business-scoped invoices (`owner_scope=business`, `issuer_business_id`, required `userId` who is a customer or team member). ### Tables @@ -635,10 +639,10 @@ Super admins issue invoices **to** a business. Schema is ready for future busine | `invoice_item_templates` | Predefined line items (`owner_scope` platform \| business) | | `invoice_templates` | Full blueprints: name, top_text | | `invoice_template_items` / `_key_points` / `_accounts` | Nested template content | -| `invoices` | Invoice header (`business_id` = billed party, optional `name`, `top_text`, `notes`, `invoice_template_id`, `status`) | +| `invoices` | Header: `user_id` (billed), `business_id` (context), optional `name`, `top_text`, `notes`, `invoice_template_id`, `status`, `public_id` | | `invoice_items` / `invoice_key_points` / `invoice_accounts` | Issued invoice nested content | -### API (super_admin only today) +### API — platform templates (super_admin) | Method | Path | |--------|------| @@ -646,20 +650,30 @@ Super admins issue invoices **to** a business. Schema is ready for future busine | PATCH/DELETE | `/invoice-item-templates/:templateId` | | GET/POST | `/invoice-templates` | | GET/PATCH/DELETE | `/invoice-templates/:templateId` | -| GET/POST | `/businesses/:businessId/invoices` | -| 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`. +### API — business-scoped (`BusinessPermissionGuard`) -Serialized platform invoices include `publicId` + `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{publicId}` when set. Accounts include optional `accountHolderName`. +| Method | Path | Permission | +|--------|------|------------| +| GET/POST | `/businesses/:businessId/invoice-item-templates` | `invoice_templates.read` / `.manage` | +| PATCH/DELETE | `.../invoice-item-templates/:templateId` | `invoice_templates.manage` | +| GET/POST | `/businesses/:businessId/invoice-templates` | `invoice_templates.read` / `.manage` | +| GET/PATCH/DELETE | `.../invoice-templates/:templateId` | read / manage | +| GET/POST | `/businesses/:businessId/invoices` | `invoices.read` / `.create` (`?userId=` filter) | +| GET/PUT/PATCH/DELETE | `.../invoices/:invoiceId` | read / update / delete | -Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:publicId`); API serves JSON via `/public/invoices/:publicId` (sequential PK is not accepted). +Super-admin calling `/businesses/:id/invoices*` still operates on **platform** invoices for that business (service branches on `isSuperAdmin`). -Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`. +### Public -Module: `src/invoices/` · Migrations: `036` … `041` +| Method | Path | +|--------|------| +| GET | `/public/invoices/:publicId` (issued/approved/paid; platform or business) | +| POST | `/public/invoices/:publicId/approve` (`issued` → `approved`) | + +Serialized invoices include `userId`, `user`, `publicId`, `publicUrl` (both scopes). Business-scoped `publicUrl` uses the business primary domain (`https://{host}/invoices/{publicId}`); platform uses `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`). + +Module: `src/invoices/` · Migrations: `036` … `041`, `058_invoice_user_id.sql` --- @@ -707,7 +721,7 @@ Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormServic | Prisma schema | `prisma/schema.prisma` | | Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) | | Invoices module | `src/invoices/` | -| Invoice migrations | `database/migrations/036_invoices.sql` … `040_invoice_public_id.sql` | +| Invoice migrations | `database/migrations/036_invoices.sql` … `041_invoice_status_approved.sql`, `058_invoice_user_id.sql` | | Docker services | `docker-compose.yml` | | Dev seed data | `database/seeds/001_sample_data.sql` | | Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f9c9c8..87c7253 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,41 +8,42 @@ datasource db { } model User { - id BigInt @id @default(autoincrement()) - cellNumber String @unique(map: "users_cell_number_unique") @map("cell_number") @db.VarChar(20) - passwordHash String @map("password_hash") @db.VarChar(255) - email String? @db.VarChar(255) - firstName String? @map("first_name") @db.VarChar(100) - lastName String? @map("last_name") @db.VarChar(100) - isActive Boolean @default(true) @map("is_active") - cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6) - lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - profile Json @default("{}") - firstNameEn String? @map("first_name_en") @db.VarChar(100) - lastNameEn String? @map("last_name_en") @db.VarChar(100) - oldId BigInt? @map("old_id") - addresses Address[] - authoredBlogs blogs[] @relation("BlogAuthor") - businessCustomers BusinessCustomer[] - businessUsersInvited BusinessUser[] @relation("BusinessInviter") - businessUsers BusinessUser[] @relation("BusinessMember") - carts Cart[] - commentsApproved Comment[] @relation("CommentApprover") - expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover") - favorites Favorite[] - invoicesIssued Invoice[] @relation("InvoiceIssuer") - mediaUploaded Media[] - ordersCreated Order[] @relation("OrderCreator") - orders Order[] @relation("OrderCustomer") - authoredPortfolios portfolios[] @relation("PortfolioAuthor") - shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator") - shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer") - transactionsCreated Transaction[] @relation("TransactionCreator") - transactions Transaction[] @relation("TransactionCustomer") - userProducts UserProduct[] - userRoles UserRole[] + id BigInt @id @default(autoincrement()) + cellNumber String @unique(map: "users_cell_number_unique") @map("cell_number") @db.VarChar(20) + passwordHash String @map("password_hash") @db.VarChar(255) + email String? @db.VarChar(255) + firstName String? @map("first_name") @db.VarChar(100) + lastName String? @map("last_name") @db.VarChar(100) + isActive Boolean @default(true) @map("is_active") + cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6) + lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + profile Json @default("{}") + firstNameEn String? @map("first_name_en") @db.VarChar(100) + lastNameEn String? @map("last_name_en") @db.VarChar(100) + oldId BigInt? @map("old_id") + addresses Address[] + authoredBlogs blogs[] @relation("BlogAuthor") + businessCustomers BusinessCustomer[] + businessUsersInvited BusinessUser[] @relation("BusinessInviter") + businessUsers BusinessUser[] @relation("BusinessMember") + carts Cart[] + commentsApproved Comment[] @relation("CommentApprover") + expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover") + favorites Favorite[] + invoicesIssued Invoice[] @relation("InvoiceIssuer") + invoicesBilled Invoice[] @relation("InvoiceBilledUser") + mediaUploaded Media[] + ordersCreated Order[] @relation("OrderCreator") + orders Order[] @relation("OrderCustomer") + authoredPortfolios portfolios[] @relation("PortfolioAuthor") + shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator") + shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer") + transactionsCreated Transaction[] @relation("TransactionCreator") + transactions Transaction[] @relation("TransactionCustomer") + userProducts UserProduct[] + userRoles UserRole[] @@index([cellNumber], map: "idx_users_cell_number") @@map("users") @@ -1178,6 +1179,7 @@ model Invoice { topText String? @map("top_text") invoiceTemplateId BigInt? @map("invoice_template_id") publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32) + userId BigInt @map("user_id") accounts InvoiceAccount[] items InvoiceItem[] keyPoints InvoiceKeyPoint[] @@ -1185,10 +1187,12 @@ model Invoice { invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction) issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction) issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction) + user User @relation("InvoiceBilledUser", fields: [userId], references: [id], onUpdate: NoAction) @@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created") @@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope") @@index([status], map: "idx_invoices_status") + @@index([userId, createdAt(sort: Desc)], map: "idx_invoices_user_created") @@map("invoices") } diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 04542a5..d1380a5 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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([ diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts index f4b0d1d..d7fad3f 100644 --- a/src/invoices/dto/invoice.dto.ts +++ b/src/invoices/dto/invoice.dto.ts @@ -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 { diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index f3add4c..1753e1f 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -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); + } +} diff --git a/src/invoices/invoices.module.ts b/src/invoices/invoices.module.ts index d76ea85..d69b924 100644 --- a/src/invoices/invoices.module.ts +++ b/src/invoices/invoices.module.ts @@ -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 {} diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 4195eb1..b21c3c6 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -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 { + 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[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');