mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Add platform invoices API with item templates and optional name.
Super admins can manage predefined invoice lines and issue invoices to businesses; schema is ready for future business-scoped use. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
136711dfb3
commit
426316d53c
@@ -53,3 +53,6 @@ CENTRAL_API_HOST=api.meshkee.com
|
||||
# Website storefront deploy agent (POST from Super Admin → websites VM)
|
||||
WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy
|
||||
WEBSITE_DEPLOY_TOKEN=
|
||||
|
||||
# Public domain for platform invoice links (https://{domain}/invoices/{id})
|
||||
INVOICE_PUBLIC_DOMAIN=meshkee.com
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
-- Meshkee CMS — invoices (platform / business billing)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- enums
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE invoice_owner_scope AS ENUM ('platform', 'business');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE invoice_status AS ENUM (
|
||||
'draft',
|
||||
'issued',
|
||||
'paid',
|
||||
'cancelled'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- invoice item templates (predefined line items)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE invoice_item_templates (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
owner_scope invoice_owner_scope NOT NULL,
|
||||
business_id BIGINT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
duration VARCHAR(100),
|
||||
worktime VARCHAR(100),
|
||||
description TEXT,
|
||||
price NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
discounted_price NUMERIC(12, 2),
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT invoice_item_templates_business_id_fkey
|
||||
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT invoice_item_templates_title_nonempty
|
||||
CHECK (char_length(trim(title)) > 0),
|
||||
CONSTRAINT invoice_item_templates_price_non_negative
|
||||
CHECK (price >= 0),
|
||||
CONSTRAINT invoice_item_templates_discounted_non_negative
|
||||
CHECK (discounted_price IS NULL OR discounted_price >= 0),
|
||||
CONSTRAINT invoice_item_templates_scope_business_check
|
||||
CHECK (
|
||||
(owner_scope = 'platform' AND business_id IS NULL)
|
||||
OR (owner_scope = 'business' AND business_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_item_templates_owner_scope
|
||||
ON invoice_item_templates (owner_scope, sort_order);
|
||||
CREATE INDEX idx_invoice_item_templates_business_id
|
||||
ON invoice_item_templates (business_id, sort_order)
|
||||
WHERE business_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER invoice_item_templates_set_updated_at
|
||||
BEFORE UPDATE ON invoice_item_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- invoices
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE invoices (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
business_id BIGINT NOT NULL,
|
||||
owner_scope invoice_owner_scope NOT NULL DEFAULT 'platform',
|
||||
issuer_business_id BIGINT,
|
||||
status invoice_status NOT NULL DEFAULT 'issued',
|
||||
notes TEXT,
|
||||
issued_by BIGINT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT invoices_business_id_fkey
|
||||
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT invoices_issuer_business_id_fkey
|
||||
FOREIGN KEY (issuer_business_id) REFERENCES businesses (id) ON DELETE SET NULL,
|
||||
CONSTRAINT invoices_issued_by_fkey
|
||||
FOREIGN KEY (issued_by) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT invoices_scope_issuer_check
|
||||
CHECK (
|
||||
(owner_scope = 'platform' AND issuer_business_id IS NULL)
|
||||
OR (owner_scope = 'business' AND issuer_business_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoices_business_created
|
||||
ON invoices (business_id, created_at DESC);
|
||||
CREATE INDEX idx_invoices_owner_scope
|
||||
ON invoices (owner_scope, created_at DESC);
|
||||
CREATE INDEX idx_invoices_issuer_business_id
|
||||
ON invoices (issuer_business_id, created_at DESC)
|
||||
WHERE issuer_business_id IS NOT NULL;
|
||||
CREATE INDEX idx_invoices_status
|
||||
ON invoices (status);
|
||||
|
||||
CREATE TRIGGER invoices_set_updated_at
|
||||
BEFORE UPDATE ON invoices
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- invoice line items
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE invoice_items (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
invoice_id BIGINT NOT NULL,
|
||||
template_id BIGINT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
duration VARCHAR(100),
|
||||
worktime VARCHAR(100),
|
||||
description TEXT,
|
||||
price NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
discounted_price NUMERIC(12, 2),
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT invoice_items_invoice_id_fkey
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE,
|
||||
CONSTRAINT invoice_items_template_id_fkey
|
||||
FOREIGN KEY (template_id) REFERENCES invoice_item_templates (id) ON DELETE SET NULL,
|
||||
CONSTRAINT invoice_items_title_nonempty
|
||||
CHECK (char_length(trim(title)) > 0),
|
||||
CONSTRAINT invoice_items_price_non_negative
|
||||
CHECK (price >= 0),
|
||||
CONSTRAINT invoice_items_discounted_non_negative
|
||||
CHECK (discounted_price IS NULL OR discounted_price >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_items_invoice_id
|
||||
ON invoice_items (invoice_id, sort_order);
|
||||
CREATE INDEX idx_invoice_items_template_id
|
||||
ON invoice_items (template_id)
|
||||
WHERE template_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER invoice_items_set_updated_at
|
||||
BEFORE UPDATE ON invoice_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- permissions (for future business dashboard use)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO permissions (name, slug, group_name, description) VALUES
|
||||
('View invoices', 'invoices.read', 'invoices', 'View invoices'),
|
||||
('Create invoices', 'invoices.create', 'invoices', 'Create invoices'),
|
||||
('Update invoices', 'invoices.update', 'invoices', 'Update invoices'),
|
||||
('Delete invoices', 'invoices.delete', 'invoices', 'Delete invoices'),
|
||||
('View invoice templates', 'invoice_templates.read', 'invoices', 'View invoice item templates'),
|
||||
('Manage invoice templates', 'invoice_templates.manage', 'invoices', 'Create, update, and delete invoice item templates')
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.slug LIKE 'invoices.%' OR p.slug LIKE 'invoice_templates.%'
|
||||
WHERE r.slug IN ('business_owner', 'owner', 'admin')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Meshkee CMS — optional invoice name
|
||||
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS name VARCHAR(255);
|
||||
+45
-3
@@ -1,7 +1,7 @@
|
||||
# Meshkee CMS API — Project Context
|
||||
|
||||
> Living reference for developers and AI assistants working on this codebase.
|
||||
> Last updated: July 2026
|
||||
> Last updated: July 24, 2026
|
||||
|
||||
## What This Project Is
|
||||
|
||||
@@ -77,6 +77,8 @@ src/
|
||||
├── orders/ # Customer checkout + admin order management
|
||||
├── media/ # Upload/list/update/delete media
|
||||
├── storage/ # S3 driver abstraction
|
||||
├── website-docs/ # Public website API docs pack
|
||||
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
|
||||
├── prisma/ # PrismaModule + PrismaService
|
||||
├── redis/ # Redis client + OTP helpers
|
||||
└── common/ # Shared interceptors (BigInt serialization)
|
||||
@@ -150,7 +152,8 @@ Example super admin: `+989121111111` / `password`
|
||||
| `018_product_variant_reward_points.sql` | Reward points on store items |
|
||||
| `019_cart_and_orders.sql` | Shopping cart, orders, order items + order permissions |
|
||||
| `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) |
|
||||
| `030_website_homepage.sql` | Website category/brand groups, sliders, brand `sort_order` |
|
||||
| `036_invoices.sql` | Invoices, invoice items, invoice item templates + permissions |
|
||||
| `037_invoice_name.sql` | Optional `invoices.name` |
|
||||
|
||||
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
|
||||
|
||||
@@ -167,6 +170,8 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
|
||||
| `VariationType` | `color`, `size`, `custom` |
|
||||
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
|
||||
| `OrderSource` | `website`, `admin` |
|
||||
| `InvoiceOwnerScope` | `platform`, `business` |
|
||||
| `InvoiceStatus` | `draft`, `issued`, `paid`, `cancelled` |
|
||||
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
||||
| `MediaType` | `image`, `video` |
|
||||
|
||||
@@ -194,6 +199,10 @@ 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
|
||||
|
||||
Media 1──* MediaAttachment (polymorphic: entityType + entityId)
|
||||
```
|
||||
|
||||
@@ -553,6 +562,37 @@ See `.env.example` for the full list. Key groups:
|
||||
|
||||
---
|
||||
|
||||
## Invoices (platform / super-admin)
|
||||
|
||||
Super admins issue invoices **to** a business. Schema is ready for future business-scoped issuing (`owner_scope=business`).
|
||||
|
||||
### Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `invoice_item_templates` | Predefined line items (`owner_scope` platform \| business) |
|
||||
| `invoices` | Invoice header (`business_id` = billed party, optional `name`, `notes`, `status`) |
|
||||
| `invoice_items` | Line items (title, duration, worktime, description, price, discounted_price) |
|
||||
|
||||
### API (super_admin only today)
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET/POST | `/invoice-item-templates` |
|
||||
| PATCH/DELETE | `/invoice-item-templates/:templateId` |
|
||||
| GET/POST | `/businesses/:businessId/invoices` |
|
||||
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
|
||||
|
||||
Auth: `JwtAuthGuard` + service `assertSuperAdmin`.
|
||||
|
||||
Serialized platform invoices include `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default domain `meshkee.com`). Public HTML viewer is **not** implemented yet.
|
||||
|
||||
Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`.
|
||||
|
||||
Module: `src/invoices/`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Postman collection:** `postman/Meshkee-CMS-Auth.postman_collection.json`
|
||||
@@ -595,7 +635,9 @@ Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormServic
|
||||
| Purpose | Path |
|
||||
|---------|------|
|
||||
| Prisma schema | `prisma/schema.prisma` |
|
||||
| Env template | `.env.example` |
|
||||
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` for platform invoice links) |
|
||||
| Invoices module | `src/invoices/` |
|
||||
| Invoice migrations | `database/migrations/036_invoices.sql`, `037_invoice_name.sql` |
|
||||
| Docker services | `docker-compose.yml` |
|
||||
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
||||
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
||||
|
||||
@@ -36,6 +36,7 @@ model User {
|
||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||
transactions Transaction[] @relation("TransactionCustomer")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuer")
|
||||
userRoles UserRole[]
|
||||
|
||||
@@index([cellNumber], map: "idx_users_cell_number")
|
||||
@@ -88,6 +89,9 @@ model Business {
|
||||
storeItems StoreItem[]
|
||||
storeSpecials StoreSpecial[]
|
||||
transactions Transaction[]
|
||||
invoices Invoice[] @relation("InvoiceBusiness")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||
invoiceItemTemplates InvoiceItemTemplate[]
|
||||
website_brand_groups website_brand_groups[]
|
||||
website_category_groups website_category_groups[]
|
||||
website_sliders website_sliders[]
|
||||
@@ -1125,3 +1129,86 @@ enum TransactionStatus {
|
||||
|
||||
@@map("transaction_status")
|
||||
}
|
||||
|
||||
enum InvoiceOwnerScope {
|
||||
platform
|
||||
business
|
||||
|
||||
@@map("invoice_owner_scope")
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
draft
|
||||
issued
|
||||
paid
|
||||
cancelled
|
||||
|
||||
@@map("invoice_status")
|
||||
}
|
||||
|
||||
model InvoiceItemTemplate {
|
||||
id BigInt @id @default(autoincrement())
|
||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||
businessId BigInt? @map("business_id")
|
||||
title String @db.VarChar(255)
|
||||
duration String? @db.VarChar(100)
|
||||
worktime String? @db.VarChar(100)
|
||||
description String?
|
||||
price Decimal @default(0) @db.Decimal(12, 2)
|
||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoiceItems InvoiceItem[]
|
||||
|
||||
@@index([ownerScope, sortOrder], map: "idx_invoice_item_templates_owner_scope")
|
||||
@@index([businessId, sortOrder], map: "idx_invoice_item_templates_business_id")
|
||||
@@map("invoice_item_templates")
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
|
||||
issuerBusinessId BigInt? @map("issuer_business_id")
|
||||
status InvoiceStatus @default(issued)
|
||||
name String? @db.VarChar(255)
|
||||
notes String?
|
||||
issuedBy BigInt? @map("issued_by")
|
||||
issuedAt DateTime @default(now()) @map("issued_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)
|
||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction)
|
||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||
items InvoiceItem[]
|
||||
|
||||
@@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
||||
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
||||
@@index([issuerBusinessId, createdAt(sort: Desc)], map: "idx_invoices_issuer_business_id")
|
||||
@@index([status], map: "idx_invoices_status")
|
||||
@@map("invoices")
|
||||
}
|
||||
|
||||
model InvoiceItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
invoiceId BigInt @map("invoice_id")
|
||||
templateId BigInt? @map("template_id")
|
||||
title String @db.VarChar(255)
|
||||
duration String? @db.VarChar(100)
|
||||
worktime String? @db.VarChar(100)
|
||||
description String?
|
||||
price Decimal @default(0) @db.Decimal(12, 2)
|
||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
template InvoiceItemTemplate? @relation(fields: [templateId], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@index([invoiceId, sortOrder], map: "idx_invoice_items_invoice_id")
|
||||
@@index([templateId], map: "idx_invoice_items_template_id")
|
||||
@@map("invoice_items")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { BrandsModule } from './brands/brands.module';
|
||||
import { WebsiteModule } from './website/website.module';
|
||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -66,6 +67,7 @@ import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||
BrandsModule,
|
||||
WebsiteModule,
|
||||
WebsiteDocsModule,
|
||||
InvoicesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { InvoiceStatus } from '@prisma/client';
|
||||
|
||||
export class InvoiceItemInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
duration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
worktime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountedPrice?: number | null;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceItemInputDto)
|
||||
items!: InvoiceItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(InvoiceStatus)
|
||||
status?: InvoiceStatus;
|
||||
}
|
||||
|
||||
export class UpdateInvoiceStatusDto {
|
||||
@IsEnum(InvoiceStatus)
|
||||
status!: InvoiceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ListInvoicesDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(InvoiceStatus)
|
||||
status?: InvoiceStatus;
|
||||
}
|
||||
|
||||
export class CreateInvoiceItemTemplateDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
duration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
worktime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountedPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateInvoiceItemTemplateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
duration?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
worktime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountedPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreateInvoiceItemTemplateDto,
|
||||
ListInvoicesDto,
|
||||
UpdateInvoiceItemTemplateDto,
|
||||
UpdateInvoiceStatusDto,
|
||||
} from './dto/invoice.dto';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Controller()
|
||||
export class InvoicesController {
|
||||
constructor(private readonly service: InvoicesService) {}
|
||||
|
||||
// Platform invoice item templates (settings)
|
||||
@Get('invoice-item-templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listTemplates(@CurrentUser() user: AuthUser) {
|
||||
return this.service.listPlatformTemplates(user);
|
||||
}
|
||||
|
||||
@Post('invoice-item-templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
createTemplate(@Body() dto: CreateInvoiceItemTemplateDto, @CurrentUser() user: AuthUser) {
|
||||
return this.service.createPlatformTemplate(dto, user);
|
||||
}
|
||||
|
||||
@Patch('invoice-item-templates/:templateId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateTemplate(
|
||||
@Param('templateId') templateId: string,
|
||||
@Body() dto: UpdateInvoiceItemTemplateDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updatePlatformTemplate(templateId, dto, user);
|
||||
}
|
||||
|
||||
@Delete('invoice-item-templates/:templateId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
deleteTemplate(@Param('templateId') templateId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.deletePlatformTemplate(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
@@ -0,0 +1,487 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreateInvoiceItemTemplateDto,
|
||||
InvoiceItemInputDto,
|
||||
ListInvoicesDto,
|
||||
UpdateInvoiceItemTemplateDto,
|
||||
UpdateInvoiceStatusDto,
|
||||
} from './dto/invoice.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||
throw new ForbiddenException('Super admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
private serializeTemplate(row: {
|
||||
id: bigint;
|
||||
ownerScope: InvoiceOwnerScope;
|
||||
businessId: bigint | null;
|
||||
title: string;
|
||||
duration: string | null;
|
||||
worktime: string | null;
|
||||
description: string | null;
|
||||
price: Prisma.Decimal;
|
||||
discountedPrice: Prisma.Decimal | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
ownerScope: row.ownerScope,
|
||||
businessId: row.businessId?.toString() ?? null,
|
||||
title: row.title,
|
||||
duration: row.duration,
|
||||
worktime: row.worktime,
|
||||
description: row.description,
|
||||
price: Number(row.price),
|
||||
discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice),
|
||||
sortOrder: row.sortOrder,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeItem(row: {
|
||||
id: bigint;
|
||||
invoiceId: bigint;
|
||||
templateId: bigint | null;
|
||||
title: string;
|
||||
duration: string | null;
|
||||
worktime: string | null;
|
||||
description: string | null;
|
||||
price: Prisma.Decimal;
|
||||
discountedPrice: Prisma.Decimal | null;
|
||||
sortOrder: number;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
invoiceId: row.invoiceId.toString(),
|
||||
templateId: row.templateId?.toString() ?? null,
|
||||
title: row.title,
|
||||
duration: row.duration,
|
||||
worktime: row.worktime,
|
||||
description: row.description,
|
||||
price: Number(row.price),
|
||||
discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice),
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
private platformInvoicePublicUrl(invoiceId: bigint) {
|
||||
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
|
||||
return `https://${domain}/invoices/${invoiceId.toString()}`;
|
||||
}
|
||||
|
||||
private serializeInvoice(
|
||||
row: {
|
||||
id: bigint;
|
||||
businessId: bigint;
|
||||
ownerScope: InvoiceOwnerScope;
|
||||
issuerBusinessId: bigint | null;
|
||||
status: InvoiceStatus;
|
||||
name: string | null;
|
||||
notes: string | null;
|
||||
issuedBy: bigint | null;
|
||||
issuedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
business?: { id: bigint; name: string; nameFa: string | null };
|
||||
issuer?: { id: bigint; firstName: string | null; lastName: string | null } | null;
|
||||
items?: Array<{
|
||||
id: bigint;
|
||||
invoiceId: bigint;
|
||||
templateId: bigint | null;
|
||||
title: string;
|
||||
duration: string | null;
|
||||
worktime: string | null;
|
||||
description: string | null;
|
||||
price: Prisma.Decimal;
|
||||
discountedPrice: Prisma.Decimal | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
},
|
||||
includeItems = true,
|
||||
) {
|
||||
const items = includeItems && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined;
|
||||
const totals = items
|
||||
? items.reduce(
|
||||
(acc, item) => {
|
||||
const effective =
|
||||
item.discountedPrice !== null && item.discountedPrice < item.price
|
||||
? item.discountedPrice
|
||||
: item.price;
|
||||
return {
|
||||
subtotal: acc.subtotal + item.price,
|
||||
total: acc.total + effective,
|
||||
};
|
||||
},
|
||||
{ subtotal: 0, total: 0 },
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
businessId: row.businessId.toString(),
|
||||
ownerScope: row.ownerScope,
|
||||
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
||||
status: row.status,
|
||||
name: row.name,
|
||||
notes: row.notes,
|
||||
publicUrl:
|
||||
row.ownerScope === InvoiceOwnerScope.platform
|
||||
? this.platformInvoicePublicUrl(row.id)
|
||||
: null,
|
||||
issuedBy: row.issuedBy?.toString() ?? null,
|
||||
issuedAt: row.issuedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
business: row.business
|
||||
? {
|
||||
id: row.business.id.toString(),
|
||||
name: row.business.name,
|
||||
nameFa: row.business.nameFa,
|
||||
}
|
||||
: undefined,
|
||||
issuer: row.issuer
|
||||
? {
|
||||
id: row.issuer.id.toString(),
|
||||
firstName: row.issuer.firstName,
|
||||
lastName: row.issuer.lastName,
|
||||
}
|
||||
: null,
|
||||
items,
|
||||
subtotal: totals?.subtotal,
|
||||
total: totals?.total,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeItemInput(item: InvoiceItemInputDto) {
|
||||
const title = item.title.trim();
|
||||
if (!title) {
|
||||
throw new BadRequestException('Each invoice item requires a title');
|
||||
}
|
||||
|
||||
const discountedPrice =
|
||||
item.discountedPrice === undefined || item.discountedPrice === null
|
||||
? null
|
||||
: item.discountedPrice;
|
||||
|
||||
if (discountedPrice !== null && discountedPrice > item.price) {
|
||||
throw new BadRequestException('Discounted price cannot exceed price');
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: item.templateId?.trim() ? BigInt(item.templateId) : null,
|
||||
title,
|
||||
duration: item.duration?.trim() || null,
|
||||
worktime: item.worktime?.trim() || null,
|
||||
description: item.description?.trim() || null,
|
||||
price: item.price,
|
||||
discountedPrice,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Templates (platform settings for now) ---
|
||||
|
||||
async listPlatformTemplates(actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const rows = await this.prisma.invoiceItemTemplate.findMany({
|
||||
where: { ownerScope: InvoiceOwnerScope.platform },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
return { items: rows.map((row) => this.serializeTemplate(row)) };
|
||||
}
|
||||
|
||||
async createPlatformTemplate(dto: CreateInvoiceItemTemplateDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
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.platform,
|
||||
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 updatePlatformTemplate(
|
||||
templateIdRaw: string,
|
||||
dto: UpdateInvoiceItemTemplateDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const templateId = BigInt(templateIdRaw);
|
||||
const existing = await this.prisma.invoiceItemTemplate.findFirst({
|
||||
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
|
||||
});
|
||||
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 deletePlatformTemplate(templateIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const templateId = BigInt(templateIdRaw);
|
||||
const existing = await this.prisma.invoiceItemTemplate.findFirst({
|
||||
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Invoice item template not found');
|
||||
}
|
||||
|
||||
await this.prisma.invoiceItemTemplate.delete({ where: { id: templateId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- 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');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 20, 100);
|
||||
const where: Prisma.InvoiceWhereInput = {
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
};
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.invoice.count({ where }),
|
||||
this.prisma.invoice.findMany({
|
||||
where,
|
||||
include: {
|
||||
business: { select: { id: true, name: true, nameFa: true } },
|
||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.serializeInvoice(row)),
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const invoiceId = BigInt(invoiceIdRaw);
|
||||
|
||||
const row = await this.prisma.invoice.findFirst({
|
||||
where: {
|
||||
id: invoiceId,
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
},
|
||||
include: {
|
||||
business: { select: { id: true, name: true, nameFa: true } },
|
||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
return this.serializeInvoice(row);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||
|
||||
const row = await this.prisma.invoice.create({
|
||||
data: {
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
status: dto.status ?? InvoiceStatus.issued,
|
||||
name: dto.name?.trim() || null,
|
||||
notes: dto.notes?.trim() || null,
|
||||
issuedBy: actor.id,
|
||||
items: {
|
||||
create: items.map((item, index) => ({
|
||||
templateId: item.templateId,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
worktime: item.worktime,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
discountedPrice: item.discountedPrice,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
business: { select: { id: true, name: true, nameFa: true } },
|
||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
});
|
||||
|
||||
return this.serializeInvoice(row);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
businessIdRaw: string,
|
||||
invoiceIdRaw: string,
|
||||
dto: UpdateInvoiceStatusDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const invoiceId = BigInt(invoiceIdRaw);
|
||||
|
||||
const existing = await this.prisma.invoice.findFirst({
|
||||
where: {
|
||||
id: invoiceId,
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
const row = await this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status: dto.status,
|
||||
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
|
||||
},
|
||||
include: {
|
||||
business: { select: { id: true, name: true, nameFa: true } },
|
||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
});
|
||||
|
||||
return this.serializeInvoice(row);
|
||||
}
|
||||
|
||||
async deleteInvoice(businessIdRaw: string, invoiceIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const invoiceId = BigInt(invoiceIdRaw);
|
||||
|
||||
const existing = await this.prisma.invoice.findFirst({
|
||||
where: {
|
||||
id: invoiceId,
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
await this.prisma.invoice.delete({ where: { id: invoiceId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user