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 @@
Base URL
@@ -93,7 +93,7 @@
For a new website AI / designer
- - Open AI_PROMPT.md and paste it into the AI chat.
+ - Open AI_PROMPT.md and paste it into the AI chat.
- Replace
<WEBSITE_DOMAIN> with that site’s apex.
- Import the Postman collection (set
domain, run Resolve tenant).
- Or feed
openapi.json to the AI / codegen tool.
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 @@
Base URL
@@ -93,7 +93,7 @@
For a new website AI / designer
- - Open AI_PROMPT.md and paste it into the AI chat.
+ - Open AI_PROMPT.md and paste it into the AI chat.
- Replace
<WEBSITE_DOMAIN> with that site’s apex.
- Import the Postman collection (set
domain, run Resolve tenant).
- Or feed
openapi.json to the AI / codegen tool.