Files
hedena_gold/docs/parity/DECISIONS.md
T
s.esmaeili ea86636027 Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
2026-10-15 17:19:10 +03:30

83 KiB
Raw Blame 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:

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):

$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:

// 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 percents; 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:

$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):

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):

this.prisma.adminUser.findMany({
  where: { role: 'SALES_EXPERT', isActive: true },
  orderBy: { fullName: 'asc' },
  select: { id: true, fullName: true },
})

It filters on role and active only — there is no whereNull equivalent, so every active sales expert appears and none can be accidentally excluded. This is strictly better than the reference, whose whereNull('reagent_id') silently hid any agent whose own reagent_id had been filled in.

Wiring: admin-customers.controller.ts:82 → GET admin/customers-omde/query/reagent, mapped at apps/admin-panel/src/api/endpoints.ts:77, consumed by bulkCustomer.vue:436.

SalesExpertProfile — approved shape

A separate table rather than columns on AdminUser, because these fields are meaningful only for role = SALES_EXPERT and would be permanently null for every other admin.

Column Type Reference origin Why
adminUserId unique FK → AdminUser — one profile per expert
cityId FK → City, nullable ReagentCustomer.vue:111 a filterable grid column (ReagentListModel.js:14) and it drives the commission autofill
gramLevel Decimal(14,4), default 0 ReagentCustomer.vue:158 the «سطح اعتبار» column, filterable (ReagentListModel.js:17)
phone text, nullable ReagentCustomer.vue:95 stored in the reference; no reader — added on the owner's instruction
nationalCode text, nullable ReagentCustomer.vue:31 same

phone and nationalCode keep the reference's validation exactly: required: false, message لطفا مقدار را وارد کنید.

City-driven commission autofill

Ported verbatim from henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228:

  1. On city selection, look for percent on the already-loaded city row.
  2. If it is absent or falsy, GET cities/get-percent-aux/{id} and use the response.
  3. Write the result into the form's percent field.

Backend: AdminController.php behind admin.php:225-226 (cities/get-percent/{id} and cities/get-percent-aux/{id}), reading cities.percent / cities.percent_aux — both already present on the target's City model as percent / percentAux (schema.prisma model City).

firstName / lastName on AdminUser

Closes D0 requirement 7. Same pattern as Customer, applied to all AdminUser rows, not only sales experts: additive columns, backfill (first token → firstName, the rest → lastName), a sync trigger where the parts win when both change in one statement, and a multi-token review report.

Every fname: fullName, lname: '' site is corrected — starting with listReagents (admin-customers.service.ts:375-376).


D18 — Standing approval (owner decision, 2026-09-22)

To reduce round-trips, the owner granted a standing approval. From now on the following are done without stopping and without asking, with only a short report at the end of each section before moving straight to the next:

  • Implementing gaps whose reference behavior is unambiguous — a field, column, filter, validation, message, button or page.
  • Fixing target/reference mismatches where the reference behavior is clear.
  • Purely additive migrations (a new column or table) on the development DB, provided a dry-run inside a transaction with rollback is clean first.
  • Writing and running tests.
  • Applying the same fix to other sites of the same pattern (as with the six extra fname: fullName, lname: '' sites found during D17).

STOP and ask only for:

  1. Deleting any data, column, table, file or capability.
  2. Any change to a PROTECTED zone or to the storefront surfaces of D13.
  3. Any place the reference behavior is ambiguous or open to two readings.
  4. Any place a reference bug is NOT being ported and that affects business logic (the D17 situation).
  5. A migration that alters existing data (backfill or update), unless it only touches test data.
  6. The Phase 1 end gate.

Everything else in DECISIONS, PROTECTED, D13 and D15 stays in force. In particular this approval does not loosen: the ban on migrate reset or touching _prisma_migrations, the CHANGELOG entry required for every data deletion, or the UTF-8-from-file rule for tests with Persian input.


D19 — Delivery Mode (owner decision, 2026-09-22)

