Owner ruling: computing and displaying price / price_with_discount is retail storefront work, so it falls under D13 and is marked out of scope. The B2B app works in making charge only. variant-price.ts and its 11 hand-checked tests are kept and still run, just unused. Nothing needed reverting: computeVariantPrice was never called from outside its own file and spec, verified by grep over apps/api/src and apps/admin-panel/src, so no price had been wired into any screen. PRICE-01 is re-marked in ISSUES.md rather than deleted so the evidence survives if the storefront is ever taken up. Making-charge work stays fully in scope: percentage_pay, makingChargeOffsetPercent, and wage display on variants and order lines. New Phase 1 order: Persons -> products/categories/variants/order detail -> financial documents -> users and permissions -> admin SMS -> promotions, notifications, cities, branches, workshops -> reports -> gate. Per section: implement the gaps, then field-by-field test that section, and show a gap summary before fixing. SEC-02 re-verified live rather than quoted: 9/9 assertions, plus 32/32 in auth-smoke.sh. POST /orders is 401 without a token; a body customerId of "customer-2" sent with a customer-dev-test token still lands the order on customer-dev-test and is warn-logged; a foreign order id returns 404 rather than 403 so ids stay unenumerable; the admin read rejects a customer token. Client-supplied wagePercent/discountPercent/flatRialCharge remain as MON-08, already scheduled as Phase 2 item #1. Also records why the dev OTP log briefly looked broken: a stale API process still held port 3077, so the new instance died with EADDRINUSE and the tailed log belonged to the old process. Not a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48 KiB
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:
- Additive migration only — nothing dropped.
- Backfill existing rows: first token of
fullName→firstName, the rest →lastName. - Rows whose
fullNamehas more than two tokens must be written to a report for manual review (they cannot be split safely). fullNameis NOT removed. It stays and is kept in sync asfirstName + " " + lastNameso existing code keeps working.toRow()must return the realfname/lname, notfullName/''.- Fix every consumer:
singleCustomer.vue,bulkCustomer.vue,ReagentCustomer.vue, and anywhere a name is displayed, searched or sorted. Remove thesplit(' ')fallback insingleCustomer.vue. - Check the same defect on
AdminUser(sales expert) and on registration / profile inuser-appanduser-app-v2. - 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
isLoadedguard - the
sts_verify_step_twoguard oncompleteProfile - 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:
- Separate secrets — customer tokens are signed with
JWT_CUSTOMER_SECRET, admin tokens withJWT_SECRET. - A
typclaim is checked in both guards —typ: 'admin'vstyp: 'customer'. - Mandatory cross-token tests: a customer token on any admin route → 401; an admin token on a customer route → rejected, unless deliberately allowed.
- Token lifetime and logout must be compared against the reference and recorded here (see below).
- 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'] ?? 7app/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:
- Read is wrong — it shows the agent's commission, not the customer's offset.
- Write is missing —
update()never handlesdata.percent(admin-customers.service.ts:600-658), so editing the field in the admin form does nothing. - The create default is missing — the reference sets
percent = 3for app-registered customers and requires it for admin-created wholesale customers. - The app-side effect is missing — no
filterPercentPayoffset anywhere inuser-app/user-app-v2.
Proposed reconstruction (needs user approval — it is a schema change)
- Add
makingChargeOffsetPercent Decimal @default(0)toCustomer(additive migration). Name it clearly so it is never confused with the two otherpercents; map it to the legacy keypercentintoRow(). toRow()returns that column, notreferredByAgent.wagePercent. Remove the incorrect comment.create(): default 3 on the app-registration path; require it on the admin wholesale path (referenceAdminController.php:354).update(): handledata.percent.- Catalogue filter: subtract the customer's offset from the
percentage_paybounds before querying, and return shiftedfilterPercentPaybounds at login — matchingApiMobileController.php:391-396,76-77. - The referring agent's
wagePercentstays 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
- 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.
- No work is done on it. No parity, no fixes, no refactoring.
- 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.
- 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.
- 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 byLogger, 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.envonly; nothing is written to the repo, and.env.examplecarries 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 byPOST /me-dataand stored atmyDataStore.ts:29,59,122-127. This is the samemakingChargeOffsetPercentfield 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):
- The server derives, per line:
wagePercent = customer.makingChargeOffsetPercent + variant.percentagePay,discountPercentfrom the applicable promotion,flatRialCharge = 0. - Client-sent values are ignored, and a warning is logged when they differ —
the same pattern already used for
customerIdinorders.service.ts. - A comparison test proves that for an ordinary customer the server-derived values equal what the app sends today, so the total is unchanged.
- 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
.jsonwritten withutf8encoding. - 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, asscripts/test-name-trigger.cjsdoes.
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 (اشخاص)
- The 5 missing sales-expert fields (
ncode,phone,city_id,gram_level,reagent_id): report first — where the reference uses each one fortype=1— then add according to what that shows. Standing proposal: aSalesExpertProfilerelated toAdminUser(D10). Fields with no use are listed for the owner to decide, not added speculatively. singleCustomermigrates to diff-send (D11).- 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.