Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1516 lines
79 KiB
Markdown
1516 lines
79 KiB
Markdown
# 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`).
|
||
|
||
---
|
||
|
||
# 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.
|