The owner has a delivery tonight. Until further notice:

  1. No more deep field-by-field auditing and no more questions. Implement the main gaps and make sure the project works.
  2. The "admin first, then apps" gate is lifted for tonight. Work on admin and both apps in the priority order below.
  3. Nothing is deleted or changed in PROTECTED zones, storefront (D13) areas, data, features or designs. All earlier security rules remain in force.
  4. When a decision is needed, pick the lowest-risk option that breaks nothing, continue, and record it here rather than stopping to ask.

Answers that closed the Persons audit

Q Ruling
A — singleCustomer diff-send Do not touch it (D13 wins). D11 is CANCELLED.
B — lock mobile on edit Not ported. The target keeps its current editable behaviour. Logged in ISSUES.
Gap 2 — faToEn on admin mobile Implement (create and update).
Gap 1 — welcome SMS Implement, byte-identical text, but non-blocking per D20.
Gap 4 — province/city tree ISSUES only.
Gap 5 — Jalali birth date ISSUES only.

D11 is superseded by this decision and must not be acted on.


D20 — SMS boot rule reversed, and a master OTP for delivery (2026-09-22)

1. SMS must never block anything

The rule from D14 — "in production, if the SMS key is missing the server must fail to boot with a clear error" — is REMOVED.

New behaviour, in every environment:

  • Missing credentials → log a warning and boot normally.
  • send() with no credentials → silently declines and logs; it never throws.
  • No SMS, including the welcome SMS, may ever stop an operation. Customer creation succeeds whether or not the message goes out.

The live send test to the owner's number is cancelled; the SMS panel is not a priority for this delivery.

2. OTP_MASTER_CODE — a manual login code for the delivery

A temporary bypass so login works during the demo.

Rule Detail
Source OTP_MASTER_CODE env var only. Never hardcoded in source.
Default Unset or empty → completely disabled.
Effect When set, it is accepted for any active customer's mobile, alongside the real code, which keeps working.
Unchanged Active-customer check, throttling and attempt limits all still apply.
Boot When enabled, print a clear warning: OTP_MASTER_CODE فعال است — قبل از انتشار اصلی حذف شود.
Audit Every login through it is logged with the customer's mobile.

Tests required: master code logs in; with the env var absent the same code is rejected; the real code still works.

Before the real release: remove OTP_MASTER_CODE from the environment and configure the SMS credentials. Recorded in DELIVERY.md and as a Critical entry in ISSUES.md.


D21 — Sales-expert login to the customer apps (owner decision, 2026-09-23)

The owner required that a sales expert be able to sign into the customer apps, without reversing D17 (the expert stays an AdminUser, never a Customer).

The problem

In the reference a sales agent WAS a Customer row (customers.type = 1), so:

  • they logged in through the ordinary customer flow and got the whole app;
  • setOrder did Customer::findOrFail(auth()->id()) and read that row's gram_level (ApiMobileController.php:534-535) — the agent's own order, own credit limit, own ledger;
  • getChildOrder filtered where('reagent_id', auth()->id()) (:767) and myChild the same (:188) — the logged-in customer's id.

Here the expert is an AdminUser, which has no ledger and no credit limit and cannot be an Order's subject (Order.customerId is a required FK).

The design

One token family, two principals. The app token's typ claim is either 'customer' or 'sales-expert'; customer-jwt.strategy.ts resolves both to one shape whose id is always a Customer id. Every route already written for a customer therefore works unchanged for an expert — which is exactly what the reference gave them.

A trading account. Each expert gets one linked Customer row (SalesExpertProfile.tradingCustomerId, additive migration 20260923120000_sales_expert_trading_customer), created lazily at their first request-otp. It carries only their own trading position.

Answering the owner's question — whose name, and from where

Question Answer Source
An order the expert places is whose? Their own trading account — not a customer's Reference: the agent's order was their own row
Its making charge? AdminUser.wagePercent → makingChargeOffsetPercent Reference: the agent's customers.percent
Its credit limit? SalesExpertProfile.gramLevel → goldCreditLimitGrams Reference: $customer->gram_level (:535)

