mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
426316d53c
commit
3faeb9bc0d
@@ -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.
|
||||||
@@ -34,3 +34,5 @@ Do **not** use Prisma Migrate. SQL migrations are authoritative.
|
|||||||
- Reuse existing services/guards instead of reimplementing
|
- Reuse existing services/guards instead of reimplementing
|
||||||
- No commits unless explicitly requested
|
- 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)
|
- **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`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -56,3 +56,5 @@ WEBSITE_DEPLOY_TOKEN=
|
|||||||
|
|
||||||
# Public domain for platform invoice links (https://{domain}/invoices/{id})
|
# Public domain for platform invoice links (https://{domain}/invoices/{id})
|
||||||
INVOICE_PUBLIC_DOMAIN=meshkee.com
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
+16
-7
@@ -154,6 +154,8 @@ Example super admin: `+989121111111` / `password`
|
|||||||
| `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) |
|
| `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) |
|
||||||
| `036_invoices.sql` | Invoices, invoice items, invoice item templates + permissions |
|
| `036_invoices.sql` | Invoices, invoice items, invoice item templates + permissions |
|
||||||
| `037_invoice_name.sql` | Optional `invoices.name` |
|
| `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.
|
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 |
|
| Table | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `invoice_item_templates` | Predefined line items (`owner_scope` platform \| business) |
|
| `invoice_item_templates` | Predefined line items (`owner_scope` platform \| business) |
|
||||||
| `invoices` | Invoice header (`business_id` = billed party, optional `name`, `notes`, `status`) |
|
| `invoice_templates` | Full blueprints: name, top_text |
|
||||||
| `invoice_items` | Line items (title, duration, worktime, description, price, discounted_price) |
|
| `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)
|
### 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` |
|
| GET/POST | `/invoice-item-templates` |
|
||||||
| PATCH/DELETE | `/invoice-item-templates/:templateId` |
|
| PATCH/DELETE | `/invoice-item-templates/:templateId` |
|
||||||
|
| GET/POST | `/invoice-templates` |
|
||||||
|
| GET/PATCH/DELETE | `/invoice-templates/:templateId` |
|
||||||
| GET/POST | `/businesses/:businessId/invoices` |
|
| GET/POST | `/businesses/:businessId/invoices` |
|
||||||
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
|
| 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.*`.
|
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 |
|
| Purpose | Path |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| Prisma schema | `prisma/schema.prisma` |
|
| 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/` |
|
| 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` |
|
| Docker services | `docker-compose.yml` |
|
||||||
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
||||||
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers & AI tools):</p>
|
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers & AI tools):</p>
|
||||||
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
|
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
|
||||||
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
||||||
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
|
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Base URL</h2>
|
<h2>Base URL</h2>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
<h2>For a new website AI / designer</h2>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
||||||
<li>Replace <code><WEBSITE_DOMAIN></code> with that site’s apex.</li>
|
<li>Replace <code><WEBSITE_DOMAIN></code> with that site’s apex.</li>
|
||||||
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
|
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
|
||||||
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
|
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
|
||||||
|
|||||||
+122
-15
@@ -92,6 +92,7 @@ model Business {
|
|||||||
invoices Invoice[] @relation("InvoiceBusiness")
|
invoices Invoice[] @relation("InvoiceBusiness")
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||||
invoiceItemTemplates InvoiceItemTemplate[]
|
invoiceItemTemplates InvoiceItemTemplate[]
|
||||||
|
invoiceTemplates InvoiceTemplate[]
|
||||||
website_brand_groups website_brand_groups[]
|
website_brand_groups website_brand_groups[]
|
||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
@@ -1147,27 +1148,98 @@ enum InvoiceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model InvoiceItemTemplate {
|
model InvoiceItemTemplate {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
businessId BigInt? @map("business_id")
|
businessId BigInt? @map("business_id")
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
duration String? @db.VarChar(100)
|
duration String? @db.VarChar(100)
|
||||||
worktime String? @db.VarChar(100)
|
worktime String? @db.VarChar(100)
|
||||||
description String?
|
description String?
|
||||||
price Decimal @default(0) @db.Decimal(12, 2)
|
price Decimal @default(0) @db.Decimal(12, 2)
|
||||||
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
discountedPrice Decimal? @map("discounted_price") @db.Decimal(12, 2)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
invoiceItems InvoiceItem[]
|
invoiceItems InvoiceItem[]
|
||||||
|
invoiceTemplateItems InvoiceTemplateItem[]
|
||||||
|
|
||||||
@@index([ownerScope, sortOrder], map: "idx_invoice_item_templates_owner_scope")
|
@@index([ownerScope, sortOrder], map: "idx_invoice_item_templates_owner_scope")
|
||||||
@@index([businessId, sortOrder], map: "idx_invoice_item_templates_business_id")
|
@@index([businessId, sortOrder], map: "idx_invoice_item_templates_business_id")
|
||||||
@@map("invoice_item_templates")
|
@@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 {
|
model Invoice {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
@@ -1175,7 +1247,9 @@ model Invoice {
|
|||||||
issuerBusinessId BigInt? @map("issuer_business_id")
|
issuerBusinessId BigInt? @map("issuer_business_id")
|
||||||
status InvoiceStatus @default(issued)
|
status InvoiceStatus @default(issued)
|
||||||
name String? @db.VarChar(255)
|
name String? @db.VarChar(255)
|
||||||
|
topText String? @map("top_text")
|
||||||
notes String?
|
notes String?
|
||||||
|
invoiceTemplateId BigInt? @map("invoice_template_id")
|
||||||
issuedBy BigInt? @map("issued_by")
|
issuedBy BigInt? @map("issued_by")
|
||||||
issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz(6)
|
issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz(6)
|
||||||
createdAt DateTime @default(now()) @map("created_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)
|
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction)
|
issuerBusiness Business? @relation("InvoiceIssuerBusiness", fields: [issuerBusinessId], references: [id], onUpdate: NoAction)
|
||||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], 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[]
|
items InvoiceItem[]
|
||||||
|
keyPoints InvoiceKeyPoint[]
|
||||||
|
accounts InvoiceAccount[]
|
||||||
|
|
||||||
@@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
@@index([businessId, createdAt(sort: Desc)], map: "idx_invoices_business_created")
|
||||||
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
@@index([ownerScope, createdAt(sort: Desc)], map: "idx_invoices_owner_scope")
|
||||||
@@index([issuerBusinessId, createdAt(sort: Desc)], map: "idx_invoices_issuer_business_id")
|
@@index([issuerBusinessId, createdAt(sort: Desc)], map: "idx_invoices_issuer_business_id")
|
||||||
@@index([status], map: "idx_invoices_status")
|
@@index([status], map: "idx_invoices_status")
|
||||||
|
@@index([invoiceTemplateId], map: "idx_invoices_invoice_template_id")
|
||||||
@@map("invoices")
|
@@map("invoices")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,3 +1290,32 @@ model InvoiceItem {
|
|||||||
@@index([templateId], map: "idx_invoice_items_template_id")
|
@@index([templateId], map: "idx_invoice_items_template_id")
|
||||||
@@map("invoice_items")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,34 @@ export class InvoiceItemInputDto {
|
|||||||
discountedPrice?: number | null;
|
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 {
|
export class CreateInvoiceDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@@ -63,10 +91,30 @@ export class CreateInvoiceDto {
|
|||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
topText?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
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()
|
@IsOptional()
|
||||||
@IsEnum(InvoiceStatus)
|
@IsEnum(InvoiceStatus)
|
||||||
status?: InvoiceStatus;
|
status?: InvoiceStatus;
|
||||||
@@ -178,3 +226,113 @@ export class UpdateInvoiceItemTemplateDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { AuthUser } from '../auth/auth.types';
|
|||||||
import {
|
import {
|
||||||
CreateInvoiceDto,
|
CreateInvoiceDto,
|
||||||
CreateInvoiceItemTemplateDto,
|
CreateInvoiceItemTemplateDto,
|
||||||
|
CreateInvoiceTemplateDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
|
UpdateInvoiceTemplateDto,
|
||||||
} from './dto/invoice.dto';
|
} from './dto/invoice.dto';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@@ -54,6 +56,41 @@ export class InvoicesController {
|
|||||||
return this.service.deletePlatformTemplate(templateId, user);
|
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
|
// Business invoices
|
||||||
@Get('businesses/:businessId/invoices')
|
@Get('businesses/:businessId/invoices')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -105,4 +142,10 @@ export class InvoicesController {
|
|||||||
) {
|
) {
|
||||||
return this.service.deleteInvoice(businessId, invoiceId, user);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import {
|
import {
|
||||||
CreateInvoiceDto,
|
CreateInvoiceDto,
|
||||||
CreateInvoiceItemTemplateDto,
|
CreateInvoiceItemTemplateDto,
|
||||||
|
CreateInvoiceTemplateDto,
|
||||||
|
InvoiceAccountInputDto,
|
||||||
InvoiceItemInputDto,
|
InvoiceItemInputDto,
|
||||||
|
InvoiceKeyPointInputDto,
|
||||||
|
InvoiceTemplateItemInputDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
|
UpdateInvoiceTemplateDto,
|
||||||
} from './dto/invoice.dto';
|
} from './dto/invoice.dto';
|
||||||
|
|
||||||
@Injectable()
|
@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: {
|
private serializeItem(row: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
invoiceId: bigint;
|
invoiceId: bigint;
|
||||||
@@ -89,6 +228,10 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private platformInvoicePublicUrl(invoiceId: bigint) {
|
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';
|
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
|
||||||
return `https://${domain}/invoices/${invoiceId.toString()}`;
|
return `https://${domain}/invoices/${invoiceId.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -101,7 +244,9 @@ export class InvoicesService {
|
|||||||
issuerBusinessId: bigint | null;
|
issuerBusinessId: bigint | null;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
topText: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
invoiceTemplateId: bigint | null;
|
||||||
issuedBy: bigint | null;
|
issuedBy: bigint | null;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -120,10 +265,24 @@ export class InvoicesService {
|
|||||||
discountedPrice: Prisma.Decimal | null;
|
discountedPrice: Prisma.Decimal | null;
|
||||||
sortOrder: number;
|
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
|
const totals = items
|
||||||
? items.reduce(
|
? items.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@@ -147,7 +306,9 @@ export class InvoicesService {
|
|||||||
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
topText: row.topText,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
|
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
||||||
publicUrl:
|
publicUrl:
|
||||||
row.ownerScope === InvoiceOwnerScope.platform
|
row.ownerScope === InvoiceOwnerScope.platform
|
||||||
? this.platformInvoicePublicUrl(row.id)
|
? this.platformInvoicePublicUrl(row.id)
|
||||||
@@ -171,11 +332,66 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
items,
|
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,
|
subtotal: totals?.subtotal,
|
||||||
total: totals?.total,
|
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) {
|
private normalizeItemInput(item: InvoiceItemInputDto) {
|
||||||
const title = item.title.trim();
|
const title = item.title.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
@@ -309,6 +525,235 @@ export class InvoicesService {
|
|||||||
return { ok: true };
|
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 ---
|
// --- Invoices for a business ---
|
||||||
|
|
||||||
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
|
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
|
||||||
@@ -335,11 +780,7 @@ export class InvoicesService {
|
|||||||
this.prisma.invoice.count({ where }),
|
this.prisma.invoice.count({ where }),
|
||||||
this.prisma.invoice.findMany({
|
this.prisma.invoice.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: this.invoiceInclude,
|
||||||
business: { select: { id: true, name: true, nameFa: true } },
|
|
||||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -367,11 +808,7 @@ export class InvoicesService {
|
|||||||
businessId,
|
businessId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
},
|
},
|
||||||
include: {
|
include: this.invoiceInclude,
|
||||||
business: { select: { id: true, name: true, nameFa: true } },
|
|
||||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -394,6 +831,21 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||||
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
|
const 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({
|
const row = await this.prisma.invoice.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -401,7 +853,9 @@ export class InvoicesService {
|
|||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
status: dto.status ?? InvoiceStatus.issued,
|
status: dto.status ?? InvoiceStatus.issued,
|
||||||
name: dto.name?.trim() || null,
|
name: dto.name?.trim() || null,
|
||||||
|
topText: dto.topText?.trim() || null,
|
||||||
notes: dto.notes?.trim() || null,
|
notes: dto.notes?.trim() || null,
|
||||||
|
invoiceTemplateId,
|
||||||
issuedBy: actor.id,
|
issuedBy: actor.id,
|
||||||
items: {
|
items: {
|
||||||
create: items.map((item, index) => ({
|
create: items.map((item, index) => ({
|
||||||
@@ -415,12 +869,23 @@ export class InvoicesService {
|
|||||||
sortOrder: index,
|
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: {
|
include: this.invoiceInclude,
|
||||||
business: { select: { id: true, name: true, nameFa: true } },
|
|
||||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.serializeInvoice(row);
|
return this.serializeInvoice(row);
|
||||||
@@ -454,11 +919,7 @@ export class InvoicesService {
|
|||||||
status: dto.status,
|
status: dto.status,
|
||||||
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
|
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: this.invoiceInclude,
|
||||||
business: { select: { id: true, name: true, nameFa: true } },
|
|
||||||
issuer: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.serializeInvoice(row);
|
return this.serializeInvoice(row);
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers & AI tools):</p>
|
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers & AI tools):</p>
|
||||||
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
|
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
|
||||||
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
||||||
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
|
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Base URL</h2>
|
<h2>Base URL</h2>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
<h2>For a new website AI / designer</h2>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
||||||
<li>Replace <code><WEBSITE_DOMAIN></code> with that site’s apex.</li>
|
<li>Replace <code><WEBSITE_DOMAIN></code> with that site’s apex.</li>
|
||||||
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
|
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
|
||||||
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
|
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user