Files
tehran_taj/docs/parity/DECISIONS.md
T

1068 lines
53 KiB
Markdown
Raw Normal View History

# 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 '<inline>'`.
- 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`).