Both numbers are the same two the reference read off the agent's own Customer row, so the behaviour matches; only the storage location differs. Seeded at creation and not resynchronised afterwards — an operator editing the expert's commission changes it for their referred customers, and changing an existing trading account's terms silently is a money change nobody asked for.

Scope rules

  • Customer.referredByAgentId points at the AdminUser, so "my customers" and "my customers' orders" scope on me.adminUserId, taken from the token. An expert can never see another expert's customers (tested, both directions).
  • The trading account has referredByAgentId = null — an expert is not their own referred customer.
  • Child orders keep the reference's sts = 5 filter as status = DELIVERED_TO_AGENT — the same «تحویل مسئول فروش» point.
  • Both expert routes 403 for an ordinary customer, and expert/whoami lets the apps hide the menu entries. The server does not rely on the UI hiding them.

Token boundaries (all tested)

Token On Result
expert app token admin route 401
customer token admin route 401
admin token expert route 401
expert token, after deactivation expert route 401, immediately

jwt.strategy.ts gained an explicit check that rejects any token carrying an app typ. This matters because an expert's app token holds their AdminUser id in sub: JWT_CUSTOMER_SECRET falls back to JWT_SECRET when unset, and in that configuration the token would otherwise have validated as an admin and been handed full panel permissions.

Deliberately NOT ported

myChild minted an api_token for every listed customer as a side effect (ApiMobileController.php:197-201), which let an agent act as that customer. That is a session-hijack primitive; every session here derives from the caller's own token. Not ported, and recorded here rather than silently dropped.

⚠️ SUPERSEDED BY D29 (2026-10-12). The owner asked for the capability back, so acting as one's own customer now exists — with the reference's full scope, but not its mechanism: no token is minted, the expert keeps their own, and ownership is re-checked on every request. The paragraph above still describes correctly what is not done (minting customer tokens). See D29.

Ambiguity resolved the low-risk way

If one mobile ever belonged to both an expert and a customer, the owner ruled the expert wins. Since Customer.mobile is unique, the code adopts the existing customer row as the expert's trading account rather than failing the login or creating a duplicate, and logs a warning.


D22 — «تکمیل پروفایل» (complete_profile), 2026-09-23

Ports POST /major/complete (henza_server Major/CustomerController::complete, reached from henza_pwa/src/views/user/src/completeProfile.vue) as POST customer/me/complete-profile.

Reference behaviour kept

  • all three document ids required; a miss is rejected before anything is written
  • the customer comes from the session, never from the body (D21)
  • the verification step is set to the reference's live value 2 — approved outright, not "waiting for an expert". The reference's own sts_verify_step_two = 1 line is commented out at :83 in favour of = 2 at :85. That is VerificationStep.VERIFIED here.
  • both SMS messages byte-identical, and non-blocking per D20
  • the entry point is the profile screen, shown only while the documents are missing (profile.vue:96-102), and the route redirects home once they are in (router/index.ts:266)

Reference behaviour deliberately NOT ported (all in ISSUES.md)

  • SEC-14 — $customer->update($request->all()) mass-assigned the whole request body, letting the app write its own credit limit, making charge, gold debt, active flag or mobile. Replaced with a three-field whitelist.
  • SEC-15 — four real personal mobiles hardcoded in the source. They come from PROFILE_ADMIN_MOBILES instead; blank means nobody is notified.
  • APP-03 — refresh()'s unterminated setInterval page reload.

One gap had to be closed first (UPL-01). The reference let the app upload through the same file/create the admin used, inside its auth:api-customer group; the target's copy is admin-guarded. Added POST customer/me/documents, reusing AdminUploadsService unchanged so both paths share one size limit, MIME whitelist and naming scheme.

D23 — JWT_CUSTOMER_SECRET warns loudly, 2026-09-23

customerJwtSecret() falls back to JWT_SECRET. When it does, app and admin tokens are signed with the same key, leaving the typ check in jwt.strategy.ts as the only barrier — and an expert's app token carries their AdminUser id in sub. The owner asked for a refusal to boot or a very clear warning; the lower-risk option was chosen (D19), since failing the boot could take down a running deployment that has not set the variable yet. A banner as loud as the master-OTP one now fires at boot, and DELIVERY.md lists it as the first pre-release step (REL-03).

