# DECISIONS — user rulings on the parity project Binding decisions made by the user. They override defaults and survive context loss. Referenced from `PROTECTED.md` and `PROGRESS.md`. | Date | Phase | Decisions | |---|---|---| | 2026-09-21 | Phase 0 gate | D0–D5, D6–D8 (urgent), D9–D11, D12 (method change) | --- ## D0 — First name / last name: split the column (option **الف**) **Ruling.** Add real `firstName` / `lastName` columns to `Customer` via an **additive** migration. Requirements: 1. Additive migration only — nothing dropped. 2. Backfill existing rows: **first token** of `fullName` → `firstName`, **the rest** → `lastName`. 3. Rows whose `fullName` has **more than two tokens** must be written to a report for manual review (they cannot be split safely). 4. **`fullName` is NOT removed.** It stays and is kept in sync as `firstName + " " + lastName` so existing code keeps working. 5. `toRow()` must return the **real** `fname` / `lname`, not `fullName` / `''`. 6. Fix every consumer: `singleCustomer.vue`, `bulkCustomer.vue`, `ReagentCustomer.vue`, and anywhere a name is displayed, searched or sorted. **Remove the `split(' ')` fallback in `singleCustomer.vue`.** 7. Check the same defect on **`AdminUser`** (sales expert) and on **registration / profile in `user-app` and `user-app-v2`**. 8. **Show the migration file and the command, and get approval, before running it.** Evidence for the defect: reference has two columns (`henza_server/database/migrations/2020_10_01_120430_customers_table.php:18-19`); target collapses them (`apps/api/src/modules/admin-customers/admin-customers.service.ts:231-233` returns `fname: c.fullName, lname: ''`). ## D1 — Admin auth: keep the target stricter The reference leaves all ~215 admin routes unauthenticated (`henza_server/routes/admin.php:12,272`). The target's JWT + 144-rid enforcement **stands**, recorded as an accepted deviation. **Additional requirement:** build a complete mapping matrix **"every reference admin operation ↔ its permission key (rid) in the target"**, so no operation is left without an equivalent permission. Explain the **172 (reference) vs 174 (target) unique key** difference. ## D2 — Domain scope: audit only what the reference has Confirmed. Do not manufacture rows for buy/sell gold, deposit, withdraw, transfer, KYC levels, price sockets or payment callbacks — none exist in the reference (`00-INVENTORY.md` §0). Target-only capabilities (e.g. the dual-currency ledger, gram/rial debt) are listed with status **➕** and **left untouched**. Ask when unsure. ## D3 — The 5 missing user-app screens: build them in Phase 2 `/report`, `/report/detail`, `/ChildOrder`, `/customerChoice`, `/complete_profile` are built in **both** apps, each in its own design language, plus whatever backend they need. Also brought to reference behaviour in Phase 2: - route analytics (`POST /log/create/{key}`, `GET /log/update/{id}`) - the `isLoaded` guard - the `sts_verify_step_two` guard on `completeProfile` - guest behaviour: **toast «ابتدا وارد شوید» + redirect to `home`** (not a redirect to `/login`) - **v2's missing cart stock ceiling (`capQty`/`maxQty`) must match v1 and the reference** ## D4 — Reference bugs: replicate behaviour, not breakage - Broken or destructive reference behaviour is **not** ported — logged only. - **Business behaviour, calculations, rounding and messages must match the reference exactly.** - Every deliberate non-replication gets a matrix note: **«عمداً تکرار نشد — ISSUE #n»**. - The price formula is the **live** version at `henza_server/app/Models/ProductVariant.php:183-190` — *not* the commented-out ones above it. - Write a **unit test** for it with sample numbers and compare against the reference formula computed by hand. ## D5 — Stray root folder: leave it for now `gold-banking-monorepo/user-app-v2/` is logged in `ISSUES.md` (DEAD-03). At the very end, after verifying nothing references it, delete it in **its own commit**. --- ## D6 — URGENT: OTP must be verified (exception to "log only", per `CLAUDE.md` §3) The reference **does** verify the OTP, so the target accepting any code is a **parity gap**, not just a security issue. Fix it to match the reference: code matching, expiry, and attempt limits. Reference implementation (`henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78`): | Step | Reference behaviour | Line | |---|---|---| | Send | `userAuth` validates `username`, finds an **active** customer by mobile, generates `rand(1000,9999)`, stores it in `customer.send_password`, sends it by SMS | `:39-49` | | Verify | `userVerfiy` validates `username` + `password`; if `faTOen(password) != customer.send_password` → **HTTP 422** | `:51-57` | | Token | issues `api_token = Str::random(60)` only if the customer doesn't already have one | `:60-62` | | Digits | the code is normalised through `Utility::faTOen()` (Persian → English digits) on **both** send and verify | `:42,56` | ⚠️ Note for the matrix: the reference has **no expiry and no attempt limit** — `send_password` is a plain column that stays valid indefinitely. D6 asks for expiry and attempt limits, which go **beyond** the reference. Recorded as a deliberate, user-requested improvement; the parity row is the code match itself. ## D7 — URGENT: `@Public()` routes that write to the ledger Show the exact list with file paths and what the reference did for each, then protect them **after user approval**. See §"@Public audit" below. ## D8 — URGENT: the login customer-list leak Identify whether it is reference or target, and what data leaks. See §"Login leak" below. --- ## D9 — `percent`: trace before acting Done — see `00-INVENTORY.md` and the trace summary delivered to the user. **Key finding: the target's implementation is wrong.** `percent` is a real column on the customer in the reference, not a derived value from the referring agent. ## D10 — Sales expert: the 5 missing fields The move to `AdminUser` is **accepted**. For `ncode`, `phone`, `city_id`, `gram_level`, `reagent_id`: find where the reference uses each for `type=1` (list, filter, report, order, calculation). Fields that are genuinely used must be added — **proposed shape: a `SalesExpertProfile` table related to `AdminUser`**. Fields with no use are listed for the user to decide. ## D11 — `singleCustomer.vue`: migrate to the diff-send pattern Bring it in line with `bulkCustomer.vue` and `ReagentCustomer.vue`. **Diff-send is recorded as an accepted deviation** from the reference (which sends every field on every save). --- ## D12 — METHOD CHANGE for Phase 1 (and Phase 2) **1. The matrix is field-by-field, not page-by-page.** Every form field (name, type, required, validation, message, default), every table column, every filter, every button — and for each one, the **client field name ↔ API field name ↔ DB column**, on **both** sides. **2. A round-trip test is mandatory per entity:** ``` create with every field populated → open the edit form, assert every field is correctly populated → save with no changes, assert the DB row is byte-identical → change exactly one field, assert only that field changed ``` Run it for the sales expert too. **3. Both rules are to be added to the `reference-parity-audit` skill** (Step C and Step E) so Phase 2 inherits them. --- ## T1 — Customer authentication: a separate JWT strategy (option **الف**) Approved. Conditions: 1. **Separate secrets** — customer tokens are signed with `JWT_CUSTOMER_SECRET`, admin tokens with `JWT_SECRET`. 2. **A `typ` claim is checked in both guards** — `typ: 'admin'` vs `typ: 'customer'`. 3. **Mandatory cross-token tests:** a customer token on any admin route → 401; an admin token on a customer route → rejected, unless deliberately allowed. 4. **Token lifetime and logout must be compared against the reference and recorded here** (see below). 5. **No IDOR** — customer routes that return personal data derive the customer from the **token**, never from an id in the request. ### Reference comparison — token lifetime and logout | Aspect | Reference | Target (current) | Decision | |---|---|---|---| | Customer token | `api_token = Str::random(60)`, a random opaque string stored on the customer row (`ApiMobileController.php:60-62`) | none | new customer JWT | | Expiry | **none** — the reference token never expires; it is reused on every login if already set | admin JWT `JWT_EXPIRES_IN`, default `12h` | customer JWT gets an explicit expiry (deliberate improvement over the reference) | | Logout | the reference has **no logout endpoint**; the app drops the token client-side | apps clear `localStorage` | keep client-side clear; note that a stolen token stays valid until expiry | | Rotation | the reference issues a new token **only if `api_token` is null** — logging in twice returns the same token forever | n/a | issue a fresh token on every verify | The last three rows are **deliberate improvements over the reference**, approved by the user in the same message that approved D6. Recorded so the matrix does not later flag them as parity gaps. ## T2 — Uploaded files stay public **Ruling:** `/uploads` is not touched. No allow-list, no signed URLs, and the "private folder" stage is dropped. **Recorded verbatim at the user's request:** > «فایل‌های آپلودی شامل مدارک عمداً عمومی هستند — تصمیم مالک پروژه» > (Uploaded files, including identity documents, are deliberately public — the > project owner's decision.) ### The light check the user asked for — result: ✅ already correct | Check | Result | Evidence | |---|---|---| | Filenames unguessable? | **Yes.** `md5(Date.now() + originalname + randomUUID())` + the original extension | `src/modules/admin-uploads/admin-uploads.service.ts:53` | | Sequential or original name on disk? | **No.** `originalName` is kept only as a DB column, never as the filename | `admin-uploads.service.ts:62` | | Directory listing? | **No.** `PublicUploadsController` only resolves a single `:filename`, and `resolveStoredUpload()` rejects `.`/`..` and anything resolving outside the upload dir | `public-uploads.controller.ts:17`, `upload-path.ts:34-49` | Nothing to change. No existing files were moved. ## T3 — Two-stage hotfix Approved, with the condition that `GET /customers` be checked first. **Check result:** the apps **are** dependent on it. Its only consumer is `LoginView.vue:131` in both apps, and `CustomerSummary` is used nowhere else (verified by grep). The fields actually read are `mobile`, `isActive`, `isArchived`, then `session.login(id, mobile)` — four fields. So per the ruling: stage 1 strips the sensitive fields and keeps the route; stage 2 removes the route when login moves to OTP. --- # Urgent investigation results ## `percent` — full trace (D9) ### In the reference, `percent` is a real column on the customer | Fact | Evidence | |---|---| | Column exists on `customers` | `database/migrations/2020_10_19_153228_customer_add.php:32` — `$table->double('percent')->default(0);` | | Mass-assignable | `app/Models/Customer.php:31` | | Defaulted to **3** for app-registered customers | `app/Http/Controllers/Adm/AdminController.php:338` — `$input['percent'] = 3;` | | **Required** when an admin creates a wholesale customer | `AdminController.php:354` — `'percent'=>'required'` | | Selected into the wholesale customer list | `Adm/MajorCustomerController.php:36` | | Shown in a report column «درصد» | `Adm/ReportController.php:137` | ### What it actually does — it shifts the customer's making-charge filter `app/Http/Controllers/Api/ApiMobileController.php:391-396`: ```php if (sizeof($limit_percent) > 0 and auth('api-customer')->check()) { $limit_percent[0] = $limit_percent[0] - auth('api-customer')->user()->percent; $limit_percent[1] = $limit_percent[1] - auth('api-customer')->user()->percent; $product->whereHas('variants', fn($q) => $q->whereBetween('percentage_pay', $limit_percent)); } ``` And it shifts the **filter bounds the app is told to display**, at login (`ApiMobileController.php:76-77`): ```php $customer['filterPercentPay'][] = strval(ProductVariant::min('percentage_pay') + $customer['percent']); $customer['filterPercentPay'][] = strval(ProductVariant::max('percentage_pay') + $customer['percent']); ``` So `customer.percent` is a **per-customer making-charge offset**: the UI shows `percentage_pay + percent`, and the filter subtracts it again before querying. ### What it does NOT do **It is not part of the price formula.** The formula's extra percent comes from a **global** config row, not from the customer: - `app/Models/ProductVariant.php:50` — `ConfigStatic::where('key','product_price_extra_percent')->first()['val'] ?? 7` - `app/Models/ProductVariant.php:183` — `($this->percentage_pay + static::$product_price_extra_percent)` There is a **separate, unrelated** `ConfigStatic` key also called `percent`, used for the cashback/wallet rule (`AdminController.php:903,994`; `Controller.php:203`) — `value = total_weight * (percent/100)`. **Do not confuse the two.** A **third** unrelated `percent` is a per-order-item field in wholesale orders (`Adm/MajorOrderController.php:220`): `w = rash_weight + rash_weight * (percent/100)`. That one comes from the request, not from the customer. ### The target is wrong `apps/api/src/modules/admin-customers/admin-customers.service.ts:245`: ```ts // legacy `percent` came from the referring agent's commission rate percent: c.referredByAgent?.wagePercent ?? null, ``` That comment is **incorrect** — the reference reads `customer.percent`, its own column. Consequences: 1. **Read is wrong** — it shows the agent's commission, not the customer's offset. 2. **Write is missing** — `update()` never handles `data.percent` (`admin-customers.service.ts:600-658`), so editing the field in the admin form does nothing. 3. **The create default is missing** — the reference sets `percent = 3` for app-registered customers and **requires** it for admin-created wholesale customers. 4. **The app-side effect is missing** — no `filterPercentPay` offset anywhere in `user-app` / `user-app-v2`. ### Proposed reconstruction (needs user approval — it is a schema change) 1. Add `makingChargeOffsetPercent Decimal @default(0)` to `Customer` (additive migration). Name it clearly so it is never confused with the two other `percent`s; map it to the legacy key `percent` in `toRow()`. 2. `toRow()` returns that column, not `referredByAgent.wagePercent`. Remove the incorrect comment. 3. `create()`: default **3** on the app-registration path; **require** it on the admin wholesale path (reference `AdminController.php:354`). 4. `update()`: handle `data.percent`. 5. Catalogue filter: subtract the customer's offset from the `percentage_pay` bounds before querying, and return shifted `filterPercentPay` bounds at login — matching `ApiMobileController.php:391-396,76-77`. 6. The referring agent's `wagePercent` stays where it belongs (the agent), and is **not** exposed through the customer form. --- ## `@Public()` audit (D7) — every unauthenticated route in the target 15 routes across 7 controllers. Verified by reading each controller. ### 🔴 Group A — ledger writes (the ones the user flagged) `apps/api/src/modules/accounting/accounting.controller.ts` — `@Public()` on the **whole controller** (`:7`), prefix `accounting` (`:8`): | # | Route | Line | Effect | Reference equivalent | |---|---|---|---|---| | A1 | `GET accounting/customers/:customerId/balance` | `:12` | reads any customer's balance | `Adm/WalletController` — reference had auth **commented out** too (`routes/admin.php:12`) | | A2 | `POST accounting/customers/:customerId/reconcile` | `:17` | **writes** — reconciles a balance | same: unauthenticated in reference | | A3 | `POST accounting/repayments/gold` | `:22` | **writes** — records a gold repayment | same | | A4 | `POST accounting/repayments/rial` | `:27` | **writes** — records a rial repayment | same | | A5 | `POST accounting/entries/:entryId/confirm` | `:32` | **writes** — confirms a ledger entry | same | **In the reference these were admin routes in `routes/admin.php`, and that whole file's `auth:api` wrapper is commented out — so they were unauthenticated there too.** The target faithfully reproduced that. Under **D1** the target is supposed to be stricter, so these five are the clearest violation of D1 still standing. **Recommendation:** A1–A5 are admin operations. Put them behind the admin JWT guard with the matching rid (the wallet/document rids from `acl.constants.ts`). **Awaiting your approval — this is the list you asked for.** ### 🟠 Group B — order writes `apps/api/src/modules/orders/orders.controller.ts` — `@Public()` at `:7`, prefix `orders`: | # | Route | Line | Note | Reference | |---|---|---|---|---| | B1 | `POST orders` | `:12` | customer places an order | `ApiMobileController::setOrder` — **was authenticated** via `auth('api-customer')`; it reads `auth()->id()` at `:533` | | B2 | `GET orders/:id` | `:17` | reads any order by id | same guard in reference | | B3 | `PATCH orders/:id/status` | `:22` | **changes order status**; DTO accepts a client-supplied `actorAdminUserId` | admin operation in reference | **B1/B2 differ from the reference in kind:** the reference's customer endpoints sat behind the `api-customer` guard and derived the customer from the token. The target has **no customer session at all**, so it takes `customerId` from the request body. That is why they are public. Fixing this properly means building customer auth — which is exactly what **D6** starts. **B3 is the worst of the three:** it lets an anonymous caller move an order's status *and* attribute the action to any admin. ### 🟢 Group C — genuinely public reads (match the reference's public storefront) | # | Route | File:line | Reference | |---|---|---|---| | C1 | `GET customers` | `customers/customers.controller.ts:10` | **see the leak below** | | C2 | `GET customers/:id` | `:15` | — | | C3 | `GET customers/:id/orders` | `:20` | — | | C4 | `GET customers/:id/ledger-entries` | `:25` | — | | C5 | `GET catalog/products` | `catalog/catalog.controller.ts:10` | reference storefront was public (`routes/site.php`) ✅ | | C6 | `GET pricing/latest` | `pricing/pricing.controller.ts:10` | reference `/price` was public ✅ | | C7 | `GET home/slider` | `admin-misc/public-slider.controller.ts:21` | reference `mobile_main_slider` config, public ✅ | | C8 | `GET uploads/:filename` | `admin-uploads/public-uploads.controller.ts:17` | reference served uploads statically ✅ | C5–C8 are legitimately public. **C1–C4 are not** — they expose customer records. --- ## Login leak (D8) — it is in the **target**, and it is worse than "a list" **Where:** target only. `apps/user-app` and `apps/user-app-v2` both call `GET /api/customers` at login and match the phone number **in the browser**. **What leaks:** `GET /customers` is `@Public()` (`customers/customers.controller.ts:10`) and returns **every active, non-archived customer** with these fields (`customers/customers.service.ts:6-23`, `:44-50`): | Field | Sensitive? | |---|---| | `id`, `createdAt`, `updatedAt` | low | | `fullName` | 🔴 **PII** | | `mobile` | 🔴 **PII** | | `nationalCode` | 🔴 **national ID number** | | `cityId`, `referredByAgentId` | medium | | `goldCreditLimitGrams`, `goldDebtGrams`, `fiatDebtRial` | 🔴 **financial position of every customer** | | `verificationStep`, `isActive`, `isArchived` | medium | | `nationalIdImageUrl`, `businessLicenseUrl`, `shopImageUrl` | 🔴 **URLs of ID-document scans** | **`passwordHash` is correctly excluded** — the select list is explicit and the source comment says so (`customers.service.ts:5`). So no password material leaks. **Everything else does.** Any anonymous visitor can fetch the full customer book — names, mobiles, national ID numbers, debts, and links to identity-document images (and `GET uploads/:filename` is public too, so those links resolve). **In the reference this was not possible.** Login was `POST userAuth` → SMS code → `POST userVerfiy` (`ApiMobileController.php:39-78`): the server looked the customer up by mobile and returned **only that one customer** after the code matched. There was no "download everyone and match on the client" step. **Root cause:** the target has no customer authentication, so the client does the lookup. **D6 (real OTP) is the fix for both problems at once** — once `POST auth/otp/verify` returns the authenticated customer, `GET /customers` can be taken off the public surface entirely. --- # Stage 2 implementation record (2026-09-22) ## Deliberate improvements over the reference — user-approved Recorded here so the Phase 1/2 matrices do not later flag them as parity gaps. | # | Behaviour | Reference | Target | Approved in | |---|---|---|---|---| | 1 | OTP storage | plain text in `customer.send_password` | bcrypt hash in `CustomerOtp.codeHash` | D6 | | 2 | OTP expiry | none — valid forever | 2 minutes | D6 | | 3 | Attempt limit | none | 5 per challenge, then lockout | D6 | | 4 | Replay | code stayed usable | single use (`consumedAt`) | D6 | | 5 | Send throttle | none | 60s cooldown/number, 5 per number and 20 per IP per 10 min | user message of 2026-09-22 | | 6 | Unknown number | `firstOrFail()` → 404, enumerable | identical `{sent:true}` | implied by 5; recorded here | | 7 | Token rotation | `api_token` issued once, reused forever | fresh token on every verify | T1 | | 8 | Token expiry | none | `JWT_CUSTOMER_EXPIRES_IN`, default 30d | T1 | | 9 | Token secret | single opaque string | `JWT_CUSTOMER_SECRET`, separate from admin, plus a `typ` claim | T1 | ## New environment variables | Name | Default | Purpose | |---|---|---| | `JWT_CUSTOMER_SECRET` | falls back to `JWT_SECRET`, then a dev string | signs customer tokens | | `JWT_CUSTOMER_EXPIRES_IN` | `30d` | customer token lifetime | Both fall back so local development keeps working, and both inherit the SEC-04 problem (an insecure default when unset) — tracked there, not re-logged. ## Known gap left open on purpose `POST /orders` and `GET /orders/:id` are still `@Public()`. The customer token now exists, so they can be closed, but `CreateOrderDto` carries `customerId` in the body and changing that is an API-contract change touching both apps' cart flow. Left for the Phase 2 apps work rather than widened into this hotfix. Tracked as SEC-02 (B1/B2) in `ISSUES.md`. --- # D13 — Retail storefront: preserved, but out of scope (2026-09-22) **Owner's ruling.** The reference also contained a **retail storefront** (single-customer / B2C shop). The **wallet**, **cashback** and `ConfigStatic['percent']` belong to that storefront, not to the B2B gold business this project is porting. ## The rule 1. **Nothing is deleted or disabled.** No code, table, column, endpoint, setting or data belonging to the storefront may be removed or switched off — neither in the target nor in the reference. Whatever exists in the target today stays exactly as it is. 2. **No work is done on it.** No parity, no fixes, no refactoring. 3. **In the matrices** these items are listed with status **⏸️ حفظ‌شده — خارج از کار فعلی** (preserved — out of current scope) so they remain visible, but **no gap row and no work row is created for them**. 4. **Shared surfaces need a heads-up.** If fixing something shared — product, variant, price, order, customer — would affect the storefront, **ask the owner before changing it**, so nothing in the storefront breaks. 5. **No guessing.** If it is unclear whether something is storefront-only or shared, **list it and ask**. ## Status legend addition | Symbol | Meaning | |---|---| | ⏸️ | Preserved — out of current scope (D13). Listed for visibility only; no gap row, no work row. | ## Endpoint classification — where the reference endpoints go **The previously reported total of 369 was wrong; the real figure is 412.** Recounting the live routes per file (excluding the 6 commented-out ones, expanding `Route::resource` ×7 and `Route::match(['get','post'])` ×2) shows `admin.php` has **151** explicit routes, not the 117 recorded in `_inv-ref-server.md` §3.6 — so admin is **249**, not 215. Corrected in `00-INVENTORY.md` §2.1. | Surface | Route file(s) | Endpoints | In scope? | |---|---|---|---| | Admin panel — B2B | `admin.php` minus the storefront block below | **218** | ✅ in scope (Phase 1) | | Admin panel — storefront management | `admin.php`: wallet 6, `single*` 8, comment 2, `get-analytics-tak` 1 explicit + `single` resource 7 + `comment` resource 7 | **31** | ⏸️ preserved, out of scope | | B2B mobile app | `api.php` | **70** | ✅ in scope (Phase 2) | | Retail storefront — customer API | `customer.php` | **48** | ⏸️ preserved, out of scope | | Retail storefront — public site API | `site.php` | **30** | ⏸️ preserved, out of scope | | Dev/ops closures, price cron, redirects | `web.php` | **15** | ⏸️ out of scope (dead/dev — see DEAD-\* in `ISSUES.md`) | | **Total** | | **412** | | **Result: 109 of the 412 endpoints belong to the retail storefront** (48 `customer.php` + 30 `site.php` + 30 storefront management inside `admin.php`), **15 more are dev/ops closures in `web.php`**, and **288 remain in scope** (218 admin + 70 B2B app). Evidence for the storefront classification: | Block | Where | Why it is storefront | |---|---|---| | `routes/customer.php` (prefix `cu`) | whole file | guarded by `auth:api-singleCustomer`; cart, fav, address, comment, payment gateway, `customer/wallet` | | `routes/site.php` (prefix `site`) | whole file | public shop front: `Site\SiteController`, `Site\CollectionController`, `Api\SiteController` | | `admin/wallet/*` | `admin.php:186-191` | `Adm\WalletController` — cashback percent, charge, config | | `admin/single*` | `admin.php:35,59,60,194-199` | `Adm\SingleCustomerController` — the retail customer's orders, transactions, wallet, report | | `admin/comment*` | `admin.php:150-152` | `Adm\CommentController` — product reviews, a storefront feature | The three unrelated `percent` meanings were already separated in `MIGRATION-STATE.md`. D13 confirms that **`ConfigStatic['percent']` (wallet cashback) is storefront and out of scope**, while **`customers.percent` (→ `makingChargeOffsetPercent`) is B2B and stays in scope**. --- # Report — SMS provider (parity investigation, 2026-09-22) Requested before implementing. **Nothing was implemented yet; awaiting approval.** ## 1. Which provider does the reference use? **Trez SMS panel** (`smspanel.trez.ir`), called directly with Laravel's `Http` facade. There is **no package, no config file and no env var** — the endpoint and the credentials are hardcoded in one model. | Item | Value | Where | |---|---|---| | Class | `App\Models\Sms` | `henza_server/app/Models/Sms.php` | | Live endpoint | `https://smspanel.trez.ir/SendMessageWithCode.ashx` | `Sms.php:69` (`Send`), `Sms.php:97` (`SendSms`) | | Method | `POST`, form-urlencoded, 20s timeout | `Sms.php:71-78` | | Form fields | `PhoneNumber` (sender line), `Username`, `Password`, `Mobile`, `Message` | `Sms.php:73-77` | | Persistence | `Sms::set()` writes every message to the `messages` table | `Sms.php:80,120-129` | | Queue | `SendSmsJob` / `SendSmsAfterCreate` exist, but `QUEUE_CONNECTION` defaults to `sync` (`config/queue.php:16`), so sends are inline | `app/Jobs/SendSmsJob.php:40` | **A superseded provider is commented out above each call**: `0098sms.com` (`sendsmslink.aspx`, GET, sender line `3000164545`). Not ported — dead code. WARNING — **the reference has live production credentials committed to git** (`Sms.php:74-76`: a panel username, a plaintext password, and a sender line). They are **not** copied into the target and are **not** repeated here. Logged as **SEC-12**. The owner should assume they are compromised and rotate them. ## 2. Message templates `Sms::Send($to, $msg)` takes a fully-built string; there are no provider-side template ids. Lines are separated by a newline. ### OTP — the one this project needs `ApiMobileController.php:47`: ``` <#> code is {CODE} رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد {CODE} می باشد aMo6iFyUQaY ``` The first line and the trailing `aMo6iFyUQaY` are the **Android SMS Retriever** hash — they must stay byte-identical or autofill breaks. This exact string is already in the target at `customer-auth.service.ts:219-222`, built and held ready, currently discarded via `void smsText`. A second OTP variant exists for the web app (`WebApp/AuthController.php:21`), ending with the site domain and a hash. Not used by the B2B app. ### The other B2B messages (all in scope) | # | Trigger | Text | Where | |---|---|---|---| | 1 | Customer created by admin | `هنزا گلد` / `همکار گرامی {fname} {lname}` / `نام کاربری : {mobile}` | `AdminController.php:380-383` | | 2 | Customer activated (`active == -1`) | `{name} عزیز ` / `حساب كاربري شما در اپليكيشن هنزا فعال شد.` / `هم اكنون مي توانيد محصولات جديد را بررسي و ثبت سفارش كنيد.` / `با مراجعه به لینک زیر می توانید وارد پروفایل کاربری خود شوید.` / `https://henzagold.com/customer/login` / `هنزا، راهكارهاي نوين تامين محصولات` | `AdminController.php:441-447` | | 3 | Financial document approved | `هنزا گلد` / `سند مالی شما توسط مدیر سیستم تایید شد.` | `AdminController.php:465`, `CashController.php:141` | | 4 | Financial document rejected | `هنزا گلد` / `سند مالی شما توسط مدیر سیستم رد شد.` | `CashController.php:144` | | 5 | Order approved | `هنزا گلد` / `سفارش شما با شماره سند {oId}تایید شد.` (no space before «تایید» in the reference) | `AdminController.php:522` | | 6 | Order placed — normal | `سفارش شما به شماره {id} به ثبت رسید. هنزا گلد` | `ApiMobileController.php:742` | | 7 | Order placed — over `gram_level` | `سفارش شما به شماره {id} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. هنزا گلد` | `ApiMobileController.php:739` | | 8 | Order placed by the sales expert for a customer | `هنزا گلد` / `همکار گرامی {fname} {lname}` / `سفارشی به وزن {allGram} گرم توسط آقای {reagentName} برای شما در سیستم ثبت شده است.` / `در صورت مورد تایید بودن سفارش بر روی لینک زیر کلیک نمایید.` / `{confirmLink}` | `ApiMobileController.php:733` | | 9 | To the customer's sales expert, on every order | `هنزا گلد` / `سفارشی توسط {fname} {lname} به شماره {id}و به وزن {allGram} به ثبت رسید.` | `ApiMobileController.php:749` | | 10 | To four hardcoded admin numbers, on every order | `هنزا گلد` / `سفارشی توسط {fname} {lname}` / ` به وزن {round(allGram)}` / `در سیستم ثبت شد.` | `ApiMobileController.php:601-605` | | 11 | To three hardcoded admin numbers, on findoc submit | `هنزا گلد` / `سندی توسط آقای / خانم {fname} {lname} در سیستم ثبت شد.لطفا نسبت به بررسی و تایید آن اقدام نمایید. ` | `ApiMobileController.php:154-158` | | 12 | Admin free-text / batch | operator-supplied | `SmsController.php:56,151` | Messages 10 and 11 go to **phone numbers hardcoded in the source** (six different mobile numbers across `ApiMobileController.php:154-158,601-605`). Porting those literals would be wrong — they belong in configuration. Proposal: one env var holding a comma-separated list, defaulting to empty. Logged as **STUB-05**. **Storefront messages are excluded** per D13: the cashback/wallet texts (`AdminController.php:921,951`), `Customer/OrderController.php:94,141-145`, `SingleCustomerController.php:66`, `Site/SiteController.php:182`, `birthdayCommand.php`, `SmsWalletCommand.php`. ## 3. Proposed env vars — names only, no values in code or git | Name | Required | Purpose | |---|---|---| | `SMS_PROVIDER` | no, default `trez` | selects the driver; `none` keeps today's dev behaviour | | `SMS_TREZ_URL` | no, defaults to the reference endpoint | provider endpoint | | `SMS_TREZ_USERNAME` | **yes in production** | panel username | | `SMS_TREZ_PASSWORD` | **yes in production** | panel password | | `SMS_TREZ_SENDER_LINE` | **yes in production** | the `PhoneNumber` form field | | `SMS_ADMIN_RECIPIENTS` | no, default empty | replaces the hardcoded admin numbers (STUB-05) | Proposed behaviour, per the owner's instruction: - **Key absent, `NODE_ENV !== 'production'`** — today's behaviour is unchanged: the code is logged once by `Logger`, a redacted audit row is written. - **Key absent, `NODE_ENV === 'production'`** — the server **fails to boot** with an explicit error naming the missing variable. No silent no-op, because a silent no-op means no customer can log in and nothing says why. - Secrets are read from `process.env` only; nothing is written to the repo, and `.env.example` carries the **names with empty values**. **Awaiting approval before implementing.** --- # Report — MON-08: where the pricing values come from (2026-09-22) Requested: in `henza_pwa`, does the user type `wagePercent` / `discountPercent` / `flatRialCharge`, or does the app compute them? ## Answer: the app computes all of them. The user types none of them. The whole order payload is built in one place, `henza_pwa/src/views/cart.vue:160-184` (`submitOrder`): | Payload field | Target equivalent | Source | Evidence | |---|---|---|---| | `percent_pay` | `wagePercent` | `getWage(variant)` = `myStore.state.percent + variant.percentage_pay` | `cart.vue:173` -> `src/Mixins/product/index.ts:69-75` | | `discount` | `discountPercent` | `getPromotion(product)?.discount_percent ?? 0` | `cart.vue:170` | | `rial_pay` | `flatRialCharge` | **hardcoded `0`** — no UI writes it | `cart.vue:179` | | `rash_weight` | (target reads from DB) | `cart.variant(cartItem).grams` — the variant's own weight | `cart.vue:178` | | `qty` | `quantity` | the cart stepper — **the only genuine user input** | `cart.vue:177` | Both computed inputs come from server-issued data: - `variant.percentage_pay` — the variant's making charge, from the catalogue. - `myStore.state.percent` — **`customers.percent`**, delivered by `POST /me-data` and stored at `myDataStore.ts:29,59,122-127`. This is the same `makingChargeOffsetPercent` field of D9, and it **confirms that trace from the client side**: the offset is per-customer and it is added to the making charge. - `promotion.discount_percent` — from the promotion the server returned. Two fields are sent as **literal constants with a `// how to get?!` comment** (`cart.vue:181-182`): `wage_weight: 5.7856000000000005` and `weight: 6.303744000000001`. The server ignores both — `setOrder` recomputes the weight from `rash_weight`, and the `wage_weight` use is commented out (`ApiMobileController.php:549-551`). Dead payload fields; logged as **DEAD-06**. ## What the server does with them `ApiMobileController.php:536-541` computes the customer's **gold debt** directly from the numbers in the request: ```php $percent = ($item['percent_pay'] ?? 0) - ($item['discount'] ?? 0); $gram = ($item['rash_weight'] ?? 0) + (($item['rash_weight'] ?? 0) * ($percent / 100)); return $gram * ($item['qty'] ?? 0); ``` Nothing is re-read from the database and nothing is validated. A crafted request sets its own making charge, its own discount and its own weight — so the reference lets a customer decide how much gold they owe. ## Consequence — per the owner's ruling The app computes these values, so the ruling applies: > «اگر اپ حساب می‌کند: اولین کار Phase 2 این باشد که سرور همان مقادیر را با همان > منطق از DB بخواند و مقادیر کلاینت را نادیده بگیرد، با تست مقایسه‌ای که نشان > دهد نتیجه برای کاربر عادی یکسان است.» **Phase 2, item #1** (recorded in `PROGRESS.md`): 1. The server derives, per line: `wagePercent = customer.makingChargeOffsetPercent + variant.percentagePay`, `discountPercent` from the applicable promotion, `flatRialCharge = 0`. 2. Client-sent values are **ignored**, and a warning is logged when they differ — the same pattern already used for `customerId` in `orders.service.ts`. 3. **A comparison test** proves that for an ordinary customer the server-derived values equal what the app sends today, so the total is unchanged. 4. The formula, its ordering and its rounding are untouched (D4). This depends on `makingChargeOffsetPercent` existing, so the percent migration must land first. ## `rash_weight` — accepted deviation The reference trusts the client's `rash_weight` (`ApiMobileController.php:538`); the target already reads the weight from the variant in the database. **Per the owner's ruling the target's behaviour stays.** Recorded here as a deliberate security deviation from the reference. The visible result is identical whenever the client sends the true variant weight, which the reference app always does (`cart.vue:178`); it differs only for a forged request, which is the point. --- # D14 — Migration workflow, and the `customer_sync_full_name` trigger (2026-09-22) ## Workflow | Environment | Command | Rule | |---|---|---| | Development | `npx prisma migrate dev` | the only way schema changes are applied | | Production | `npx prisma migrate deploy` | **only** this — never `migrate dev`, never `migrate reset` | | Anywhere | `prisma db execute` | **no longer used for schema changes** | If `migrate dev` reports drift and offers a reset: **stop, do not accept, report it.** `_prisma_migrations` is never edited by hand. ### What actually happened on 2026-09-22 `migrate dev` **refused to run**: it is interactive by design and this shell is not a TTY. That is a safety property, not a problem — a reset prompt could never be auto-accepted here. Before falling back, drift was checked read-only with `prisma migrate diff`. It reported one pre-existing difference that has nothing to do with this work: ``` -- DropTable DROP TABLE "_goldbank_migrations"; ``` `_goldbank_migrations` is a **bespoke ledger from the earlier port**, holding 12 historical rows (`0001_init.sql`, `seed:initial`, …). It is not in `schema.prisma`, so Prisma considers it foreign and would drop it. **It was not dropped.** Dropping it is a destructive operation, it is unapproved, and the rows are the only record of how the pre-Prisma port was applied. Logged as **DEAD-07**; leave it until the owner decides. The two new migrations were therefore applied with **`migrate deploy`**, which applies exactly the pending files and touches nothing else — so the drift was neither "fixed" nor acted upon. Result: `11 migrations found. Database schema is up to date!` ## The `customer_sync_full_name` trigger Created by `20260922120000_customer_name_parts`. **Prisma does not know it exists**, so a write through Prisma Client can produce a row different from what was written. Documented in three places so the next developer cannot miss it: the migration header, the `Customer` model doc comment in `schema.prisma`, and here. | Write | Result | |---|---| | the parts only | `fullName` is rebuilt as `first + ' ' + last` | | `fullName` only | the parts are re-derived (first token / the rest) | | **both in one statement** | **THE PARTS WIN**; the submitted `fullName` is discarded | **Why the parts win.** After D0 the parts are what the three forms edit, so they carry the operator's intent; `fullName` is the derived, legacy-facing value. The opposite precedence would silently undo an edit made in the two boxes the operator actually typed into. Verified live by `apps/api/scripts/test-name-trigger.cjs` — 9 assertions across all three directions plus insert, a multi-token name, and a no-op update. It creates one temporary row and deletes it, reporting the deletion. --- # D15 — `_goldbank_migrations` is kept, and the UTF-8 testing rule (2026-09-22) ## The table stays, declared and ignored **Owner's ruling:** keep `_goldbank_migrations`, delete no row from it, declare it in `schema.prisma` with `@@ignore` so `migrate diff` stops proposing a drop, and explain why it is there. Done — `model goldbank_migrations` at the end of `schema.prisma`, mapped to the real table name, with `@@ignore` so it never enters Prisma Client. **Verification:** ``` npx prisma migrate diff --from-schema-datasource --to-schema-datamodel --script → -- This is an empty migration. (drift: ZERO) npx prisma migrate status → 11 migrations found. Database schema is up to date! Prisma Client exposes it? no (@@ignore) ``` ## ⚠️ Correction to the earlier DEAD-07 assessment The previous entry called this table a leftover from the pre-Prisma port. **That was wrong**, and investigating the ruling is what surfaced it. `_goldbank_migrations` is the tracking table of **the application's own migration runner**, `apps/api/src/prisma/migrate.ts:5`. That runner exists for a real reason, stated at `migrate.ts:7-13`: the Docker deploy image is copy-only and ships **no Prisma CLI**, so `migrate deploy` cannot run in the container. The runner executes the same `.sql` files through Prisma Client instead and records them here. So the project keeps **two ledgers, deliberately**: | Ledger | Written by | Used | |---|---|---| | `_prisma_migrations` | the Prisma CLI | development (`migrate dev` / `migrate deploy`) | | `_goldbank_migrations` | `src/prisma/migrate.ts` | the deployed container, at boot | That runner also **already defended this table**: `migrate.ts:190-196` filters out any `DROP TABLE "_goldbank_migrations"` statement, with a comment noting that `migrate diff` had once emitted exactly that. The `@@ignore` model now removes the cause rather than relying on that guard. **Consequence for D14:** `migrate deploy` remains the rule for production *when run by hand*, but the container applies migrations itself at boot through `prepareDatabase()`. Any new migration must therefore be safe under **both** paths — which is why idempotent SQL (`IF NOT EXISTS`, `CREATE OR REPLACE`) matters here beyond ordinary caution. ### Row count went 12 → 14, and nothing was deleted Starting the API for the round-trip test ran `prepareDatabase()`, which appended the two new migrations to this ledger: ``` 2026-09-22T04:29:14.085Z 20260922120000_customer_name_parts 2026-09-22T04:29:14.101Z 20260922120100_customer_making_charge_offset ``` All 12 original rows are present and unmodified. The runner's `$$`-aware statement splitter handled the PL/pgSQL trigger body correctly — the trigger and its function are both present in the database, and the 9 trigger assertions pass against it. ## Testing rule — Persian input always comes from a file **Owner's ruling:** the Windows shell mangles Persian text, so any test with Persian input (names, addresses, messages, validation) must read its data from a **file** — the test file itself or a UTF-8 JSON fixture — never from a command-line argument. Any earlier result that was wrong because of this must be re-run. ### Why, concretely A `curl -d '{"fname":"محمد علی"}'` issued through the shell arrived at the API with every Persian character replaced by `?` (codepoint `0x3f`). The first round-trip attempt therefore "passed" its internal consistency checks while storing `????` — the API and DB agreed with each other, and both were wrong. ### What was re-run The customer round-trip test, with the payload written by Node as UTF-8 and sent with `curl --data-binary @file`. It then passed on real Persian text: create «محمد علی» + «احمدی» → edit form shows each part correctly → no-op save leaves the row byte-identical → single-field edits touch only that field. The three rows from both attempts were deleted and logged. ### The rule going forward - Fixtures live in the test file or in a `.json` written with `utf8` encoding. - HTTP bodies are sent with `--data-binary @file`, not `-d ''`. - Assertions compare against literals **in the test file**, never against a string that has passed through the shell. - Console output of Persian is not evidence: the Windows console renders it as `?` even when the data is correct. Verify by comparing values or codepoints in Node, as `scripts/test-name-trigger.cjs` does. --- # D16 — Price display is storefront; new work order (2026-09-22) ## 1. PRICE-01 is stopped — price belongs to the retail storefront **Owner's ruling.** Computing and displaying `price` / `price_with_discount` is a **retail storefront (تک‌فروشی)** concern. The B2B app works in **making charge (اجرت)** only. So PRICE-01 falls under D13 and is marked **⏸️ preserved — out of current scope**. | Item | Ruling | |---|---| | `apps/api/src/modules/pricing/variant-price.ts` | **Kept, not deleted.** Simply not used. | | `variant-price.spec.ts` (11 hand-checked tests) | **Kept** and still runs in CI. | | Any code wiring price into the admin panel | **Revert.** — *None existed: `computeVariantPrice` was never called from anywhere outside its own file and spec, so there was nothing to revert. Verified by grep over `apps/api/src` and `apps/admin-panel/src`.* | | Making-charge fields — `percentage_pay`, `makingChargeOffsetPercent`, wage display | **Still fully in scope.** | PRICE-01 in `ISSUES.md` is re-marked ⏸️ rather than deleted, so the analysis and its evidence stay available if the storefront is ever taken up. **Why this matters beyond one row:** the reference's `ProductVariant::$appends` carries `price`, `color_str` and `price_with_discount` together (`ProductVariant.php:115-119`). Only the price pair is storefront; anything else appended there is still judged on its own merits. ## 2. The gate stands: admin first, then the apps ### Phase 1 — admin, in this order **(a) Persons (اشخاص)** 1. The **5 missing sales-expert fields** (`ncode`, `phone`, `city_id`, `gram_level`, `reagent_id`): **report first** — where the reference uses each one for `type=1` — then add according to what that shows. Standing proposal: a `SalesExpertProfile` related to `AdminUser` (D10). Fields with no use are **listed for the owner to decide**, not added speculatively. 2. `singleCustomer` migrates to **diff-send** (D11). 3. Field-by-field audit of every form and grid in Persons — retail customer, wholesale customer, sales expert — per D12, each with its round-trip test. **(b) Products, categories, variants, and order detail in the admin** — deep field-by-field: every form field, column, filter and button (D12). - The **variant image picker stays untouched** (PROTECTED, `PROTECTED.md` §B); only genuine omissions are added. - The **making charge of every variant and every order line must display and persist exactly as the reference does.** **(c) The rest of the admin, in order:** financial documents → users and permissions → admin SMS templates → promotions / notifications / cities / branches / workshops → reports. **(d)** Full admin test pass, the Phase 1 report, then the gate question **«ادمین کامل و تست شد. بریم سراغ اپ‌ها؟»** — and **stop and wait**. ### Phase 2 — apps (only after the owner approves) **(a)** The 5 v2-behind-v1 logic gaps, most important the **cart stock ceiling (`capQty`/`maxQty`)**, matching v1 and the reference exactly — v2's design fully preserved, with a test proving v1 and v2 behave identically. **(b)** The missing screens (`report`, `report/detail`, `ChildOrder`, `customerChoice`, `complete_profile`) in **both** apps, each in its own design, plus whatever backend they need. **(c)** Field-by-field audit of products, categories, variants, cart and order in the apps. **(d)** The remaining Phase 2 items: MON-08, removing `customerId` from the order DTO, route analytics, the `isLoaded` / `sts_verify_step_two` guards, guest behaviour, app SMS templates, and the remaining screens. ## 3. Working method per section **Implement the gaps first, then field-by-field test that same section.** Before fixing any section, **show the owner a short summary of the gaps found** and wait. This supersedes the "audit everything, then fix everything" reading of D12: the field-by-field requirement is unchanged, but it is applied **per section**, with a checkpoint before each one. --- # D17 — Sales expert: `SalesExpertProfile`, and `reagent_id` deliberately not ported (2026-09-22) ## `reagent_id` — deliberately NOT replicated **Owner's ruling, verbatim:** > «`reagent_id` در فرم کارشناس مرجع، اثر جانبی فرم مشترک `customers` بوده و پر > کردنش کارشناس را از لیست قابل‌انتخاب حذف می‌کرد (`queryReagent:420`). عمداً > پورت نشد. منطق معرف روی `Customer.referredByAgentId` کامل پیاده شده است.» ### The evidence behind it `reagent_id` carries real logic in the reference — but **every single use reads a CUSTOMER's `reagent_id`** (which agent introduced them), never a sales expert's own: | Use | Reference | What it does | |---|---|---| | Scope "my customers" | `AdminController.php:271` | `->where('reagent_id', $auth->id)` | | Set on customer create | `AdminController.php:326` | records the introducing agent | | Commission / gram-sold rollup | `MajorOrderController.php:267-269,316-318` | `updateGramSell($customer->reagent_id)` | | SMS to the customer's agent | `ApiMobileController.php:727,750` | notify the agent | | Analytics rollup | `AdminController.php:550-552` | `whereNull('reagent_id')` roots + their children | A `grep` for any code reading `reagent_id` on a `type=1` row returns **nothing**. **The decisive line is `queryReagent` (`AdminController.php:415-424`):** ```php Customer::select(['id', 'name as title'])->whereNull('reagent_id') ``` Only rows **without** a `reagent_id` may be chosen as an agent. So filling the `reagent_id` field on a sales expert's own form (`ReagentCustomer.vue:139`) **removes that expert from the selectable-agent list** — an unwanted side effect of the shared `customers` form, not a feature. The same `whereNull` filter shapes the analytics rollup. **Therefore:** the field is not added to `SalesExpertProfile`. Adding it would either do nothing or reproduce that defect. No new hierarchy logic is invented. Matrix annotation: **«عمداً تکرار نشد — D17»**. ### Confirmed: no agent can be dropped from the referrer dropdown The owner asked for explicit confirmation. The target's agent list is `queryReagents()` (`apps/api/src/modules/admin-customers/admin-customers.service.ts:779-786`): ```ts this.prisma.adminUser.findMany({ where: { role: 'SALES_EXPERT', isActive: true }, orderBy: { fullName: 'asc' }, select: { id: true, fullName: true }, }) ``` It filters on **role and active only** — there is no `whereNull` equivalent, so **every active sales expert appears and none can be accidentally excluded.** This is strictly better than the reference, whose `whereNull('reagent_id')` silently hid any agent whose own `reagent_id` had been filled in. Wiring: `admin-customers.controller.ts:82` → `GET admin/customers-omde/query/reagent`, mapped at `apps/admin-panel/src/api/endpoints.ts:77`, consumed by `bulkCustomer.vue:436`. ## `SalesExpertProfile` — approved shape A separate table rather than columns on `AdminUser`, because these fields are meaningful only for `role = SALES_EXPERT` and would be permanently null for every other admin. | Column | Type | Reference origin | Why | |---|---|---|---| | `adminUserId` | unique FK → `AdminUser` | — | one profile per expert | | `cityId` | FK → `City`, nullable | `ReagentCustomer.vue:111` | a filterable grid column (`ReagentListModel.js:14`) **and** it drives the commission autofill | | `gramLevel` | `Decimal(14,4)`, default 0 | `ReagentCustomer.vue:158` | the «سطح اعتبار» column, filterable (`ReagentListModel.js:17`) | | `phone` | text, nullable | `ReagentCustomer.vue:95` | stored in the reference; no reader — added on the owner's instruction | | `nationalCode` | text, nullable | `ReagentCustomer.vue:31` | same | `phone` and `nationalCode` keep the reference's validation exactly: `required: false`, message `لطفا مقدار را وارد کنید`. ## City-driven commission autofill Ported verbatim from `henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228`: 1. On city selection, look for `percent` on the already-loaded city row. 2. If it is absent or falsy, `GET cities/get-percent-aux/{id}` and use the response. 3. Write the result into the form's `percent` field. Backend: `AdminController.php` behind `admin.php:225-226` (`cities/get-percent/{id}` and `cities/get-percent-aux/{id}`), reading `cities.percent` / `cities.percent_aux` — both already present on the target's `City` model as `percent` / `percentAux` (`schema.prisma` `model City`). ## `firstName` / `lastName` on `AdminUser` Closes D0 requirement 7. Same pattern as `Customer`, applied to **all** `AdminUser` rows, not only sales experts: additive columns, backfill (first token → `firstName`, the rest → `lastName`), a sync trigger where **the parts win** when both change in one statement, and a multi-token review report. Every `fname: fullName, lname: ''` site is corrected — starting with `listReagents` (`admin-customers.service.ts:375-376`). --- # D18 — Standing approval (owner decision, 2026-09-22) To reduce round-trips, the owner granted a **standing approval**. From now on the following are done **without stopping and without asking**, with only a short report at the end of each section before moving straight to the next: - Implementing gaps whose reference behavior is unambiguous — a field, column, filter, validation, message, button or page. - Fixing target/reference mismatches where the reference behavior is clear. - **Purely additive** migrations (a new column or table) on the development DB, provided a dry-run inside a transaction with rollback is clean first. - Writing and running tests. - Applying the same fix to other sites of the same pattern (as with the six extra `fname: fullName, lname: ''` sites found during D17). **STOP and ask** only for: 1. Deleting any data, column, table, file or capability. 2. Any change to a PROTECTED zone or to the storefront surfaces of D13. 3. Any place the reference behavior is ambiguous or open to two readings. 4. Any place a reference bug is NOT being ported and that affects business logic (the D17 situation). 5. A migration that alters existing data (backfill or update), unless it only touches test data. 6. The Phase 1 end gate. Everything else in DECISIONS, PROTECTED, D13 and D15 stays in force. In particular this approval does **not** loosen: the ban on `migrate reset` or touching `_prisma_migrations`, the CHANGELOG entry required for every data deletion, or the UTF-8-from-file rule for tests with Persian input. --- # D19 — Delivery Mode (owner decision, 2026-09-22) The owner has a **delivery tonight**. Until further notice: 1. **No more deep field-by-field auditing and no more questions.** Implement the main gaps and make sure the project works. 2. **The "admin first, then apps" gate is lifted for tonight.** Work on admin and both apps in the priority order below. 3. **Nothing is deleted or changed** in PROTECTED zones, storefront (D13) areas, data, features or designs. All earlier security rules remain in force. 4. **When a decision is needed, pick the lowest-risk option that breaks nothing**, continue, and record it here rather than stopping to ask. ## Answers that closed the Persons audit | Q | Ruling | |---|---| | A — `singleCustomer` diff-send | **Do not touch it** (D13 wins). **D11 is CANCELLED.** | | B — lock `mobile` on edit | **Not ported.** The target keeps its current editable behaviour. Logged in ISSUES. | | Gap 2 — `faToEn` on admin mobile | **Implement** (create and update). | | Gap 1 — welcome SMS | **Implement**, byte-identical text, but non-blocking per D20. | | Gap 4 — province/city tree | **ISSUES only.** | | Gap 5 — Jalali birth date | **ISSUES only.** | **D11 is superseded by this decision and must not be acted on.** --- # D20 — SMS boot rule reversed, and a master OTP for delivery (2026-09-22) ## 1. SMS must never block anything The rule from D14 — *"in production, if the SMS key is missing the server must fail to boot with a clear error"* — is **REMOVED**. New behaviour, in every environment: * Missing credentials → log a **warning** and **boot normally**. * `send()` with no credentials → **silently declines and logs**; it never throws. * **No SMS, including the welcome SMS, may ever stop an operation.** Customer creation succeeds whether or not the message goes out. The live send test to the owner's number is **cancelled**; the SMS panel is not a priority for this delivery. ## 2. `OTP_MASTER_CODE` — a manual login code for the delivery A temporary bypass so login works during the demo. | Rule | Detail | |---|---| | Source | **`OTP_MASTER_CODE` env var only.** Never hardcoded in source. | | Default | Unset or empty → **completely disabled**. | | Effect | When set, it is accepted for **any active customer's** mobile, **alongside** the real code, which keeps working. | | Unchanged | Active-customer check, throttling and attempt limits all still apply. | | Boot | When enabled, print a clear warning: `OTP_MASTER_CODE فعال است — قبل از انتشار اصلی حذف شود`. | | Audit | Every login through it is logged **with the customer's mobile**. | Tests required: master code logs in; with the env var absent the same code is rejected; the real code still works. **Before the real release: remove `OTP_MASTER_CODE` from the environment and configure the SMS credentials.** Recorded in `DELIVERY.md` and as a Critical entry in `ISSUES.md`. --- # D21 — Sales-expert login to the customer apps (owner decision, 2026-09-23) The owner required that a sales expert be able to sign into the customer apps, **without** reversing D17 (the expert stays an AdminUser, never a Customer). ## The problem In the reference a sales agent WAS a Customer row (`customers.type = 1`), so: * they logged in through the ordinary customer flow and got the whole app; * `setOrder` did `Customer::findOrFail(auth()->id())` and read that row's `gram_level` (`ApiMobileController.php:534-535`) — the agent's own order, own credit limit, own ledger; * `getChildOrder` filtered `where('reagent_id', auth()->id())` (`:767`) and `myChild` the same (`:188`) — the logged-in customer's id. Here the expert is an AdminUser, which has **no ledger and no credit limit** and cannot be an `Order`'s subject (`Order.customerId` is a required FK). ## The design **One token family, two principals.** The app token's `typ` claim is either `'customer'` or `'sales-expert'`; `customer-jwt.strategy.ts` resolves both to one shape whose `id` is **always a Customer id**. Every route already written for a customer therefore works unchanged for an expert — which is exactly what the reference gave them. **A trading account.** Each expert gets **one** linked Customer row (`SalesExpertProfile.tradingCustomerId`, additive migration `20260923120000_sales_expert_trading_customer`), created lazily at their first `request-otp`. It carries only their own trading position. ### Answering the owner's question — whose name, and from where | Question | Answer | Source | |---|---|---| | An order the expert places is **whose**? | Their **own trading account** — not a customer's | Reference: the agent's order was their own row | | Its **making charge**? | `AdminUser.wagePercent` → `makingChargeOffsetPercent` | Reference: the agent's `customers.percent` | | Its **credit limit**? | `SalesExpertProfile.gramLevel` → `goldCreditLimitGrams` | Reference: `$customer->gram_level` (`:535`) | Both numbers are the same two the reference read off the agent's own Customer row, so the behaviour matches; only the storage location differs. Seeded at creation and not resynchronised afterwards — an operator editing the expert's commission changes it for their referred customers, and changing an existing trading account's terms silently is a money change nobody asked for. ## Scope rules * `Customer.referredByAgentId` points at the **AdminUser**, so "my customers" and "my customers' orders" scope on `me.adminUserId`, taken from the token. An expert can never see another expert's customers (tested, both directions). * The trading account has `referredByAgentId = null` — an expert is not their own referred customer. * Child orders keep the reference's `sts = 5` filter as `status = DELIVERED_TO_AGENT` — the same «تحویل مسئول فروش» point. * Both expert routes 403 for an ordinary customer, and `expert/whoami` lets the apps hide the menu entries. The server does not rely on the UI hiding them. ## Token boundaries (all tested) | Token | On | Result | |---|---|---| | expert app token | admin route | **401** | | customer token | admin route | **401** | | admin token | expert route | **401** | | expert token, after deactivation | expert route | **401**, immediately | `jwt.strategy.ts` gained an explicit check that rejects any token carrying an app `typ`. This matters because an expert's app token holds their **AdminUser id** in `sub`: `JWT_CUSTOMER_SECRET` falls back to `JWT_SECRET` when unset, and in that configuration the token would otherwise have validated as an admin and been handed full panel permissions. ## Deliberately NOT ported `myChild` minted an `api_token` for every listed customer as a side effect (`ApiMobileController.php:197-201`), which let an agent act **as** that customer. That is a session-hijack primitive; every session here derives from the caller's own token. Not ported, and recorded here rather than silently dropped. > ⚠️ **SUPERSEDED BY D29 (2026-10-12).** The owner asked for the capability > back, so acting as one's own customer now exists — with the reference's full > scope, but not its mechanism: no token is minted, the expert keeps their own, > and ownership is re-checked on every request. The paragraph above still > describes correctly what is **not** done (minting customer tokens). See D29. ## Ambiguity resolved the low-risk way If one mobile ever belonged to both an expert and a customer, the owner ruled the **expert wins**. Since `Customer.mobile` is unique, the code adopts the existing customer row as the expert's trading account rather than failing the login or creating a duplicate, and logs a warning. --- ## D22 — «تکمیل پروفایل» (complete_profile), 2026-09-23 Ports `POST /major/complete` (henza_server `Major/CustomerController::complete`, reached from `henza_pwa/src/views/user/src/completeProfile.vue`) as `POST customer/me/complete-profile`. **Reference behaviour kept** - all three document ids required; a miss is rejected before anything is written - the customer comes from the session, never from the body (D21) - the verification step is set to the reference's **live** value 2 — approved outright, not "waiting for an expert". The reference's own `sts_verify_step_two = 1` line is commented out at `:83` in favour of `= 2` at `:85`. That is `VerificationStep.VERIFIED` here. - both SMS messages byte-identical, and non-blocking per D20 - the entry point is the **profile screen**, shown only while the documents are missing (`profile.vue:96-102`), and the route redirects home once they are in (`router/index.ts:266`) **Reference behaviour deliberately NOT ported** (all in ISSUES.md) - **SEC-14** — `$customer->update($request->all())` mass-assigned the whole request body, letting the app write its own credit limit, making charge, gold debt, active flag or mobile. Replaced with a three-field whitelist. - **SEC-15** — four real personal mobiles hardcoded in the source. They come from `PROFILE_ADMIN_MOBILES` instead; blank means nobody is notified. - **APP-03** — `refresh()`'s unterminated `setInterval` page reload. **One gap had to be closed first (UPL-01).** The reference let the app upload through the same `file/create` the admin used, inside its `auth:api-customer` group; the target's copy is admin-guarded. Added `POST customer/me/documents`, reusing `AdminUploadsService` unchanged so both paths share one size limit, MIME whitelist and naming scheme. ## D23 — `JWT_CUSTOMER_SECRET` warns loudly, 2026-09-23 `customerJwtSecret()` falls back to `JWT_SECRET`. When it does, app and admin tokens are signed with the **same key**, leaving the `typ` check in `jwt.strategy.ts` as the only barrier — and an expert's app token carries their AdminUser id in `sub`. The owner asked for a refusal to boot **or** a very clear warning; the lower-risk option was chosen (D19), since failing the boot could take down a running deployment that has not set the variable yet. A banner as loud as the master-OTP one now fires at boot, and DELIVERY.md lists it as the **first** pre-release step (REL-03). ## D24 — «گزارش» (report + report/detail) on the internal ledger, 2026-09-23 **In the reference this feature computes nothing.** `Customer\TahesabController` (henza_server `app/Http/Controllers/Customer/TahesabController.php`) forwards every call to an external accounting system — `hesab.henzagold.com` and a bare IP at `31.7.68.147:3030` — with `Http::withoutVerifying()` (TLS verification off), a bearer token hardcoded in the source and a URL-embedded `sfx` secret, keyed on `Customer.mcode`, the customer's id in that other system. The PWA renders whatever comes back. The target has no such system and no `mcode`. **The owner chose option الف:** build the reference's UI and flow over our own ledger, with no external connection. So `customer/me/report*` reproduces the statement's SHAPE — the field names `henza_pwa/src/type/accounting.ts` declares, so the ported components bind unchanged — from `LedgerEntry`. **Honesty rule.** Every figure is computed from confirmed ledger entries. Where the external system carried a column the target has no equivalent for (foreign currency, وزن پلاستیک, نام کارساخته, جنس فلز and its internal bookkeeping ids), the field is `null`/zero and the UI **omits the row** rather than rendering a fabricated value. Recorded as REP-01. **Two things the target does better, deliberately:** - Only **confirmed** entries count, the same rule the cached `Customer.goldDebtGrams` / `fiatDebtRial` follow — so the statement's closing balance agrees with the figure shown everywhere else in the app. Asserted by the test. - **SEC-16:** the reference's `detail($id)` took the factor code from the URL and performed **no ownership check whatsoever** — it never looks at `auth()` — so any signed-in customer could read any other customer's document by guessing a code. Here the id is only ever a filter on top of a `customerId` scope, so someone else's id returns nothing. Tested in both directions. The reference gated the page on `myStore.hasMCode()`; with no mcode in the target the gate is simply a session, and the server scopes every row to the token. ## D25 — blank env vars are treated as unset, 2026-09-23 Found by the pre-delivery compose run (DEPLOY-02): **admin login returned 500 for every user in Docker.** `docker-compose.yml` passes each optional variable as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code read them with `process.env.X ?? 'default'`, which only falls back on `undefined` — so the empty string won and `jsonwebtoken` threw on every sign. Invisible to every existing suite, all of which ran against a local dev process where the variable is genuinely absent. `src/common/env.ts` now provides `envOr` / `envOrNull`, which treat blank as unset, and every auth, port, upload and SMS read uses them. Three of those were not merely cosmetic: a blank `UPLOAD_DIR` would have served static files from the **filesystem root**, a blank `PORT` would have bound a random ephemeral port (`Number('') === 0`), and a blank `JWT_SECRET` would have become the verifying key. Pinned by `src/common/env.spec.ts`, which reproduces the original throw against the real `jsonwebtoken` and asserts the reader can no longer produce it. **Standing rule from here on:** read an environment variable with `envOr` / `envOrNull`, never with `??`, unless an empty string is a meaningful value for that specific variable. ## D26 — SMS through the owner's relay gateway, drivers per provider, 2026-10-05 The owner supplied the PHP client of their own SMS gateway — a relay at `http://185.231.112.101/api/createsms` that handles SMS for several panels and apps. Owner rulings: | Topic | Ruling | |---|---| | Default | The relay **replaces Trez as the default**. Trez is **kept**, selectable with `SMS_PROVIDER=trez`; more providers may follow. | | Secret | The `secret_key` **lives in source** as the default. The gateway has a second verification factor held elsewhere, so the owner does not treat it as sensitive. `SMS_RELAY_SECRET` overrides it. (Contrast SEC-12, where the Trez credentials stay env-only.) | | Platform | **Every** message goes through Kavenegar (`platform: "kave"`). | | Host | The server's own IP is correct, as in the PHP client; `SMS_RELAY_HOST` overrides it. | | Address | The IP over HTTP is correct. HTTPS on that IP presents an expired `*.montpar.ir` certificate (checked 2026-10-05), so HTTPS is not usable on the IP; `SMS_RELAY_URL` can switch later. | | Replies | Undocumented; left to the implementation. A 2xx counts as sent unless the JSON body says `status: false`, `success: false`, `ok: false`, `status: "error"`/`"failed"` or carries `error`. | Implementation: `apps/api/src/modules/sms/drivers/` — one `SmsDriver` per provider (`relay.driver.ts`, `trez.driver.ts`) registered in `drivers/index.ts`. A new provider is one file plus one registry line. `SmsService` keeps the cross-provider rules: trim, persist every message (SEC-11 for OTP unchanged), log failures and never throw (D20). Plan: `docs/SMS-RELAY-PLAN.md`. Consequence: with no SMS variables set, **every environment now sends real SMS**, local development included. Set `SMS_PROVIDER=none` on a machine that must not send. ## D27 — accounting integration (Tahesab / Zarrin), switchable from the admin panel, 2026-10-05 The owner wants the app connected to the shop's accounting software, and the software differs per installation: **Tahesab** or **Zarrin**, picked with one switch in the admin panel (`none | tahesab | zarrin`). Plan and the full API survey: `docs/ACCOUNTING-INTEGRATION-PLAN.md`. Owner rulings: | Topic | Ruling | |---|---| | Scope | Customer sync, balance sync, order documents, and ready-made items (کارساخته) ↔ products. **Everything else stays in the app** — repayment documents (melted gold, cash, cheque) are not sent. | | Balance | The accounting balance is **shown to the customer**; the **credit-limit check stays on the internal ledger**. | | Report | «گزارش» reads from the accounting software when the integration is on, so it agrees with the balance shown. Supersedes D24 for that case only; D24 remains the fallback. | | Customers | Created in the accounting software when an admin marks them **VERIFIED**; existing ones are linked by **mobile**. Accounts that exist only in the accounting software are **created in the app**, keyed by mobile (national code falls back to the mobile, as elsewhere). | | Documents | Sent at the debt-recognition point (`DELIVERED_TO_AGENT`) as **temporary** (موقت) documents for the accountant to finalise. Ready-made items only for now; gold/coin trading later. | | Products | One accounting کارساخته = one app **Product**. Title, code and stock belong to the accounting software; images, description, variants, category and **making charge belong to the app**. | | Visibility | **A product with no image is never shown in the app** — all products, automatically. | | Stock | Accounting stock is shown in the admin panel; at zero the product is unavailable in the app (pre-order variants excepted). | Security carried over from the reference's mistakes (SEC-06/16/17): TLS verification is never disabled (a custom CA can be supplied instead), the token is write-only and encrypted at rest with `ACCOUNTING_SECRET_KEY`, the generic `const` settings routes refuse `accounting.*` keys, and statement details are scoped to the token's own customer. Later rulings, same day (plan §11–§12, ACC-08/09): sync is real-time (change feed polled every 5 s, sends right after commit, order-time live stock that falls back to the last known figure); sync buttons on the wholesale-customer and product lists and a live balance on the app profile, all working whenever a provider is connected regardless of the feature switches; and the order status that posts the temporary document (پیش‌فاکتور) is an admin-chosen rule (`documents.triggerStatus`, default «تحویل مسئول فروش»). --- ## D28 — ورود اپ باید خطای واقعی را بگوید (۲۰۲۶/۱۰/۱۲) **گزارش مالک:** «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد میبردیش توی صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد یا نه واقعا نیست میخوام خطا بدی کاربر یافت نشد» **تصمیم:** مرحله اول ورود باید علت را بگوید. | حالت | قبل | بعد | |---|---|---| | شماره ثبت نشده | `{ sent: true }` — کاربر به صفحه کد می‌رفت | **۴۰۴** «این شماره در سیستم ثبت نشده است…» | | پیامک فرستاده نشد (درگاه خراب) | `{ sent: true }` | **۵۰۳** «ارسال پیام کوتاه در حال حاضر ممکن نیست…» | | کارشناس فروش با حساب معاملاتی غیرفعال | `{ sent: true }` | **۴۰۳** «حساب شما غیرفعال است…» | | کارشناس فروش بدون پروفایل | `{ sent: true }` | **۴۰۳** «حساب شما کامل نیست…» | **نسبت با مرجع:** این همان کاری است که مرجع می‌کرد (`firstOrFail()` در `ApiMobileController::userAuth:43` ⇒ ۴۰۴). پاسخ مبهم، انحراف ما بود نه این. **معاوضه امنیتی، صریح:** پاسخ مبهم برای این بود که کسی از بیرون نتواند بفهمد چه شماره‌هایی در سیستم ثبت‌اند. این محافظت در مرحله اول کاهش یافت، **عمداً و به دستور مالک**، چون هزینه‌اش را کاربران واقعی و پشتیبانی می‌دادند. چیزی که سر جایش ماند: سقف تعداد ارسال برای هر شماره و هر IP (`assertSendAllowed`) که مرجع نداشت — پس شمارش‌کردن شماره‌ها محدود است نه آزاد؛ و مرحله دوم (`verifyOtp`) که برای همه علت‌های شکست یک پیام واحد می‌دهد. **چرا D20 اینجا نقض نمی‌شود:** D20 می‌گوید پیامک ناموفق نباید یک *عملیات* را خراب کند — سفارش باید ثبت شود حتی اگر پیامکش نرود. در ورود، پیامک **خودِ عملیات** است، پس سکوت از چیزی محافظت نمی‌کند. ثبت شده در `ISSUES.md` به‌عنوان AUTH-01/02/03 و DOC-01. --- ## D29 — «ورود به حساب مشتری» برای کارشناس فروش (۲۰۲۶/۱۰/۱۲) **خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید و این خیلی کاربردی بود ولی الان نیست» و سپس: «همان قابلیت هارو میخوام حالا هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره». این **D21 را اصلاح می‌کند**: آنجا `changeUser` «عمداً پورت نشد» و به‌عنوان session-hijack رد شد. مالک قابلیت را خواست، پس بازگردانده شد — با همان دامنه کامل مرجع، ولی با سازوکار دیگر. ### دامنه: کامل، مثل مرجع مرجع توکن را در interceptor سراسری axios عوض می‌کرد (`henza_pwa/plugins/axios.ts:18` ← `getUserToken()`)، پس **هر** درخواست از طرف مشتری می‌رفت: دیدن، ثبت سفارش، علاقه‌مندی، و آپلود مدارک. همان دامنه حفظ شد — هیچ مسیری در حالت act-as بسته نشد. ### سازوکار: هدر، نه توکن | | مرجع | اینجا | |---|---|---| | چطور | توکن دائمی مشتری (`api_token`) به کارشناس داده می‌شد (`ApiMobileController.php:197-201`) | کارشناس توکن **خودش** را می‌فرستد + هدر `X-Act-As-Customer` | | کجا اعمال می‌شود | interceptor کلاینت | `CustomerJwtStrategy.validateExpert` — تنها نقطه | | بررسی مالکیت | یک‌بار، در لحظه گرفتن توکن | **هر درخواست**، با `referredByAgentId` در همان کوئری | | ماندگاری | `localStorage`، بی‌انقضا | `sessionStorage`؛ بستن تب = پایان | | ردّ حسابرسی | هیچ — درخواست عیناً مشتری بود | `adminUserId` حفظ می‌شود؛ سفارش لاگ می‌شود | | بعد از قطع همکاری کارشناس | توکن مشتری همچنان کار می‌کرد | ۴۰۱ فوری (نقش و `isActive` هر بار خوانده می‌شود) | **چرا این نقطه:** `validate()` همیشه یک principal می‌سازد که `id` آن یک Customer id است و **هر ۱۷ مسیر مشتری** از آن می‌خوانند. پس تغییر در یک نقطه، همه مسیرها را از دید مشتری می‌کند — بدون تغییر در هیچ route. **پرفورمنس:** یک `findFirst` روی کلید اصلی، **فقط** وقتی هدر هست؛ و جایگزین کوئریِ `tradingCustomer` می‌شود نه اضافه بر آن. مشتری عادی و کارشناس در حالت معمولی صفر هزینه اضافه. **۴۰۳ و نه ۴۰۱** برای مشتریِ غیرمجاز: نشست خود کارشناس سالم است، پس اپ وضعیت act-as را پاک می‌کند و او را از سیستم بیرون نمی‌اندازد. ### چیزهایی که مرجع نداشت و اضافه شد ۱. **نوار همیشگی «در حال مشاهده به‌عنوان …» + دکمه خروج.** در مرجع، جابجایی نامرئی بود: راه فهمیدن اینکه در حساب کیستی، فقط دیدن داده‌های عجیب بود، و ثبت سفارش روی حساب اشتباه یک کلیک فاصله داشت. تنها راه خروج هم logout بود که نشست خود کارشناس را هم می‌بست (`Dashboard.vue:781-789`). ۲. **پاک‌سازی حالت در جابجایی** (هر دو جهت): سبد، `queryCache` کلیدهای `me:`، علاقه‌مندی‌ها، و **اجرت‌های کش‌شده**. این آخری مالی است: اجرت هر مشتری متفاوت است، و بدون پاک‌سازی عددِ روی صفحه با عددِ ثبت سفارش یکی نمی‌بود. (در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض می‌شد.) ثبت شده در `ISSUES.md` به‌عنوان EXP-01. ## D30 — بخش‌های «فقط سوپر ادمین» در پنل مدیریت (۲۰۲۶/۱۰/۱۴) **خواسته مالک:** این بخش‌ها فقط به سوپر ادمین (۰۹۱۲۸۰۸۱۳۹۲) نشان داده شوند و در سطح دسترسی‌ها هم نباشند: لیست مشتریان تک، لیست بنکداران، لیست مغازه‌داران حضوری، شعب و کارگاه، تب «مشتری تک» در افزودن شخص، فیلد «سینی ها» در فرم محصول، ثبت سفارش تک و لیست سفارش‌های تک، «افزودن تخفیف تک»، و «مدیریت کیف پول». **پاسخ‌های مالک:** - سوپر ادمین = **فقط حساب با موبایل ۰۹۱۲۸۰۸۱۳۹۲**، نه هر ادمینی که نقش `SUPER_ADMIN` دارد. - اعمال **کامل**: مخفی در پنل + بستن آدرس مستقیم صفحه + ۴۰۳ از API. - «سینی ها» در **هر دو جا**: فیلد فرم محصول و تب «مدیریت ثابت ها». - تخفیف تک: **افزودن + ویرایش + حذف**، و هر سه از سطح دسترسی‌ها حذف. **سازوکار.** `@SuperAdminOnly()` در `apps/api/src/modules/auth/guards.ts`، که `PermissionsGuard` سراسری آن را اعمال می‌کند؛ شماره و فهرست rid ها در `auth/super-admin.ts`. به سوپر ادمین rid های این بخش‌ها همیشه داده می‌شود (`withSuperAdminRids`) تا با ذخیره‌ی دوباره‌ی سطح دسترسی‌اش از دست نروند. در پنل: `users.isSuperAdmin` (از `is_super_admin` در login و `users/show`)، `superAdminOnly` روی منو و route، و حذف این rid ها از درخت صفحه دسترسی‌ها برای غیر سوپر ادمین. **تصمیم‌های جانبی (برای نشکستن چیزهای دیگر):** - **پنل شعبه** همان endpoint های مشتری تک و سفارش تک را صدا می‌زند؛ این endpoint ها برای ادمینِ دارای `branchId` هم باز ماند (`allowBranch`). بدون این، کارکنان شعبه قفل می‌شدند. پیامد: ادمینی که شعبه دارد از API به این چهار endpoint دسترسی دارد. - **سوپر ادمین در درخت دسترسی‌ها همه را می‌بیند** تا بتواند این rid ها را به سطح دسترسی کارکنان شعبه بدهد. دیگران آن‌ها را نمی‌بینند، و rid هایی که سطح از قبل دارد هنگام ذخیره توسط آن‌ها **حفظ** می‌شود (حذف بی‌صدا نمی‌شود). - `admin/orders/:id`، `status` و `status-options` بین سفارش تک، عمده و تامین مشترک‌اند و `order.list` می‌خواستند؛ حالا هر کدام از `order.list` / `order.omde` / `order.tamin` کافی است (`RequirePermission` چند rid می‌پذیرد). - لیست کارگاه‌ها (`admin/workshops/list`) **باز ماند**: انتخاب کارگاه در «سفارش های ساختنی» از آن است. بقیه‌ی endpoint های کارگاه و همه‌ی شعب بسته شدند. - **تخفیف تک فقط در پنل بسته شد، نه در API**: API تک و عمده را از هم تشخیص نمی‌دهد (PROMO-TAK-01). - بنکداران/مغازه‌داران و سینی‌ها rid مشترک با بخش‌های دیگر دارند (`persons.list.client.omde`، `collection.*`)؛ پس از درخت حذف نشدند و با `@SuperAdminOnly` / `isSuperAdmin` بسته شدند.