From 3faeb9bc0d596e0e44e2d8cc51146ec8ca3b3aa1 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 26 Jul 2026 11:04:26 +0330 Subject: [PATCH] Expand invoices with full templates, public viewer API, and account holder. Add invoice template CRUD, key points/accounts, public GET endpoint, and migration 039 for account_holder_name. Co-authored-by: Cursor --- .cursor/rules/env-clients-deploy.mdc | 34 ++ .cursor/rules/meshkee-project.mdc | 2 + .cursor/rules/website-api-docs.mdc | 29 + .env.example | 2 + database/migrations/038_invoice_templates.sql | 190 +++++++ .../migrations/039_invoice_account_holder.sql | 7 + docs/PROJECT_CONTEXT.md | 23 +- docs/website-api/index.html | 8 +- prisma/schema.prisma | 137 ++++- src/invoices/dto/invoice.dto.ts | 158 ++++++ src/invoices/invoices.controller.ts | 43 ++ src/invoices/invoices.service.ts | 505 +++++++++++++++++- src/website-docs/static/index.html | 8 +- 13 files changed, 1094 insertions(+), 52 deletions(-) create mode 100644 .cursor/rules/env-clients-deploy.mdc create mode 100644 .cursor/rules/website-api-docs.mdc create mode 100644 database/migrations/038_invoice_templates.sql create mode 100644 database/migrations/039_invoice_account_holder.sql diff --git a/.cursor/rules/env-clients-deploy.mdc b/.cursor/rules/env-clients-deploy.mdc new file mode 100644 index 0000000..c2adc39 --- /dev/null +++ b/.cursor/rules/env-clients-deploy.mdc @@ -0,0 +1,34 @@ +--- +description: Environment split — websites always use remote API; dashboards use local API in dev and remote after deploy; coordinate backend + dashboard releases. +alwaysApply: true +--- + +# Backend ↔ dashboards ↔ websites + +## Who talks to which API + +| Client | Local development | After deploy (production) | +|--------|-------------------|---------------------------| +| **Business websites** (storefronts) | Still **remote** `https://api.meshkee.com` (or `https://api.{domain}`) | Same remote API | +| **Dashboards** (manage / business / customer) | **Local** backend (`localhost`) | **Remote** `https://api.meshkee.com` | + +Websites never depend on a developer’s local API. Dashboards do during local work. + +## Release coordination + +Because deployed dashboards hit the **same** remote backend as production websites: + +1. **Dashboard-facing API changes** (routes under auth/CMS/`businesses/:id/...` used by dashboards) must ship **backend + dashboards together** (or backend first only if fully backward-compatible with the currently deployed dashboards). +2. Do **not** deploy a dashboard that requires new backend fields/routes until that backend is live on `api.meshkee.com`. +3. Do **not** deploy a breaking backend change for dashboards until the matching dashboard build is ready to deploy in the same window. +4. **Website-facing** API changes go live for all storefronts as soon as the backend is deployed — keep `docs/website-api/` in sync (see `website-api-docs.mdc`). Prefer additive/backward-compatible changes when old websites may still be out. + +## Practical order + +```text +Compatible API add → deploy backend → deploy dashboards (can use new APIs) +Breaking API change → deploy backend + dashboards in one coordinated release +Website-only API → deploy backend (+ update website docs); websites pick it up from remote +``` + +Local dashboard work: run local backend; point dashboard env at local API. After merge/deploy, dashboards use remote API again. diff --git a/.cursor/rules/meshkee-project.mdc b/.cursor/rules/meshkee-project.mdc index 04ad6d3..52dd837 100644 --- a/.cursor/rules/meshkee-project.mdc +++ b/.cursor/rules/meshkee-project.mdc @@ -34,3 +34,5 @@ Do **not** use Prisma Migrate. SQL migrations are authoritative. - Reuse existing services/guards instead of reimplementing - No commits unless explicitly requested - **Production deploy:** push to git first, then pull/build on the API VM — see `.cursor/rules/git-deploy.mdc` (never rsync as the normal path) +- **Website API docs:** when storefront APIs change, update `docs/website-api/` + `src/website-docs/static/` — see `.cursor/rules/website-api-docs.mdc` +- **Clients:** websites always use remote API; dashboards use local API in dev and remote after deploy — coordinate dashboard releases with backend — see `.cursor/rules/env-clients-deploy.mdc` diff --git a/.cursor/rules/website-api-docs.mdc b/.cursor/rules/website-api-docs.mdc new file mode 100644 index 0000000..5f19875 --- /dev/null +++ b/.cursor/rules/website-api-docs.mdc @@ -0,0 +1,29 @@ +--- +description: Keep the public Website API docs pack in sync whenever storefront-facing backend APIs change. +alwaysApply: true +--- + +# Website API docs sync + +Whenever you add, change, or remove **storefront / website-facing** APIs (public `tenants/:host/...`, customer auth/addresses, cities, cart, orders, favorites), update the public docs pack in the **same change**. + +## Files to keep in sync (both copies) + +1. `docs/website-api/` — source of truth for humans / git +2. `src/website-docs/static/` — served by Nest at `/docs/website` (must match) + +Update as needed: + +| File | When | +|------|------| +| `openapi.json` | Paths, methods, params, bodies, or auth change | +| `Meshkee-Website-API.postman_collection.json` | Same — add/rename/fix requests | +| `AI_PROMPT.md` | Flow or hard rules change | +| `index.html` | Hub copy / links (use **absolute** `/docs/website/...` hrefs, never `./`) | + +## Rules + +- Docs are **domain-agnostic** (`YOUR_WEBSITE_DOMAIN` / `{domain}`), not tied to one business. +- Do **not** document CMS/dashboard/super-admin routes in this pack. +- After editing `docs/website-api/*`, copy the same files into `src/website-docs/static/`. +- Prefer updating docs in the same PR/commit as the API change; don’t leave storefront contract drift. diff --git a/.env.example b/.env.example index 37204e2..cf5e620 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,5 @@ WEBSITE_DEPLOY_TOKEN= # Public domain for platform invoice links (https://{domain}/invoices/{id}) INVOICE_PUBLIC_DOMAIN=meshkee.com +# Optional full origin for local (overrides domain), e.g. https://meshkee.app:5174 +# INVOICE_PUBLIC_BASE_URL=https://meshkee.app:5174 diff --git a/database/migrations/038_invoice_templates.sql b/database/migrations/038_invoice_templates.sql new file mode 100644 index 0000000..406a49d --- /dev/null +++ b/database/migrations/038_invoice_templates.sql @@ -0,0 +1,190 @@ +-- Meshkee CMS — full invoice templates + invoice key points / accounts + +-- --------------------------------------------------------------------------- +-- invoice templates (full blueprint: name, top text, items, key points, accounts) +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_templates ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner_scope invoice_owner_scope NOT NULL, + business_id BIGINT, + name VARCHAR(255) NOT NULL, + top_text TEXT, + 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_templates_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT invoice_templates_name_nonempty + CHECK (char_length(trim(name)) > 0), + CONSTRAINT invoice_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_templates_owner_scope + ON invoice_templates (owner_scope, sort_order); +CREATE INDEX idx_invoice_templates_business_id + ON invoice_templates (business_id, sort_order) + WHERE business_id IS NOT NULL; + +CREATE TRIGGER invoice_templates_set_updated_at + BEFORE UPDATE ON invoice_templates + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- template line items +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_template_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + template_id BIGINT NOT NULL, + item_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_template_items_template_id_fkey + FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE, + CONSTRAINT invoice_template_items_item_template_id_fkey + FOREIGN KEY (item_template_id) REFERENCES invoice_item_templates (id) ON DELETE SET NULL, + CONSTRAINT invoice_template_items_title_nonempty + CHECK (char_length(trim(title)) > 0), + CONSTRAINT invoice_template_items_price_non_negative + CHECK (price >= 0), + CONSTRAINT invoice_template_items_discounted_non_negative + CHECK (discounted_price IS NULL OR discounted_price >= 0) +); + +CREATE INDEX idx_invoice_template_items_template_id + ON invoice_template_items (template_id, sort_order); + +CREATE TRIGGER invoice_template_items_set_updated_at + BEFORE UPDATE ON invoice_template_items + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- template key points (duplicatable) +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_template_key_points ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + template_id BIGINT NOT NULL, + text TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_template_key_points_template_id_fkey + FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE, + CONSTRAINT invoice_template_key_points_text_nonempty + CHECK (char_length(trim(text)) > 0) +); + +CREATE INDEX idx_invoice_template_key_points_template_id + ON invoice_template_key_points (template_id, sort_order); + +CREATE TRIGGER invoice_template_key_points_set_updated_at + BEFORE UPDATE ON invoice_template_key_points + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- template bank accounts (duplicatable) +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_template_accounts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + template_id BIGINT NOT NULL, + bank_name VARCHAR(255) NOT NULL, + card_number VARCHAR(64), + iban VARCHAR(64), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_template_accounts_template_id_fkey + FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE, + CONSTRAINT invoice_template_accounts_bank_name_nonempty + CHECK (char_length(trim(bank_name)) > 0) +); + +CREATE INDEX idx_invoice_template_accounts_template_id + ON invoice_template_accounts (template_id, sort_order); + +CREATE TRIGGER invoice_template_accounts_set_updated_at + BEFORE UPDATE ON invoice_template_accounts + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- extend issued invoices +-- --------------------------------------------------------------------------- +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS top_text TEXT, + ADD COLUMN IF NOT EXISTS invoice_template_id BIGINT; + +ALTER TABLE invoices + DROP CONSTRAINT IF EXISTS invoices_invoice_template_id_fkey; + +ALTER TABLE invoices + ADD CONSTRAINT invoices_invoice_template_id_fkey + FOREIGN KEY (invoice_template_id) REFERENCES invoice_templates (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_invoices_invoice_template_id + ON invoices (invoice_template_id) + WHERE invoice_template_id IS NOT NULL; + +CREATE TABLE invoice_key_points ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + invoice_id BIGINT NOT NULL, + text TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_key_points_invoice_id_fkey + FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE, + CONSTRAINT invoice_key_points_text_nonempty + CHECK (char_length(trim(text)) > 0) +); + +CREATE INDEX idx_invoice_key_points_invoice_id + ON invoice_key_points (invoice_id, sort_order); + +CREATE TRIGGER invoice_key_points_set_updated_at + BEFORE UPDATE ON invoice_key_points + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE invoice_accounts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + invoice_id BIGINT NOT NULL, + bank_name VARCHAR(255) NOT NULL, + card_number VARCHAR(64), + iban VARCHAR(64), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_accounts_invoice_id_fkey + FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE, + CONSTRAINT invoice_accounts_bank_name_nonempty + CHECK (char_length(trim(bank_name)) > 0) +); + +CREATE INDEX idx_invoice_accounts_invoice_id + ON invoice_accounts (invoice_id, sort_order); + +CREATE TRIGGER invoice_accounts_set_updated_at + BEFORE UPDATE ON invoice_accounts + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/database/migrations/039_invoice_account_holder.sql b/database/migrations/039_invoice_account_holder.sql new file mode 100644 index 0000000..952eae1 --- /dev/null +++ b/database/migrations/039_invoice_account_holder.sql @@ -0,0 +1,7 @@ +-- Add account holder name to invoice / template bank accounts + +ALTER TABLE invoice_accounts + ADD COLUMN IF NOT EXISTS account_holder_name VARCHAR(255); + +ALTER TABLE invoice_template_accounts + ADD COLUMN IF NOT EXISTS account_holder_name VARCHAR(255); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 8b57bcf..99f0435 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -154,6 +154,8 @@ Example super admin: `+989121111111` / `password` | `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) | | `036_invoices.sql` | Invoices, invoice items, invoice item templates + permissions | | `037_invoice_name.sql` | Optional `invoices.name` | +| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices | +| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts | Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations. @@ -571,8 +573,10 @@ Super admins issue invoices **to** a business. Schema is ready for future busine | 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) | +| `invoice_templates` | Full blueprints: name, top_text | +| `invoice_template_items` / `_key_points` / `_accounts` | Nested template content | +| `invoices` | Invoice header (`business_id` = billed party, optional `name`, `top_text`, `notes`, `invoice_template_id`, `status`) | +| `invoice_items` / `invoice_key_points` / `invoice_accounts` | Issued invoice nested content | ### API (super_admin only today) @@ -580,16 +584,21 @@ Super admins issue invoices **to** a business. Schema is ready for future busine |--------|------| | GET/POST | `/invoice-item-templates` | | PATCH/DELETE | `/invoice-item-templates/:templateId` | +| GET/POST | `/invoice-templates` | +| GET/PATCH/DELETE | `/invoice-templates/:templateId` | | GET/POST | `/businesses/:businessId/invoices` | | GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` | +| GET | `/public/invoices/:invoiceId` (no auth; issued/paid only) | -Auth: `JwtAuthGuard` + service `assertSuperAdmin`. +Auth (admin routes): `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. +Serialized platform invoices include `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{id}` when set. Accounts include optional `accountHolderName`. + +Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:id`); API serves JSON via `/public/invoices/:id`. Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`. -Module: `src/invoices/` +Module: `src/invoices/` · Migrations: `036`, `037`, `038`, `039` --- @@ -635,9 +644,9 @@ Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormServic | Purpose | Path | |---------|------| | Prisma schema | `prisma/schema.prisma` | -| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` for platform invoice links) | +| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) | | Invoices module | `src/invoices/` | -| Invoice migrations | `database/migrations/036_invoices.sql`, `037_invoice_name.sql` | +| Invoice migrations | `database/migrations/036_invoices.sql` … `039_invoice_account_holder.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/docs/website-api/index.html b/docs/website-api/index.html index 493f03a..9191fb0 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -74,9 +74,9 @@