D24 — «گزارش» (report + report/detail) on the internal ledger, 2026-09-23

In the reference this feature computes nothing. Customer\TahesabController (henza_server app/Http/Controllers/Customer/TahesabController.php) forwards every call to an external accounting system — hesab.henzagold.com and a bare IP at 31.7.68.147:3030 — with Http::withoutVerifying() (TLS verification off), a bearer token hardcoded in the source and a URL-embedded sfx secret, keyed on Customer.mcode, the customer's id in that other system. The PWA renders whatever comes back.

The target has no such system and no mcode. The owner chose option الف: build the reference's UI and flow over our own ledger, with no external connection. So customer/me/report* reproduces the statement's SHAPE — the field names henza_pwa/src/type/accounting.ts declares, so the ported components bind unchanged — from LedgerEntry.

Honesty rule. Every figure is computed from confirmed ledger entries. Where the external system carried a column the target has no equivalent for (foreign currency, وزن پلاستیک, نام کارساخته, جنس فلز and its internal bookkeeping ids), the field is null/zero and the UI omits the row rather than rendering a fabricated value. Recorded as REP-01.

Two things the target does better, deliberately:

  • Only confirmed entries count, the same rule the cached Customer.goldDebtGrams / fiatDebtRial follow — so the statement's closing balance agrees with the figure shown everywhere else in the app. Asserted by the test.
  • SEC-16: the reference's detail($id) took the factor code from the URL and performed no ownership check whatsoever — it never looks at auth() — so any signed-in customer could read any other customer's document by guessing a code. Here the id is only ever a filter on top of a customerId scope, so someone else's id returns nothing. Tested in both directions.

The reference gated the page on myStore.hasMCode(); with no mcode in the target the gate is simply a session, and the server scopes every row to the token.

D25 — blank env vars are treated as unset, 2026-09-23

Found by the pre-delivery compose run (DEPLOY-02): admin login returned 500 for every user in Docker. docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured one arrives as "", not undefined. The code read them with process.env.X ?? 'default', which only falls back on undefined — so the empty string won and jsonwebtoken threw on every sign.

Invisible to every existing suite, all of which ran against a local dev process where the variable is genuinely absent.

src/common/env.ts now provides envOr / envOrNull, which treat blank as unset, and every auth, port, upload and SMS read uses them. Three of those were not merely cosmetic: a blank UPLOAD_DIR would have served static files from the filesystem root, a blank PORT would have bound a random ephemeral port (Number('') === 0), and a blank JWT_SECRET would have become the verifying key. Pinned by src/common/env.spec.ts, which reproduces the original throw against the real jsonwebtoken and asserts the reader can no longer produce it.

Standing rule from here on: read an environment variable with envOr / envOrNull, never with ??, unless an empty string is a meaningful value for that specific variable.

D26 — SMS through the owner's relay gateway, drivers per provider, 2026-10-05

The owner supplied the PHP client of their own SMS gateway — a relay at http://185.231.112.101/api/createsms that handles SMS for several panels and apps. Owner rulings:

Topic Ruling
Default The relay replaces Trez as the default. Trez is kept, selectable with SMS_PROVIDER=trez; more providers may follow.
Secret The secret_key lives in source as the default. The gateway has a second verification factor held elsewhere, so the owner does not treat it as sensitive. SMS_RELAY_SECRET overrides it. (Contrast SEC-12, where the Trez credentials stay env-only.)
Platform Every message goes through Kavenegar (platform: "kave").
Host The server's own IP is correct, as in the PHP client; SMS_RELAY_HOST overrides it.
Address The IP over HTTP is correct. HTTPS on that IP presents an expired *.montpar.ir certificate (checked 2026-10-05), so HTTPS is not usable on the IP; SMS_RELAY_URL can switch later.
Replies Undocumented; left to the implementation. A 2xx counts as sent unless the JSON body says status: false, success: false, ok: false, status: "error"/"failed" or carries error.

