mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e59db25814
commit
5734a73c9c
@@ -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;
|
||||||
+32
-18
@@ -1,7 +1,7 @@
|
|||||||
# Meshkee CMS API — Project Context
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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 |
|
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
|
||||||
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
|
| `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 |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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) |
|
| `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` |
|
| `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.
|
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)
|
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
|
||||||
User 1──* Cart, Order (as customer)
|
User 1──* Cart, Order (as customer)
|
||||||
|
|
||||||
Business 1──* Invoice (billed party) 1──* InvoiceItem
|
Business 1──* Invoice (tenant context) 1──* InvoiceItem
|
||||||
InvoiceItemTemplate (platform or per-business predefined lines)
|
User 1──* Invoice (billed party via user_id)
|
||||||
Invoice.issuedBy → User
|
InvoiceItemTemplate / InvoiceTemplate (platform or per-business)
|
||||||
|
Invoice.issuedBy → User (issuer)
|
||||||
|
Invoice.issuerBusinessId → Business (when owner_scope=business)
|
||||||
|
|
||||||
Media 1──* MediaAttachment (polymorphic: entityType + entityId)
|
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
|
### 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_item_templates` | Predefined line items (`owner_scope` platform \| business) |
|
||||||
| `invoice_templates` | Full blueprints: name, top_text |
|
| `invoice_templates` | Full blueprints: name, top_text |
|
||||||
| `invoice_template_items` / `_key_points` / `_accounts` | Nested template content |
|
| `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 |
|
| `invoice_items` / `invoice_key_points` / `invoice_accounts` | Issued invoice nested content |
|
||||||
|
|
||||||
### API (super_admin only today)
|
### API — platform templates (super_admin)
|
||||||
|
|
||||||
| Method | Path |
|
| 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` |
|
| PATCH/DELETE | `/invoice-item-templates/:templateId` |
|
||||||
| 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/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` |
|
| Prisma schema | `prisma/schema.prisma` |
|
||||||
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) |
|
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) |
|
||||||
| Invoices module | `src/invoices/` |
|
| 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` |
|
| Docker services | `docker-compose.yml` |
|
||||||
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
||||||
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ model User {
|
|||||||
expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover")
|
expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover")
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
||||||
|
invoicesBilled Invoice[] @relation("InvoiceBilledUser")
|
||||||
mediaUploaded Media[]
|
mediaUploaded Media[]
|
||||||
ordersCreated Order[] @relation("OrderCreator")
|
ordersCreated Order[] @relation("OrderCreator")
|
||||||
orders Order[] @relation("OrderCustomer")
|
orders Order[] @relation("OrderCustomer")
|
||||||
@@ -1178,6 +1179,7 @@ model Invoice {
|
|||||||
topText String? @map("top_text")
|
topText String? @map("top_text")
|
||||||
invoiceTemplateId BigInt? @map("invoice_template_id")
|
invoiceTemplateId BigInt? @map("invoice_template_id")
|
||||||
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
|
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
|
||||||
|
userId BigInt @map("user_id")
|
||||||
accounts InvoiceAccount[]
|
accounts InvoiceAccount[]
|
||||||
items InvoiceItem[]
|
items InvoiceItem[]
|
||||||
keyPoints InvoiceKeyPoint[]
|
keyPoints InvoiceKeyPoint[]
|
||||||
@@ -1185,10 +1187,12 @@ model Invoice {
|
|||||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||||
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], 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([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
||||||
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
||||||
@@index([status], map: "idx_invoices_status")
|
@@index([status], map: "idx_invoices_status")
|
||||||
|
@@index([userId, createdAt(sort: Desc)], map: "idx_invoices_user_created")
|
||||||
@@map("invoices")
|
@@map("invoices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export class CustomersService {
|
|||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
await this.assertPermission(businessId, actor.id, 'orders.read');
|
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 from = startOfLocalDay(days - 1);
|
||||||
|
|
||||||
const [registeredRows, activeRows] = await Promise.all([
|
const [registeredRows, activeRows] = await Promise.all([
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export class InvoiceAccountInputDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateInvoiceDto {
|
export class CreateInvoiceDto {
|
||||||
|
/** Billed user. Optional for platform (defaults to business owner); required for business-scoped. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -131,6 +136,10 @@ export class UpdateInvoiceStatusDto {
|
|||||||
|
|
||||||
/** Full content replace for an existing invoice (items / key points / accounts). */
|
/** Full content replace for an existing invoice (items / key points / accounts). */
|
||||||
export class UpdateInvoiceDto {
|
export class UpdateInvoiceDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -183,6 +192,11 @@ export class ListInvoicesDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(InvoiceStatus)
|
@IsEnum(InvoiceStatus)
|
||||||
status?: InvoiceStatus;
|
status?: InvoiceStatus;
|
||||||
|
|
||||||
|
/** Filter invoices billed to this user (business dashboard deep link). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateInvoiceItemTemplateDto {
|
export class CreateInvoiceItemTemplateDto {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import {
|
import {
|
||||||
@@ -93,70 +95,7 @@ export class InvoicesController {
|
|||||||
return this.service.deletePlatformInvoiceTemplate(templateId, user);
|
return this.service.deletePlatformInvoiceTemplate(templateId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business invoices
|
/** Public invoice show page (no auth). Issued / approved / paid invoices only (platform or business). */
|
||||||
@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. */
|
|
||||||
@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);
|
||||||
@@ -168,3 +107,163 @@ export class InvoicesController {
|
|||||||
return this.service.approvePublicInvoice(publicId);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { InvoicesController } from './invoices.controller';
|
import { BusinessInvoicesController, InvoicesController } from './invoices.controller';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [InvoicesController],
|
controllers: [InvoicesController, BusinessInvoicesController],
|
||||||
providers: [InvoicesService],
|
providers: [InvoicesService],
|
||||||
})
|
})
|
||||||
export class InvoicesModule {}
|
export class InvoicesModule {}
|
||||||
|
|||||||
+602
-115
@@ -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<bigint> {
|
||||||
|
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: {
|
private serializeTemplate(row: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
ownerScope: InvoiceOwnerScope;
|
ownerScope: InvoiceOwnerScope;
|
||||||
@@ -85,8 +209,21 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly invoiceInclude = {
|
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 } },
|
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 }] },
|
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
keyPoints: { 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 }] },
|
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();
|
const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
|
||||||
if (base) {
|
if (base) {
|
||||||
return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
|
return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
|
||||||
@@ -266,11 +419,23 @@ export class InvoicesService {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
invoiceTemplateId: bigint | null;
|
invoiceTemplateId: bigint | null;
|
||||||
issuedBy: bigint | null;
|
issuedBy: bigint | null;
|
||||||
|
userId: bigint;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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;
|
issuer?: { id: bigint; firstName: string | null; lastName: string | null } | null;
|
||||||
|
user?: {
|
||||||
|
id: bigint;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
cellNumber: string;
|
||||||
|
} | null;
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: bigint;
|
id: bigint;
|
||||||
invoiceId: bigint;
|
invoiceId: bigint;
|
||||||
@@ -328,11 +493,12 @@ export class InvoicesService {
|
|||||||
topText: row.topText,
|
topText: row.topText,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
||||||
publicUrl:
|
publicUrl: this.invoicePublicUrl(row.publicId, {
|
||||||
row.ownerScope === InvoiceOwnerScope.platform
|
ownerScope: row.ownerScope,
|
||||||
? this.platformInvoicePublicUrl(row.publicId)
|
businessHost: row.business?.domains?.[0]?.host ?? null,
|
||||||
: null,
|
}),
|
||||||
issuedBy: row.issuedBy?.toString() ?? null,
|
issuedBy: row.issuedBy?.toString() ?? null,
|
||||||
|
userId: row.userId.toString(),
|
||||||
issuedAt: row.issuedAt,
|
issuedAt: row.issuedAt,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -350,6 +516,14 @@ export class InvoicesService {
|
|||||||
lastName: row.issuer.lastName,
|
lastName: row.issuer.lastName,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
user: row.user
|
||||||
|
? {
|
||||||
|
id: row.user.id.toString(),
|
||||||
|
firstName: row.user.firstName,
|
||||||
|
lastName: row.user.lastName,
|
||||||
|
cell: row.user.cellNumber,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
items,
|
items,
|
||||||
keyPoints:
|
keyPoints:
|
||||||
includeNested && row.keyPoints
|
includeNested && row.keyPoints
|
||||||
@@ -732,7 +906,362 @@ export class InvoicesService {
|
|||||||
return { ok: true };
|
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<InvoicesService['serializeInvoice']>[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) {
|
async getPublicInvoice(publicIdRaw: string) {
|
||||||
const publicId = publicIdRaw.trim();
|
const publicId = publicIdRaw.trim();
|
||||||
@@ -743,7 +1272,6 @@ export class InvoicesService {
|
|||||||
const row = await this.prisma.invoice.findFirst({
|
const row = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: {
|
||||||
publicId,
|
publicId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] },
|
status: { in: [InvoiceStatus.issued, InvoiceStatus.approved, InvoiceStatus.paid] },
|
||||||
},
|
},
|
||||||
include: this.invoiceInclude,
|
include: this.invoiceInclude,
|
||||||
@@ -753,44 +1281,26 @@ export class InvoicesService {
|
|||||||
throw new NotFoundException('Invoice not found');
|
throw new NotFoundException('Invoice not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const serialized = this.serializeInvoice(row, true);
|
return this.toPublicInvoicePayload(row);
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Invoices for a business ---
|
// --- Invoices for a business ---
|
||||||
|
|
||||||
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
|
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const business = await this.prisma.business.findUnique({
|
await this.assertBusinessExists(businessId);
|
||||||
where: { id: businessId },
|
|
||||||
select: { id: true },
|
const access = await this.resolveInvoiceAccess(actor, businessId);
|
||||||
});
|
if (access.mode === 'business') {
|
||||||
if (!business) {
|
await this.assertPermission(businessId, actor.id, 'invoices.read');
|
||||||
throw new NotFoundException('Business not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = Math.min(query.pageSize ?? 20, 100);
|
const pageSize = Math.min(query.pageSize ?? 20, 100);
|
||||||
const where: Prisma.InvoiceWhereInput = {
|
const where: Prisma.InvoiceWhereInput = {
|
||||||
businessId,
|
...this.invoiceScopeWhere(businessId, access.mode),
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
...(query.status ? { status: query.status } : {}),
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.userId ? { userId: BigInt(query.userId) } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [total, rows] = await this.prisma.$transaction([
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
@@ -814,17 +1324,16 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
|
async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const invoiceId = BigInt(invoiceIdRaw);
|
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({
|
const row = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
|
||||||
id: invoiceId,
|
|
||||||
businessId,
|
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
},
|
|
||||||
include: this.invoiceInclude,
|
include: this.invoiceInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -836,17 +1345,16 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createForBusiness(businessIdRaw: string, dto: CreateInvoiceDto, actor: AuthUser) {
|
async createForBusiness(businessIdRaw: string, dto: CreateInvoiceDto, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const business = await this.prisma.business.findUnique({
|
await this.assertBusinessExists(businessId);
|
||||||
where: { id: businessId },
|
|
||||||
select: { id: true },
|
const access = await this.resolveInvoiceAccess(actor, businessId);
|
||||||
});
|
if (access.mode === 'business') {
|
||||||
if (!business) {
|
await this.assertPermission(businessId, actor.id, 'invoices.create');
|
||||||
throw new NotFoundException('Business not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = await this.resolveBilledUserId(businessId, dto.userId, access.mode);
|
||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||||
@@ -854,26 +1362,31 @@ export class InvoicesService {
|
|||||||
? BigInt(dto.invoiceTemplateId)
|
? BigInt(dto.invoiceTemplateId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const ownerScope =
|
||||||
|
access.mode === 'platform' ? InvoiceOwnerScope.platform : InvoiceOwnerScope.business;
|
||||||
|
|
||||||
|
let resolvedInvoiceTemplateId: bigint | null = null;
|
||||||
if (invoiceTemplateId !== null) {
|
if (invoiceTemplateId !== null) {
|
||||||
const template = await this.prisma.invoiceTemplate.findFirst({
|
const template = await this.prisma.invoiceTemplate.findFirst({
|
||||||
where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform },
|
where: { id: invoiceTemplateId, ...this.templateScopeWhere(businessId, ownerScope) },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!template) {
|
// Content is already snapshotted; drop stale / out-of-scope template refs.
|
||||||
throw new BadRequestException('Invoice template not found');
|
resolvedInvoiceTemplateId = template?.id ?? null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.prisma.invoice.create({
|
const row = await this.prisma.invoice.create({
|
||||||
data: {
|
data: {
|
||||||
publicId: await this.generateUniquePublicId(),
|
publicId: await this.generateUniquePublicId(),
|
||||||
businessId,
|
businessId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope,
|
||||||
|
issuerBusinessId: access.mode === 'business' ? businessId : null,
|
||||||
|
userId,
|
||||||
status: dto.status ?? InvoiceStatus.issued,
|
status: dto.status ?? InvoiceStatus.issued,
|
||||||
name: dto.name?.trim() || null,
|
name: dto.name?.trim() || null,
|
||||||
topText: dto.topText?.trim() || null,
|
topText: dto.topText?.trim() || null,
|
||||||
notes: dto.notes?.trim() || null,
|
notes: dto.notes?.trim() || null,
|
||||||
invoiceTemplateId,
|
invoiceTemplateId: resolvedInvoiceTemplateId,
|
||||||
issuedBy: actor.id,
|
issuedBy: actor.id,
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => ({
|
create: items.map((item, index) => ({
|
||||||
@@ -915,17 +1428,16 @@ export class InvoicesService {
|
|||||||
dto: UpdateInvoiceStatusDto,
|
dto: UpdateInvoiceStatusDto,
|
||||||
actor: AuthUser,
|
actor: AuthUser,
|
||||||
) {
|
) {
|
||||||
await this.assertSuperAdmin(actor);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const invoiceId = BigInt(invoiceIdRaw);
|
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({
|
const existing = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
|
||||||
id: invoiceId,
|
|
||||||
businessId,
|
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('Invoice not found');
|
throw new NotFoundException('Invoice not found');
|
||||||
@@ -960,17 +1472,16 @@ export class InvoicesService {
|
|||||||
dto: UpdateInvoiceDto,
|
dto: UpdateInvoiceDto,
|
||||||
actor: AuthUser,
|
actor: AuthUser,
|
||||||
) {
|
) {
|
||||||
await this.assertSuperAdmin(actor);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const invoiceId = BigInt(invoiceIdRaw);
|
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({
|
const existing = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
|
||||||
id: invoiceId,
|
|
||||||
businessId,
|
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('Invoice not found');
|
throw new NotFoundException('Invoice not found');
|
||||||
@@ -983,6 +1494,11 @@ export class InvoicesService {
|
|||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
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;
|
let invoiceTemplateId: bigint | null | undefined;
|
||||||
if (dto.invoiceTemplateId === null) {
|
if (dto.invoiceTemplateId === null) {
|
||||||
invoiceTemplateId = null;
|
invoiceTemplateId = null;
|
||||||
@@ -991,12 +1507,14 @@ export class InvoicesService {
|
|||||||
invoiceTemplateId = trimmed ? BigInt(trimmed) : null;
|
invoiceTemplateId = trimmed ? BigInt(trimmed) : null;
|
||||||
if (invoiceTemplateId !== null) {
|
if (invoiceTemplateId !== null) {
|
||||||
const template = await this.prisma.invoiceTemplate.findFirst({
|
const template = await this.prisma.invoiceTemplate.findFirst({
|
||||||
where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform },
|
where: {
|
||||||
|
id: invoiceTemplateId,
|
||||||
|
...this.templateScopeWhere(businessId, existing.ownerScope),
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!template) {
|
// Content is already snapshotted; drop stale / out-of-scope template refs.
|
||||||
throw new BadRequestException('Invoice template not found');
|
invoiceTemplateId = template?.id ?? null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,6 +1526,7 @@ export class InvoicesService {
|
|||||||
return tx.invoice.update({
|
return tx.invoice.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
|
...(userId !== undefined ? { userId } : {}),
|
||||||
...(dto.name !== undefined ? { name: dto.name?.trim() || null } : {}),
|
...(dto.name !== undefined ? { name: dto.name?.trim() || null } : {}),
|
||||||
...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}),
|
...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}),
|
||||||
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
|
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
|
||||||
@@ -1054,10 +1573,7 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.invoice.findFirst({
|
const existing = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: { publicId },
|
||||||
publicId,
|
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!existing ||
|
!existing ||
|
||||||
@@ -1074,21 +1590,7 @@ export class InvoicesService {
|
|||||||
include: this.invoiceInclude,
|
include: this.invoiceInclude,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('Invoice not found');
|
if (!row) throw new NotFoundException('Invoice not found');
|
||||||
const serialized = this.serializeInvoice(row, true);
|
return this.toPublicInvoicePayload(row);
|
||||||
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({
|
const row = await this.prisma.invoice.update({
|
||||||
@@ -1097,35 +1599,20 @@ export class InvoicesService {
|
|||||||
include: this.invoiceInclude,
|
include: this.invoiceInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialized = this.serializeInvoice(row, true);
|
return this.toPublicInvoicePayload(row);
|
||||||
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);
|
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const invoiceId = BigInt(invoiceIdRaw);
|
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({
|
const existing = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: { id: invoiceId, ...this.invoiceScopeWhere(businessId, access.mode) },
|
||||||
id: invoiceId,
|
|
||||||
businessId,
|
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('Invoice not found');
|
throw new NotFoundException('Invoice not found');
|
||||||
|
|||||||
Reference in New Issue
Block a user