Global links (share these with designers & AI tools):

- OpenAPI JSON - Download Postman - AI prompt + OpenAPI JSON + Download Postman + AI prompt

Base URL

@@ -93,7 +93,7 @@

For a new website AI / designer

    -
  1. Open AI_PROMPT.md and paste it into the AI chat.
  2. +
  3. Open AI_PROMPT.md and paste it into the AI chat.
  4. Replace <WEBSITE_DOMAIN> with that site’s apex.
  5. Import the Postman collection (set domain, run Resolve tenant).
  6. Or feed openapi.json to the AI / codegen tool.
  7. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3763c25..d898b16 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,6 +92,7 @@ model Business { invoices Invoice[] @relation("InvoiceBusiness") invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness") invoiceItemTemplates InvoiceItemTemplate[] + invoiceTemplates InvoiceTemplate[] website_brand_groups website_brand_groups[] website_category_groups website_category_groups[] website_sliders website_sliders[] @@ -1147,27 +1148,98 @@ enum InvoiceStatus { } 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[] + 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[] + invoiceTemplateItems InvoiceTemplateItem[] @@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 InvoiceTemplate { + id BigInt @id @default(autoincrement()) + ownerScope InvoiceOwnerScope @map("owner_scope") + businessId BigInt? @map("business_id") + name String @db.VarChar(255) + topText String? @map("top_text") + 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) + items InvoiceTemplateItem[] + keyPoints InvoiceTemplateKeyPoint[] + accounts InvoiceTemplateAccount[] + invoices Invoice[] + + @@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope") + @@index([businessId, sortOrder], map: "idx_invoice_templates_business_id") + @@map("invoice_templates") +} + +model InvoiceTemplateItem { + id BigInt @id @default(autoincrement()) + templateId BigInt @map("template_id") + itemTemplateId BigInt? @map("item_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) + template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction) + itemTemplate InvoiceItemTemplate? @relation(fields: [itemTemplateId], references: [id], onUpdate: NoAction) + + @@index([templateId, sortOrder], map: "idx_invoice_template_items_template_id") + @@map("invoice_template_items") +} + +model InvoiceTemplateKeyPoint { + id BigInt @id @default(autoincrement()) + templateId BigInt @map("template_id") + text String + 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) + template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([templateId, sortOrder], map: "idx_invoice_template_key_points_template_id") + @@map("invoice_template_key_points") +} + +model InvoiceTemplateAccount { + id BigInt @id @default(autoincrement()) + templateId BigInt @map("template_id") + bankName String @map("bank_name") @db.VarChar(255) + accountHolderName String? @map("account_holder_name") @db.VarChar(255) + cardNumber String? @map("card_number") @db.VarChar(64) + iban String? @db.VarChar(64) + 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) + template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([templateId, sortOrder], map: "idx_invoice_template_accounts_template_id") + @@map("invoice_template_accounts") +} + model Invoice { id BigInt @id @default(autoincrement()) businessId BigInt @map("business_id") @@ -1175,7 +1247,9 @@ model Invoice { issuerBusinessId BigInt? @map("issuer_business_id") status InvoiceStatus @default(issued) name String? @db.VarChar(255) + topText String? @map("top_text") notes String? + invoiceTemplateId BigInt? @map("invoice_template_id") 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) @@ -1183,12 +1257,16 @@ model Invoice { 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) + invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction) items InvoiceItem[] + keyPoints InvoiceKeyPoint[] + accounts InvoiceAccount[] @@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") + @@index([invoiceTemplateId], map: "idx_invoices_invoice_template_id") @@map("invoices") } @@ -1212,3 +1290,32 @@ model InvoiceItem { @@index([templateId], map: "idx_invoice_items_template_id") @@map("invoice_items") } + +model InvoiceKeyPoint { + id BigInt @id @default(autoincrement()) + invoiceId BigInt @map("invoice_id") + text String + 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) + + @@index([invoiceId, sortOrder], map: "idx_invoice_key_points_invoice_id") + @@map("invoice_key_points") +} + +model InvoiceAccount { + id BigInt @id @default(autoincrement()) + invoiceId BigInt @map("invoice_id") + bankName String @map("bank_name") @db.VarChar(255) + accountHolderName String? @map("account_holder_name") @db.VarChar(255) + cardNumber String? @map("card_number") @db.VarChar(64) + iban String? @db.VarChar(64) + 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) + + @@index([invoiceId, sortOrder], map: "idx_invoice_accounts_invoice_id") + @@map("invoice_accounts") +} diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts index 3207db4..253a208 100644 --- a/src/invoices/dto/invoice.dto.ts +++ b/src/invoices/dto/invoice.dto.ts @@ -51,6 +51,34 @@ export class InvoiceItemInputDto { discountedPrice?: number | null; } +export class InvoiceKeyPointInputDto { + @IsString() + @MinLength(1) + text!: string; +} + +export class InvoiceAccountInputDto { + @IsString() + @MinLength(1) + @MaxLength(255) + bankName!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + accountHolderName?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + cardNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + iban?: string; +} + export class CreateInvoiceDto { @IsArray() @ArrayMinSize(1) @@ -63,10 +91,30 @@ export class CreateInvoiceDto { @MaxLength(255) name?: string; + @IsOptional() + @IsString() + topText?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() + @IsString() + invoiceTemplateId?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceKeyPointInputDto) + keyPoints?: InvoiceKeyPointInputDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceAccountInputDto) + accounts?: InvoiceAccountInputDto[]; + @IsOptional() @IsEnum(InvoiceStatus) status?: InvoiceStatus; @@ -178,3 +226,113 @@ export class UpdateInvoiceItemTemplateDto { @IsBoolean() isActive?: boolean; } + +export class InvoiceTemplateItemInputDto { + @IsOptional() + @IsString() + itemTemplateId?: 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 CreateInvoiceTemplateDto { + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsOptional() + @IsString() + topText?: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceTemplateItemInputDto) + items!: InvoiceTemplateItemInputDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceKeyPointInputDto) + keyPoints?: InvoiceKeyPointInputDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceAccountInputDto) + accounts?: InvoiceAccountInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; +} + +export class UpdateInvoiceTemplateDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + topText?: string | null; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceTemplateItemInputDto) + items?: InvoiceTemplateItemInputDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceKeyPointInputDto) + keyPoints?: InvoiceKeyPointInputDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoiceAccountInputDto) + accounts?: InvoiceAccountInputDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index 0ffa5b4..b4f274d 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -15,9 +15,11 @@ import { AuthUser } from '../auth/auth.types'; import { CreateInvoiceDto, CreateInvoiceItemTemplateDto, + CreateInvoiceTemplateDto, ListInvoicesDto, UpdateInvoiceItemTemplateDto, UpdateInvoiceStatusDto, + UpdateInvoiceTemplateDto, } from './dto/invoice.dto'; import { InvoicesService } from './invoices.service'; @@ -54,6 +56,41 @@ export class InvoicesController { return this.service.deletePlatformTemplate(templateId, user); } + // Platform invoice templates (settings) + @Get('invoice-templates') + @UseGuards(JwtAuthGuard) + listInvoiceTemplates(@CurrentUser() user: AuthUser) { + return this.service.listPlatformInvoiceTemplates(user); + } + + @Post('invoice-templates') + @UseGuards(JwtAuthGuard) + createInvoiceTemplate(@Body() dto: CreateInvoiceTemplateDto, @CurrentUser() user: AuthUser) { + return this.service.createPlatformInvoiceTemplate(dto, user); + } + + @Get('invoice-templates/:templateId') + @UseGuards(JwtAuthGuard) + getInvoiceTemplate(@Param('templateId') templateId: string, @CurrentUser() user: AuthUser) { + return this.service.getPlatformInvoiceTemplate(templateId, user); + } + + @Patch('invoice-templates/:templateId') + @UseGuards(JwtAuthGuard) + updateInvoiceTemplate( + @Param('templateId') templateId: string, + @Body() dto: UpdateInvoiceTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updatePlatformInvoiceTemplate(templateId, dto, user); + } + + @Delete('invoice-templates/:templateId') + @UseGuards(JwtAuthGuard) + deleteInvoiceTemplate(@Param('templateId') templateId: string, @CurrentUser() user: AuthUser) { + return this.service.deletePlatformInvoiceTemplate(templateId, user); + } + // Business invoices @Get('businesses/:businessId/invoices') @UseGuards(JwtAuthGuard) @@ -105,4 +142,10 @@ export class InvoicesController { ) { return this.service.deleteInvoice(businessId, invoiceId, user); } + + /** Public invoice show page (no auth). Issued / paid platform invoices only. */ + @Get('public/invoices/:invoiceId') + getPublic(@Param('invoiceId') invoiceId: string) { + return this.service.getPublicInvoice(invoiceId); + } } diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 6c5c967..1fa8bdf 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -11,10 +11,15 @@ import { PrismaService } from '../prisma/prisma.service'; import { CreateInvoiceDto, CreateInvoiceItemTemplateDto, + CreateInvoiceTemplateDto, + InvoiceAccountInputDto, InvoiceItemInputDto, + InvoiceKeyPointInputDto, + InvoiceTemplateItemInputDto, ListInvoicesDto, UpdateInvoiceItemTemplateDto, UpdateInvoiceStatusDto, + UpdateInvoiceTemplateDto, } from './dto/invoice.dto'; @Injectable() @@ -62,6 +67,140 @@ export class InvoicesService { }; } + private readonly invoiceInclude = { + business: { select: { id: true, name: true, nameFa: true } }, + issuer: { select: { id: true, firstName: true, lastName: true } }, + items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }; + + private readonly invoiceTemplateInclude = { + items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }; + + private serializeKeyPoint(row: { + id: bigint; + text: string; + sortOrder: number; + }) { + return { + id: row.id.toString(), + text: row.text, + sortOrder: row.sortOrder, + }; + } + + private serializeAccount(row: { + id: bigint; + bankName: string; + accountHolderName: string | null; + cardNumber: string | null; + iban: string | null; + sortOrder: number; + }) { + return { + id: row.id.toString(), + bankName: row.bankName, + accountHolderName: row.accountHolderName, + cardNumber: row.cardNumber, + iban: row.iban, + sortOrder: row.sortOrder, + }; + } + + private serializeTemplateItem(row: { + id: bigint; + templateId: bigint; + itemTemplateId: 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(), + templateId: row.templateId.toString(), + itemTemplateId: row.itemTemplateId?.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 serializeInvoiceTemplate( + row: { + id: bigint; + ownerScope: InvoiceOwnerScope; + businessId: bigint | null; + name: string; + topText: string | null; + sortOrder: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + items?: Array<{ + id: bigint; + templateId: bigint; + itemTemplateId: bigint | null; + title: string; + duration: string | null; + worktime: string | null; + description: string | null; + price: Prisma.Decimal; + discountedPrice: Prisma.Decimal | null; + sortOrder: number; + }>; + keyPoints?: Array<{ + id: bigint; + text: string; + sortOrder: number; + }>; + accounts?: Array<{ + id: bigint; + bankName: string; + accountHolderName: string | null; + cardNumber: string | null; + iban: string | null; + sortOrder: number; + }>; + }, + includeNested = true, + ) { + return { + id: row.id.toString(), + ownerScope: row.ownerScope, + businessId: row.businessId?.toString() ?? null, + name: row.name, + topText: row.topText, + sortOrder: row.sortOrder, + isActive: row.isActive, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + items: + includeNested && row.items + ? row.items.map((item) => this.serializeTemplateItem(item)) + : undefined, + keyPoints: + includeNested && row.keyPoints + ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) + : undefined, + accounts: + includeNested && row.accounts + ? row.accounts.map((acc) => this.serializeAccount(acc)) + : undefined, + }; + } + private serializeItem(row: { id: bigint; invoiceId: bigint; @@ -89,6 +228,10 @@ export class InvoicesService { } private platformInvoicePublicUrl(invoiceId: bigint) { + const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim(); + if (base) { + return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`; + } const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'; return `https://${domain}/invoices/${invoiceId.toString()}`; } @@ -101,7 +244,9 @@ export class InvoicesService { issuerBusinessId: bigint | null; status: InvoiceStatus; name: string | null; + topText: string | null; notes: string | null; + invoiceTemplateId: bigint | null; issuedBy: bigint | null; issuedAt: Date; createdAt: Date; @@ -120,10 +265,24 @@ export class InvoicesService { discountedPrice: Prisma.Decimal | null; sortOrder: number; }>; + keyPoints?: Array<{ + id: bigint; + text: string; + sortOrder: number; + }>; + accounts?: Array<{ + id: bigint; + bankName: string; + accountHolderName: string | null; + cardNumber: string | null; + iban: string | null; + sortOrder: number; + }>; }, - includeItems = true, + includeNested = true, ) { - const items = includeItems && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined; + const items = + includeNested && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined; const totals = items ? items.reduce( (acc, item) => { @@ -147,7 +306,9 @@ export class InvoicesService { issuerBusinessId: row.issuerBusinessId?.toString() ?? null, status: row.status, name: row.name, + topText: row.topText, notes: row.notes, + invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null, publicUrl: row.ownerScope === InvoiceOwnerScope.platform ? this.platformInvoicePublicUrl(row.id) @@ -171,11 +332,66 @@ export class InvoicesService { } : null, items, + keyPoints: + includeNested && row.keyPoints + ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) + : undefined, + accounts: + includeNested && row.accounts + ? row.accounts.map((acc) => this.serializeAccount(acc)) + : undefined, subtotal: totals?.subtotal, total: totals?.total, }; } + private normalizeKeyPointInput(keyPoint: InvoiceKeyPointInputDto) { + const text = keyPoint.text.trim(); + if (!text) { + throw new BadRequestException('Each key point requires text'); + } + return { text }; + } + + private normalizeAccountInput(account: InvoiceAccountInputDto) { + const bankName = account.bankName.trim(); + if (!bankName) { + throw new BadRequestException('Each account requires a bank name'); + } + return { + bankName, + accountHolderName: account.accountHolderName?.trim() || null, + cardNumber: account.cardNumber?.trim() || null, + iban: account.iban?.trim() || null, + }; + } + + private normalizeTemplateItemInput(item: InvoiceTemplateItemInputDto) { + const title = item.title.trim(); + if (!title) { + throw new BadRequestException('Each invoice template 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 { + itemTemplateId: item.itemTemplateId?.trim() ? BigInt(item.itemTemplateId) : null, + title, + duration: item.duration?.trim() || null, + worktime: item.worktime?.trim() || null, + description: item.description?.trim() || null, + price: item.price, + discountedPrice, + }; + } + private normalizeItemInput(item: InvoiceItemInputDto) { const title = item.title.trim(); if (!title) { @@ -309,6 +525,235 @@ export class InvoicesService { return { ok: true }; } + // --- Invoice templates (platform settings) --- + + async listPlatformInvoiceTemplates(actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const rows = await this.prisma.invoiceTemplate.findMany({ + where: { ownerScope: InvoiceOwnerScope.platform }, + include: this.invoiceTemplateInclude, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + }); + + return { items: rows.map((row) => this.serializeInvoiceTemplate(row)) }; + } + + async getPlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const templateId = BigInt(templateIdRaw); + const row = await this.prisma.invoiceTemplate.findFirst({ + where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, + include: this.invoiceTemplateInclude, + }); + + if (!row) { + throw new NotFoundException('Invoice template not found'); + } + + return this.serializeInvoiceTemplate(row); + } + + async createPlatformInvoiceTemplate(dto: CreateInvoiceTemplateDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + 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.platform, + 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 updatePlatformInvoiceTemplate( + templateIdRaw: string, + dto: UpdateInvoiceTemplateDto, + actor: AuthUser, + ) { + await this.assertSuperAdmin(actor); + + const templateId = BigInt(templateIdRaw); + const existing = await this.prisma.invoiceTemplate.findFirst({ + where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, + }); + 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 deletePlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const templateId = BigInt(templateIdRaw); + const existing = await this.prisma.invoiceTemplate.findFirst({ + where: { id: templateId, ownerScope: InvoiceOwnerScope.platform }, + }); + if (!existing) { + throw new NotFoundException('Invoice template not found'); + } + + await this.prisma.invoiceTemplate.delete({ where: { id: templateId } }); + return { ok: true }; + } + + // --- Public invoice viewer (platform) --- + + async getPublicInvoice(invoiceIdRaw: string) { + let invoiceId: bigint; + try { + invoiceId = BigInt(invoiceIdRaw); + } catch { + throw new NotFoundException('Invoice not found'); + } + + const row = await this.prisma.invoice.findFirst({ + where: { + id: invoiceId, + ownerScope: InvoiceOwnerScope.platform, + status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] }, + }, + include: this.invoiceInclude, + }); + + if (!row) { + throw new NotFoundException('Invoice not found'); + } + + const serialized = this.serializeInvoice(row, true); + // Public payload: no internal notes / issuer identity + return { + id: serialized.id, + 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 --- async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) { @@ -335,11 +780,7 @@ export class InvoicesService { 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' }] }, - }, + include: this.invoiceInclude, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, @@ -367,11 +808,7 @@ export class InvoicesService { 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' }] }, - }, + include: this.invoiceInclude, }); if (!row) { @@ -394,6 +831,21 @@ export class InvoicesService { } const items = dto.items.map((item) => this.normalizeItemInput(item)); + const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); + const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); + const invoiceTemplateId = dto.invoiceTemplateId?.trim() + ? BigInt(dto.invoiceTemplateId) + : null; + + if (invoiceTemplateId !== null) { + const template = await this.prisma.invoiceTemplate.findFirst({ + where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform }, + select: { id: true }, + }); + if (!template) { + throw new BadRequestException('Invoice template not found'); + } + } const row = await this.prisma.invoice.create({ data: { @@ -401,7 +853,9 @@ export class InvoicesService { ownerScope: InvoiceOwnerScope.platform, status: dto.status ?? InvoiceStatus.issued, name: dto.name?.trim() || null, + topText: dto.topText?.trim() || null, notes: dto.notes?.trim() || null, + invoiceTemplateId, issuedBy: actor.id, items: { create: items.map((item, index) => ({ @@ -415,12 +869,23 @@ export class InvoicesService { 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: { - business: { select: { id: true, name: true, nameFa: true } }, - issuer: { select: { id: true, firstName: true, lastName: true } }, - items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, - }, + include: this.invoiceInclude, }); return this.serializeInvoice(row); @@ -454,11 +919,7 @@ export class InvoicesService { 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' }] }, - }, + include: this.invoiceInclude, }); return this.serializeInvoice(row); diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html index 493f03a..9191fb0 100644 --- a/src/website-docs/static/index.html +++ b/src/website-docs/static/index.html @@ -74,9 +74,9 @@

    Global links (share these with designers & AI tools):

    - OpenAPI JSON - Download Postman - AI prompt + OpenAPI JSON + Download Postman + AI prompt

    Base URL

    @@ -93,7 +93,7 @@

    For a new website AI / designer

      -
    1. Open AI_PROMPT.md and paste it into the AI chat.
    2. +
    3. Open AI_PROMPT.md and paste it into the AI chat.
    4. Replace <WEBSITE_DOMAIN> with that site’s apex.
    5. Import the Postman collection (set domain, run Resolve tenant).
    6. Or feed openapi.json to the AI / codegen tool.