Implementation: apps/api/src/modules/sms/drivers/ — one SmsDriver per provider (relay.driver.ts, trez.driver.ts) registered in drivers/index.ts. A new provider is one file plus one registry line. SmsService keeps the cross-provider rules: trim, persist every message (SEC-11 for OTP unchanged), log failures and never throw (D20). Plan: docs/SMS-RELAY-PLAN.md.

Consequence: with no SMS variables set, every environment now sends real SMS, local development included. Set SMS_PROVIDER=none on a machine that must not send.

D27 — accounting integration (Tahesab / Zarrin), switchable from the admin panel, 2026-10-05

The owner wants the app connected to the shop's accounting software, and the software differs per installation: Tahesab or Zarrin, picked with one switch in the admin panel (none | tahesab | zarrin). Plan and the full API survey: docs/ACCOUNTING-INTEGRATION-PLAN.md. Owner rulings:

Topic Ruling
Scope Customer sync, balance sync, order documents, and ready-made items (کارساخته) ↔ products. Everything else stays in the app — repayment documents (melted gold, cash, cheque) are not sent.
Balance The accounting balance is shown to the customer; the credit-limit check stays on the internal ledger.
Report «گزارش» reads from the accounting software when the integration is on, so it agrees with the balance shown. Supersedes D24 for that case only; D24 remains the fallback.
Customers Created in the accounting software when an admin marks them VERIFIED; existing ones are linked by mobile. Accounts that exist only in the accounting software are created in the app, keyed by mobile (national code falls back to the mobile, as elsewhere).
Documents Sent at the debt-recognition point (DELIVERED_TO_AGENT) as temporary (موقت) documents for the accountant to finalise. Ready-made items only for now; gold/coin trading later.
Products One accounting کارساخته = one app Product. Title, code and stock belong to the accounting software; images, description, variants, category and making charge belong to the app.
Visibility A product with no image is never shown in the app — all products, automatically.
Stock Accounting stock is shown in the admin panel; at zero the product is unavailable in the app (pre-order variants excepted).

Security carried over from the reference's mistakes (SEC-06/16/17): TLS verification is never disabled (a custom CA can be supplied instead), the token is write-only and encrypted at rest with ACCOUNTING_SECRET_KEY, the generic const settings routes refuse accounting.* keys, and statement details are scoped to the token's own customer.

Later rulings, same day (plan §11–§12, ACC-08/09): sync is real-time (change feed polled every 5 s, sends right after commit, order-time live stock that falls back to the last known figure); sync buttons on the wholesale-customer and product lists and a live balance on the app profile, all working whenever a provider is connected regardless of the feature switches; and the order status that posts the temporary document (پیش‌فاکتور) is an admin-chosen rule (documents.triggerStatus, default «تحویل مسئول فروش»).


D28 — ورود اپ باید خطای واقعی را بگوید (۲۰۲۶/۱۰/۱۲)

گزارش مالک: «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد میبردیش توی صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد یا نه واقعا نیست میخوام خطا بدی کاربر یافت نشد»

تصمیم: مرحله اول ورود باید علت را بگوید.

حالت قبل بعد
شماره ثبت نشده { sent: true } — کاربر به صفحه کد می‌رفت ۴۰۴ «این شماره در سیستم ثبت نشده است…»
پیامک فرستاده نشد (درگاه خراب) { sent: true } ۵۰۳ «ارسال پیام کوتاه در حال حاضر ممکن نیست…»
کارشناس فروش با حساب معاملاتی غیرفعال { sent: true } ۴۰۳ «حساب شما غیرفعال است…»
کارشناس فروش بدون پروفایل { sent: true } ۴۰۳ «حساب شما کامل نیست…»

نسبت با مرجع: این همان کاری است که مرجع می‌کرد (firstOrFail() در ApiMobileController::userAuth:43 ⇒ ۴۰۴). پاسخ مبهم، انحراف ما بود نه این.

