diff --git a/.env.example b/.env.example index 551c18f..37204e2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/database/migrations/036_invoices.sql b/database/migrations/036_invoices.sql new file mode 100644 index 0000000..f57fb4f --- /dev/null +++ b/database/migrations/036_invoices.sql @@ -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; diff --git a/database/migrations/037_invoice_name.sql b/database/migrations/037_invoice_name.sql new file mode 100644 index 0000000..5a21c1a --- /dev/null +++ b/database/migrations/037_invoice_name.sql @@ -0,0 +1,4 @@ +-- Meshkee CMS — optional invoice name + +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS name VARCHAR(255); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 88874bc..8b57bcf 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: 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` | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 259d14b..3763c25 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/app.module.ts b/src/app.module.ts index 0002868..fd48db9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts new file mode 100644 index 0000000..3207db4 --- /dev/null +++ b/src/invoices/dto/invoice.dto.ts @@ -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; +} diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts new file mode 100644 index 0000000..0ffa5b4 --- /dev/null +++ b/src/invoices/invoices.controller.ts @@ -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); + } +} diff --git a/src/invoices/invoices.module.ts b/src/invoices/invoices.module.ts new file mode 100644 index 0000000..d76ea85 --- /dev/null +++ b/src/invoices/invoices.module.ts @@ -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 {} diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts new file mode 100644 index 0000000..6c5c967 --- /dev/null +++ b/src/invoices/invoices.service.ts @@ -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 }; + } +}