معاوضه امنیتی، صریح: پاسخ مبهم برای این بود که کسی از بیرون نتواند بفهمد چه شماره‌هایی در سیستم ثبت‌اند. این محافظت در مرحله اول کاهش یافت، عمداً و به دستور مالک، چون هزینه‌اش را کاربران واقعی و پشتیبانی می‌دادند. چیزی که سر جایش ماند: سقف تعداد ارسال برای هر شماره و هر IP (assertSendAllowed) که مرجع نداشت — پس شمارش‌کردن شماره‌ها محدود است نه آزاد؛ و مرحله دوم (verifyOtp) که برای همه علت‌های شکست یک پیام واحد می‌دهد.

چرا D20 اینجا نقض نمی‌شود: D20 می‌گوید پیامک ناموفق نباید یک عملیات را خراب کند — سفارش باید ثبت شود حتی اگر پیامکش نرود. در ورود، پیامک خودِ عملیات است، پس سکوت از چیزی محافظت نمی‌کند.

ثبت شده در ISSUES.md به‌عنوان AUTH-01/02/03 و DOC-01.


D29 — «ورود به حساب مشتری» برای کارشناس فروش (۲۰۲۶/۱۰/۱۲)

خواسته مالک: «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید و این خیلی کاربردی بود ولی الان نیست» و سپس: «همان قابلیت هارو میخوام حالا هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره».

این D21 را اصلاح می‌کند: آنجا changeUser «عمداً پورت نشد» و به‌عنوان session-hijack رد شد. مالک قابلیت را خواست، پس بازگردانده شد — با همان دامنه کامل مرجع، ولی با سازوکار دیگر.

دامنه: کامل، مثل مرجع

مرجع توکن را در interceptor سراسری axios عوض می‌کرد (henza_pwa/plugins/axios.ts:18 ← getUserToken())، پس هر درخواست از طرف مشتری می‌رفت: دیدن، ثبت سفارش، علاقه‌مندی، و آپلود مدارک. همان دامنه حفظ شد — هیچ مسیری در حالت act-as بسته نشد.

سازوکار: هدر، نه توکن

مرجع اینجا
چطور توکن دائمی مشتری (api_token) به کارشناس داده می‌شد (ApiMobileController.php:197-201) کارشناس توکن خودش را می‌فرستد + هدر X-Act-As-Customer
کجا اعمال می‌شود interceptor کلاینت CustomerJwtStrategy.validateExpert — تنها نقطه
بررسی مالکیت یک‌بار، در لحظه گرفتن توکن هر درخواست، با referredByAgentId در همان کوئری
ماندگاری localStorage، بی‌انقضا sessionStorage؛ بستن تب = پایان
ردّ حسابرسی هیچ — درخواست عیناً مشتری بود adminUserId حفظ می‌شود؛ سفارش لاگ می‌شود
بعد از قطع همکاری کارشناس توکن مشتری همچنان کار می‌کرد ۴۰۱ فوری (نقش و isActive هر بار خوانده می‌شود)

چرا این نقطه: validate() همیشه یک principal می‌سازد که id آن یک Customer id است و هر ۱۷ مسیر مشتری از آن می‌خوانند. پس تغییر در یک نقطه، همه مسیرها را از دید مشتری می‌کند — بدون تغییر در هیچ route.

پرفورمنس: یک findFirst روی کلید اصلی، فقط وقتی هدر هست؛ و جایگزین کوئریِ tradingCustomer می‌شود نه اضافه بر آن. مشتری عادی و کارشناس در حالت معمولی صفر هزینه اضافه.

۴۰۳ و نه ۴۰۱ برای مشتریِ غیرمجاز: نشست خود کارشناس سالم است، پس اپ وضعیت act-as را پاک می‌کند و او را از سیستم بیرون نمی‌اندازد.

چیزهایی که مرجع نداشت و اضافه شد

۱. نوار همیشگی «در حال مشاهده به‌عنوان …» + دکمه خروج. در مرجع، جابجایی نامرئی بود: راه فهمیدن اینکه در حساب کیستی، فقط دیدن داده‌های عجیب بود، و ثبت سفارش روی حساب اشتباه یک کلیک فاصله داشت. تنها راه خروج هم logout بود که نشست خود کارشناس را هم می‌بست (Dashboard.vue:781-789). ۲. پاک‌سازی حالت در جابجایی (هر دو جهت): سبد، queryCache کلیدهای me:، علاقه‌مندی‌ها، و اجرت‌های کش‌شده. این آخری مالی است: اجرت هر مشتری متفاوت است، و بدون پاک‌سازی عددِ روی صفحه با عددِ ثبت سفارش یکی نمی‌بود. (در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض می‌شد.)

ثبت شده در ISSUES.md به‌عنوان EXP-01.

D30 — بخش‌های «فقط سوپر ادمین» در پنل مدیریت (۲۰۲۶/۱۰/۱۴)

خواسته مالک: این بخش‌ها فقط به سوپر ادمین (۰۹۱۲۸۰۸۱۳۹۲) نشان داده شوند و در سطح دسترسی‌ها هم نباشند: لیست مشتریان تک، لیست بنکداران، لیست مغازه‌داران حضوری، شعب و کارگاه، تب «مشتری تک» در افزودن شخص، فیلد «سینی ها» در فرم محصول، ثبت سفارش تک و لیست سفارش‌های تک، «افزودن تخفیف تک»، و «مدیریت کیف پول».

پاسخ‌های مالک:

  • سوپر ادمین = فقط حساب با موبایل ۰۹۱۲۸۰۸۱۳۹۲، نه هر ادمینی که نقش SUPER_ADMIN دارد.
  • اعمال کامل: مخفی در پنل + بستن آدرس مستقیم صفحه + ۴۰۳ از API.
  • «سینی ها» در هر دو جا: فیلد فرم محصول و تب «مدیریت ثابت ها».
  • تخفیف تک: افزودن + ویرایش + حذف، و هر سه از سطح دسترسی‌ها حذف.

سازوکار. @SuperAdminOnly() در apps/api/src/modules/auth/guards.ts، که PermissionsGuard سراسری آن را اعمال می‌کند؛ شماره و فهرست rid ها در auth/super-admin.ts. به سوپر ادمین rid های این بخش‌ها همیشه داده می‌شود (withSuperAdminRids) تا با ذخیره‌ی دوباره‌ی سطح دسترسی‌اش از دست نروند. در پنل: users.isSuperAdmin (از is_super_admin در login و users/show)، superAdminOnly روی منو و route، و حذف این rid ها از درخت صفحه دسترسی‌ها برای غیر سوپر ادمین.

تصمیم‌های جانبی (برای نشکستن چیزهای دیگر):

  • پنل شعبه همان endpoint های مشتری تک و سفارش تک را صدا می‌زند؛ این endpoint ها برای ادمینِ دارای branchId هم باز ماند (allowBranch). بدون این، کارکنان شعبه قفل می‌شدند. پیامد: ادمینی که شعبه دارد از API به این چهار endpoint دسترسی دارد.
  • سوپر ادمین در درخت دسترسی‌ها همه را می‌بیند تا بتواند این rid ها را به سطح دسترسی کارکنان شعبه بدهد. دیگران آن‌ها را نمی‌بینند، و rid هایی که سطح از قبل دارد هنگام ذخیره توسط آن‌ها حفظ می‌شود (حذف بی‌صدا نمی‌شود).
  • admin/orders/:id، status و status-options بین سفارش تک، عمده و تامین مشترک‌اند و order.list می‌خواستند؛ حالا هر کدام از order.list / order.omde / order.tamin کافی است (RequirePermission چند rid می‌پذیرد).
  • لیست کارگاه‌ها (admin/workshops/list) باز ماند: انتخاب کارگاه در «سفارش های ساختنی» از آن است. بقیه‌ی endpoint های کارگاه و همه‌ی شعب بسته شدند.
  • تخفیف تک فقط در پنل بسته شد، نه در API: API تک و عمده را از هم تشخیص نمی‌دهد (PROMO-TAK-01).
  • بنکداران/مغازه‌داران و سینی‌ها rid مشترک با بخش‌های دیگر دارند (persons.list.client.omde، collection.*)؛ پس از درخت حذف نشدند و با @SuperAdminOnly / isSuperAdmin بسته شدند.