Files
tehran_taj/docs/parity/ISSUES.md
T
s.esmaeiliandClaude Opus 5 c518f9995b fix(auth): tell the user when the number is not registered (D28)
Reported by the owner: a number that is not in the system produced no
error at all. The app advanced to the code box and then nothing
happened, and there was no way to tell apart "the SMS is late", "the
gateway is down" and "this number was never registered" — the only one
of the three the person can actually do something about.

Step 1 now says which it is:
  - unregistered number          -> 404 «این شماره در سیستم ثبت نشده است…»
  - SMS send failed              -> 503 «ارسال پیام کوتاه … ممکن نیست…»
  - sales expert, account unusable -> 403, its own message

and both apps surface the server's Persian message for a 404 exactly as
they already did for a 429. Without that second half the person would
still have seen the generic «خطا در ارسال کد», which is the confusion
being removed.

This restores the reference's behaviour (firstOrFail() in
ApiMobileController::userAuth:43 returns 404); the opaque answer was our
deviation, not this. The anti-enumeration protection it was there for is
deliberately reduced at step 1 on the owner's instruction — the cost was
being paid by real users and by support. Still standing: the per-mobile
and per-IP send throttles, which the reference never had, so enumeration
is rate-bounded rather than free, and verifyOtp keeps one message for
every failure reason. ISSUES.md SEC-03's "no enumeration" claim is
corrected rather than left to go stale.

Two more instances of the same shape turned up while fixing it:

A failed SMS send also returned success, because SmsService never throws
(D20), so a dead gateway produced the identical dead end. D20 exists so
a failed SMS cannot break an operation — an order must still be placed —
but in a login the SMS IS the operation, so it now fails loudly. The
local-dev path keys off SmsService's own `sms-not-configured` result
rather than re-testing the config: with D26's default relay driver,
smsConfigured() is true on any machine with an external IPv4, so a
config test would have called a dev box configured and then failed its
login. The new test caught precisely that on its first run.

Three states return no trading account for a sales expert who does
exist. Under the new 404 each would have claimed the number was not
registered — false, and it would send support hunting a registration
problem that is not there.

Also corrects both apps' LoginView header comment, which still described
this flow as stubbed: no SMS provider, any 4-digit code accepted, step 1
matching the customer list in the client (that was the login leak, D8).
All three had been untrue for weeks and the stale text misled a reader
during this session.

requestOtp had NO test coverage: the 28-test customer-auth suite stayed
green through a change to its response for every unregistered number, so
its greenness was not treated as evidence. request-otp.spec.ts adds 9
cases, including that the dev fallback does not apply in production.

api: 295/295 tests, 27 suites, typecheck clean; both apps vue-tsc clean.

اپ: اگر شماره شما در سیستم ثبت نشده باشد، همان را می‌گوید و دیگر بی‌دلیل به صفحه کد نمی‌رود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 10:59:48 +03:30

62 KiB
Raw Blame History

ISSUES — logged, NOT fixed during parity

Anything found during the parity audit that is a bug, risk, smell or improvement opportunity goes here. Do not fix these while parity work is in progress (see CLAUDE.md §3). They are worked through in Phase 3, only when the user asks.

Severity: Critical (money loss / security / data loss) · High · Medium · Low

Paths are relative to the workspace root (gold bank/). "REF" = reference project (read-only). "TGT" = gold-banking-monorepo. Items marked [replicated] exist in the reference and were faithfully ported — fixing them is a product decision, not a parity fix.

Security & auth

ID Severity Where Description Evidence Suggested fix
SEC-01 Critical REF henza_server/routes/admin.php:12,272 The whole admin route file is unauthenticated — the Route::middleware('auth:api')->group(...) wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. wallet/charge, users, roles). Verified by reading both lines. n/a in reference (read-only). Drives TGT decision below.
SEC-02 Critical FIXED — hotfix stage 3 TGT apps/api/src/modules/accounting/accounting.controller.ts:7, orders/orders.controller.ts:7, customers/customers.controller.ts:5, catalog/catalog.controller.ts:5, pricing/pricing.controller.ts:5 15 routes carry @Public(), including ledger writes (POST accounting/repayments/*, entries/:id/confirm) and POST /api/orders + PATCH /api/orders/:id/status. The public status DTO accepts a client-supplied actorAdminUserId, so a caller can attribute an action to any admin. grep -rn "@Public()"; source comments say "pre-existing unauthenticated endpoint". ✅ All closed across stages 1-3. Ledger + order-status behind admin rids; customer routes behind the customer JWT; POST /orders takes the customer from the token and ignores the body. Only catalog/products, pricing/latest, home/slider and uploads/:filename remain public, all by design.
SEC-03 Critical FIXED — hotfix stage 2 TGT apps/user-app/src/views/user/LoginView.vue, apps/user-app-v2/ same OTP is not verified — any 4-digit code logs the user in. This is a parity gap: the reference does verify it (ApiMobileController.php:51-57, mismatch → HTTP 422). DECISIONS.md D6 ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle. Verified end-to-end by auth-smoke.sh. ⚠️ "no enumeration" no longer holds at step 1 — owner ruling 2026-10-12 (see AUTH-01 below) makes requestOtp answer an unregistered number with 404, because the opaque answer was unusable in support. Step 2 (verifyOtp) is still single-message for every failure reason, and the per-IP send throttle still bounds any enumeration attempt.
SEC-08 Critical FIXED — hotfix stage 2 TGT apps/api/src/modules/customers/customers.service.ts:6-23,44-50 + customers.controller.ts:10 (@Public()) Full customer book exposed to anonymous callers. Login downloads GET /customers and matches the phone client-side, so the endpoint returns every active customer with fullName, mobile, nationalCode, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, and the URLs of national-ID / business-licence / shop scans (and GET uploads/:filename is public too, so those URLs resolve). passwordHash is correctly excluded. Not possible in the reference, where userVerfiy returned one customer after the code matched. Read customers.service.ts:5-23; DECISIONS.md D8 ✅ Closed. The whole controller is deleted — no route accepts a customer id any more. Reads moved to token-derived /customer/me*. Stage 1 had only narrowed the fields, which left the ids and the :id routes exposed.
SEC-09 Critical FIXED — hotfix stage 1 TGT apps/api/src/modules/accounting/accounting.controller.ts:7 @Public() on the whole accounting controller exposes 5 routes, 4 of them writes: reconcile a balance, record a gold repayment, record a rial repayment, confirm a ledger entry. Faithful to the reference (whose admin routes were all unauthenticated) but contradicts the confirmed decision D1. DECISIONS.md §"@Public audit" group A ✅ Done: rids wallet.list, wallet.charge, document.create ×2, document.list.confirm. Verified 401/403/pass-through by apps/api/scripts/auth-smoke.sh.
SEC-10 High FIXED — hotfix stage 1 TGT apps/api/src/modules/orders/orders.controller.ts:22 PATCH orders/:id/status is public and its DTO accepts a client-supplied actorAdminUserId, so an anonymous caller can move an order's status and attribute it to any admin. In the reference this was an admin operation. DECISIONS.md §"@Public audit" B3 ✅ Done: order.list.sts.submit. Had no caller — the admin panel uses admin/orders/status.
SEC-04 High FIXED 2026-10-08 — production (NODE_ENV=production) refuses to boot without JWT_SECRET; CORS reads CORS_ORIGINS (exact origins, comma-separated), blank = allow-all with a boot warning in production. Verified: boot refused with a blank key in production only; a listed origin gets Access-Control-Allow-Origin, an unlisted one does not. TGT apps/api (two files) JWT_SECRET falls back to 'dev-only-insecure-secret' and is absent from .env.example. enableCors() is wide open. Reported by api inventory; see _inv-tgt-api.md §11. Require the env var at boot; restrict CORS origins.
SEC-05 High FIXED 2026-10-08 — worse than logged: the app runs the seed once on a fresh database at boot (bootstrap.ts), and the WHOLE seed was demo data (sample customers, products, orders, posted debt, and admin accounts with known passwords). Now only roles + permissions run in production; the rest needs SEED_DEMO_DATA=true. ⚠ Any deployment that already booted on an empty database has these demo rows and must be checked. TGT apps/api/prisma/seed.ts Seed creates a SUPER_ADMIN (09128081392) whose password equals the phone number, holding all 144 rids. Marked DEV-ONLY in comments but lives in the normal seed. docs/CHANGELOG-PORT-HISTORY.md §4 documents this as intentional for dev. Gate behind an explicit env flag so it can never run outside dev.
SEC-06 High REF henza_server Hardcoded secrets committed to the repo: SMS panel password, PayPing bearer token, Tahesab bearer + IP allowlist, wallet hash pepper ("yhoenszeaf"). See _inv-ref-server.md §11.4. Rotate all of them before any deployment that reuses these credentials.
SEC-07 Medium TGT apps/api/src/modules/admin-customers/admin-customers.service.ts setRoles stores arbitrary strings as rids with no validation against ALL_RIDS. api inventory §9. Validate against the rid catalogue. ✅ FIXED 2026-10-08: setRoles stores only rids in ALL_RIDS or their tree parents; anything else is dropped with a warning log, so a tick on a legacy-only tree leaf (e.g. notify.create.single.*, which no route checks) cannot fail the save. Test: admin-users.roles.spec.ts.

Customer / person module (found during the user-requested deep check)

These are parity defects being fixed, not deferred items — see DECISIONS.md D0, D9, D10, D11. Listed here so the trail is complete.

ID Severity Where Description Evidence Status
CUST-01 Critical TGT apps/api/src/modules/admin-customers/admin-customers.service.ts:231-233 First and last name collapse. The reference stores fname/lname as two columns; the target merged them into fullName and returns fname: c.fullName, lname: ''. Opening any edit form puts the whole name in the first-name box and blanks the surname; saving then persists that. singleCustomer.vue tries to compensate with split(' '), which mis-splits multi-part names. REF database/migrations/2020_10_01_120430_customers_table.php:18-19 (two columns) vs TGT :231-233. Reported by the user from live use. FIXING — D0
CUST-02 Critical TGT same file :245, :600-658 percent read from the wrong entity and never written. Target returns c.referredByAgent?.wagePercent; the reference reads the customer's own percent column. update() ignores data.percent entirely, so editing the field does nothing. The create-time default (3) and the admin-path required rule are both missing, as is the app-side filterPercentPay offset. REF database/migrations/2020_10_19_153228_customer_add.php:32; AdminController.php:338,354; ApiMobileController.php:391-396,76-77. Full trace in DECISIONS.md §percent. FIXING — D9
CUST-03 High TGT apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue:97-110 Sales-expert form is missing 5 reference fields: ncode, phone, city_id, gram_level, reagent_id. The reference form had 15; the target has 8. Likely because AdminUser lacks those columns. REF henza_admin/src/views/apps/customer/ReagentCustomer.vue (15 v-decorator fields) vs TGT form object. INVESTIGATING — D10
CUST-04 Low TGT apps/admin-panel/src/views/apps/customer/singleCustomer.vue:91 Still spreads the whole form on save while the other two customer forms send a diff. Harmless today (4 fields, all repopulated) but the same shape that caused the earlier data loss. read at :80-106 FIXING — D11
CUST-05 Low TGT apps/admin-panel/src/views/apps/customer/bulkCustomer.vue Diff-send on edit is a deliberate deviation from the reference, which posts all 15 fields on every save. Recorded so it is not "fixed" back. DECISIONS.md D11 accepted deviation

Money, stock & data integrity

ID Severity Where Description Evidence Suggested fix
MON-01 Critical FIXED (verified 2026-10-08) TGT apps/user-app-v2/src/stores/cartStore.ts v2 has no capQty / maxQty stock ceiling, which user-app does have (apps/user-app/src/stores/cartStore.ts:62,65; components/product/CartItem.vue:82-83). A v2 user can order more units than exist. Verified by grep across both apps — zero hits in v2. Port the ceiling to v2 (this is also a Phase 2 parity gap, row to be added to 02-APPS-MATRIX.md).
MON-02 High REF henza_server wallet package The wallet balance column mixes grams and toman in one field, and money operations are not wrapped in DB transactions. _inv-ref-server.md §7. Separate the units; wrap mutations in transactions.
MON-03 High NO EFFECT (verified 2026-10-08) — Prisma.Decimal(4.331802) converts through the number's shortest decimal string, i.e. exactly 4.331802; nothing is pre-rounded TGT apps/api/src/modules/accounting/ledger.service.ts:189 GRAMS_PER_MESGHAL = 4.331802 is a JS float used inside an otherwise-Prisma.Decimal CASH repayment formula, so the divisor is pre-rounded before the Decimal division. Read at ledger.service.ts:182-190; the source comment already flags the formula for stakeholder review. Make the constant a Decimal (or a string literal passed to Decimal).
MON-04 High FIXED 2026-10-08 — see ACC-03 TGT apps/api/src/modules/orders/orders.service.ts Debt is recognised at DELIVERED_TO_AGENT, but the idempotency guard checks deliveredAt, which is only set by DELIVERED_TO_CUSTOMER — so the debt trigger is not reliably idempotent. There is also no status-transition validation anywhere. api inventory §8. Guard on the same field the trigger fires on; add an explicit transition table.
MON-05 Medium FIXED 2026-10-08 (owner option 2) — canPlaceOrders now blocks orders placed from the app (403); an operator can still order for the customer. Legacy never read customers.sts (313 active customers carry «عدم سفارش», mostly the sign-up default), so the import sets everyone to allowed; only a deliberate suspension after go-live blocks. verificationStep is still not enforced, as in legacy. TGT apps/api canPlaceOrders and verificationStep are stored and toggled by the admin panel but never enforced on any order path. api inventory §8. Enforce at order creation, or remove the toggles.
MON-06 Medium TGT apps/api Setting rows are never seeded — wallet.config, wallet.birthday, promotion.auto, mobile_main_slider only materialise on first write, so a fresh install has undefined behaviour until an admin saves each one. api inventory §7. Seed the defaults.
MON-07 Medium REF henza_server/app/Models/ProductVariant.php:183-190 vs SingleCustomerOrders.php:67, Order.php:66 Two divergent order-status enums coexist (0–6 and 1–7) for what is conceptually one flow. Read both models. Unify, or document the mapping explicitly.

Validation & contracts

ID Severity Where Description Evidence Suggested fix
VAL-01 High REF henza_server Zero FormRequests; all validation is inline, and setOrder, wallet charge, setConst and file upload have no validation at all. The wallet package's own validator is dead code (it calls failed() instead of fails()). _inv-ref-server.md §12. n/a in reference; informs how much validation the target must add.
VAL-02 High TGT apps/api Only 8 DTO classes exist; most admin request bodies are untyped Record<string, unknown>, so class-validator never runs on them. api inventory §5. Add DTOs for the admin write paths.
VAL-03 Medium TGT both user apps HTTP client is a bare fetch wrapper: no interceptors, no Authorization header, no refresh, no 401 handling, no error toasts. Non-2xx throws a generic Error; call sites mostly console.log(e). Order-submit failure is silent to the user. apps/user-app/src/api/client.ts, apps/user-app-v2/src/api/client.ts:3. Add a shared client with error surfacing; compare against henza_pwa/src/service/* in Phase 2.

Parity-relevant divergence between user-app and user-app-v2

CLAUDE.md §2: the only allowed difference is visual design. These are logic differences, so they are Phase 2 parity gaps as well as issues.

ID Severity Where Description Evidence
V2-01 Critical FIXED (verified 2026-10-08) v2 missing stock ceiling see MON-01 grep
V2-02 Medium FIXED (verified 2026-10-08 — v2 calls getHomeSlider) v2 missing getHomeSlider() / GET /home/slider admin banner v1 api/client.ts:152-170 defines AdminSlide + getHomeSlider; v2 has neither diff apps/user-app/src/api/client.ts apps/user-app-v2/src/api/client.ts
V2-03 Medium v2 missing per-variant images v1 defines ProductImageSummary (id/url/sortOrder/role) and imageId/image on the variant; v2's images is just Array<{url:string}> same diff
V2-04 Medium v2 missing orderable-first variant defaulting v1 composables/useProduct.ts:26 picks the first orderable variant; v2 has no equivalent grep
V2-05 Low v2 missing variant-chip preview cap + per-chip stock badges v1 components/product/SingleProduct.vue:388-392 grep

Dead code & maintenance

ID Severity Where Description
DEAD-01 Low TGT apps/user-app 8 components with no importers, loadingStore, api.getBalance (zero call sites), unused gsap dependency. ✅ FIXED 2026-10-08 (owner approved): removed 8 unreachable components + loadingStore (found by walking imports from main.ts; sw.ts kept — vite-plugin-pwa entry), the unused validators/price helpers, and the gsap + @ant-design/icons-vue dependencies. getBalance was already gone.
DEAD-02 Low TGT apps/user-app-v2 11 dead components, loadingStore, getBalance, priceUnit/priceUnitRialLess, 12 of 15 validators, unused gsap + @ant-design/icons-vue deps. ✅ FIXED 2026-10-08 (owner approved): same for v2: 11 unreachable components + loadingStore, unused validators/price helpers, gsap + @ant-design/icons-vue.
DEAD-03 Low TGT gold-banking-monorepo/user-app-v2/ (repo root, outside apps/) Stray leftover folder containing only package.json and an older vite.config.ts (hardcoded port 5179, hardcoded proxy, imports a themeConfig that doesn't exist there). Not matched by pnpm-workspace.yaml, so pnpm ignores it. Left in place — deleting it needs user approval. ✅ FIXED 2026-10-08 (owner approved): stray root user-app-v2/ deleted; nothing referenced it.
DEAD-04 Low REF henza_server/app/Console/Kernel.php schedule() is empty — no cron. Price refresh / expiry / birthday logic must be driven by OS cron or the GET / web route.
DEAD-05 Low REF henza_server PayPing returnUrl points at site/transaction/verify, which is not registered in any route file — the payment callback is unreachable.

Known stubs in the target (not bugs, but not parity either)

ID Where Description
STUB-01 TGT both user apps Registration persists nothing; favourites are in-memory only (lost on reload); notifications is a static empty state; home slides are 3 hardcoded placeholders in v2.
STUB-02 TGT apps/api No OTP, no SMS provider, no payment gateway/callbacks, no cron, no websockets. SMS and notifications are DB-persistence only.
STUB-03 TGT both user apps All list filtering and "pagination" is client-side over one flat GET /catalog/products; no server page/limit params. "Pagination" is a 20-item IntersectionObserver window.
STUB-04 TGT both user apps Live price is pull-on-mount only (GET /pricing/latest?karatPerMille=750) — no socket, no SSE, no polling interval. quotedAt renders as a Jalali date only, so a stale rate looks current.

Found during the stage-3 OTP audit

ID Severity Where Description Evidence Status
SEC-11 High TGT apps/api/src/modules/customer-auth/customer-auth.service.ts (deliver) The OTP message text, code included, was persisted to SmsMessage.body, which the admin SMS grid lists and full-text searches (admin-misc.service.ts:135-136). Any operator holding the sms rid could read live codes — defeating the bcrypt hashing of codeHash. Read both files; grid allowed includes body. ✅ Fixed in stage 3: the persisted row is a redacted audit line.
MON-08 Medium FIXED 2026-10-08 (owner-approved) — app orders are priced by the server (pricing/wage-formula.ts, legacy by default: variant % + customer % − largest active product/category/brand promotion; rial charge 0) and the app's wage/discount/rial are ignored; the apps show the same figure from POST customer/me/wage-quote. Operators keep their manual numbers. Found on the way: the apps never added the customer's own percent (usually 3) nor any promotion, so the new app undercharged vs legacy. The formula is editable on the new «اجرت» admin screen (config.wage) with a live server-computed sample and approve-then-save. Verified end to end: 9+3=12; promotions 2,1 + expired 5 → 10; a tampered order (wage 0, discount 50, rial 999) was charged 10.5% and 0 rial under an approved +0.5 formula. TGT apps/api/src/modules/orders/orders.service.ts:40-52 wagePercent, discountPercent and flatRialCharge are taken from the client on order creation, so a caller can set their own making charge. rashWeightGrams is correctly read from the variant. The reference is worse — ApiMobileController.php:538-539,619 takes rash_weight from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. REF :536-545,615-632 vs TGT :40-52 Log only (D4). Fix in Phase 2 alongside the price-formula work.

Found while classifying scope for D13

ID Severity Where Description Evidence Status
DOC-01 Medium DOC docs/parity/_inv-ref-server.md:588 (§3.6) The Phase 0 inventory undercounted routes/admin.php: it recorded 117 explicit routes where the file has 151 live ones (6 more are commented out). Every downstream total inherited the error, so the reference endpoint count was reported as 369 when it is 412 (admin 249, not 215). No code is affected — only the planning figures. grep -E "^\s*Route::(get|post|put|patch|delete|any)" routes/admin.php | grep -v "^\s*//" | wc -l → 151; resources 14×7=98; 151+98=249 ✅ Corrected in 00-INVENTORY.md §2.1 and DECISIONS.md §D13. _inv-ref-server.md left as the raw subagent record.
DOC-02 Low REF routes/admin.php:38 GET admin/get-analytics-tak reads SingleCustomerOrders (Adm/AdminController.php:557-579), so it is a retail-storefront endpoint despite its neutral name. A keyword scan for wallet|single|comment misses it. Noted so the D13 scope split stays correct — anything else named neutrally but querying SingleCustomerOrders, Fav, Address, Comment or Transaction is storefront too. AdminController.php:563 — SingleCustomerOrders::select(...) ✅ Classified as ⏸️ storefront in D13 (the 31st admin storefront route).

Found during the SMS / MON-08 investigation (2026-09-22)

ID Severity Where Description Evidence Status
SEC-12 Critical REF henza_server/app/Models/Sms.php:73-77,101-105 Live SMS-panel production credentials (username, plaintext password, sender line) are committed to git in the reference, duplicated across two methods. An older provider's credentials sit commented out beside them. Anyone with repo access can send SMS on the owner's account at their cost. Read the file. Not ported — the target will read them from env only. The owner should rotate these credentials; they must be assumed compromised.
STUB-05 Medium REF ApiMobileController.php:154-158,601-605; Adm/OrderController.php:54-55; Customer/OrderController.php:141-145 Admin notification recipients are hardcoded mobile numbers scattered across controllers (six distinct numbers). Staff changes require a code deploy, and an ex-employee keeps receiving order data. Read the files. ✅ FIXED (2026-10-12, commit 83e5d28) — ported as two comma-separated env vars, PROFILE_ADMIN_MOBILES and ORDER_ADMIN_MOBILES, both defaulting to empty. No literal number is in the target's git history.
DEAD-06 Low REF henza_pwa/src/views/cart.vue:181-182 The cart sends wage_weight: 5.7856000000000005 and weight: 6.303744000000001 as literal constants, each with a // how to get?! comment. The server ignores both: it recomputes weight from rash_weight, and the wage_weight use is commented out (ApiMobileController.php:549-551). Read both files. Dead payload fields — do not port.

Found while applying the D0 / percent migrations (2026-09-22)

ID Severity Where Description Evidence Status
DEAD-07 Low TGT DB table _goldbank_migrations + apps/api/src/prisma/migrate.ts Re-assessed 2026-09-22 — not dead code. It is the tracking table of the app's OWN migration runner (src/prisma/migrate.ts:5), which exists because the Docker deploy image is copy-only and ships no Prisma CLI, so migrate deploy cannot run there (migrate.ts:7-13). So the project has TWO ledgers on purpose: _prisma_migrations (CLI, used in development) and _goldbank_migrations (runtime runner, used in the container). migrate diff proposed DROP TABLE only because the table was not declared in the schema; the runner already refused to execute such a statement (migrate.ts:190-196). Read src/prisma/migrate.ts. ✅ Resolved: declared as an @@ignored model, so drift is now zero. Table and all rows kept.

Found in the Phase 1 admin matrix (2026-09-22)

ID Severity Where Description Evidence Status
PRICE-01 ⏸️ out of scope (was Critical) TGT apps/api/src/modules/admin-products/admin-products.service.ts:188-208; apps/admin-panel/src/views/apps/product/ProductList.vue No product price is computed or shown anywhere in the admin panel. The reference appends price and price_with_discount to every variant (ProductVariant.php:115-119), sets the live gold rate before listing (AdminController.php:97-98), and renders {{ item.price }} in three places in its product list (ProductList.vue:283,287,301). The target's variantRow() returns neither field, nothing fetches a gold price for the admin list, and computeVariantPrice() — ported and unit-tested today — is called from nowhere. grep computeVariantPrice src/ --include=*.ts matches only its own file and spec. ⏸️ Stopped by D16 — price display is retail-storefront, out of scope. variant-price.ts and its 11 tests are KEPT but unused. Nothing needed reverting: the function was never called. Making-charge fields stay in scope.
PRICE-02 Info REF app/Models/ProductVariant.php:163 vs TGT admin-products.service.ts:206-208 rial_pay (a flat rial charge per variant) appears only in the superseded, commented-out formula, not the live one at :183-190. The target returns null rather than faking it, which matches the live reference. Recorded so nobody "restores" it from the dead code. Read both. ✅ No action — matches.
PERM-01 Medium TGT prisma/seed.ts; apps/admin-panel/src/views/user/permissionConst.ts dashboard.omde and dashboard.tak are seeded into the permission table but are absent from the permission UI tree, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses dashboard.view). The DB holds 178 distinct rids against the UI's 174. SELECT rid FROM permission GROUP BY rid → 178, including all three dashboard rids. ✅ FIXED 2026-10-08 (owner approved the deletion). Migration 20261008160000_dashboard_permission_merge gives dashboard.view to every type holding dashboard / dashboard.omde / dashboard.tak, then deletes those rows. Also found: the import dropped these three as unknown rids, so legacy types 102, 104, 105, 108 and 135 lost the dashboard — people.ts RENAMED_RIDS now maps them to dashboard.view. The other 45 legacy rids the target lacks are tree parents or rids no legacy code reads.

Found while closing Phase 1 (2026-10-12)

ID Severity Where Description Evidence Status
SMS-03 Medium TGT apps/api/src/modules/orders/orders.service.ts No order-placement SMS at all. The reference texts four audiences when an order is placed (ApiMobileController.php:733-766): the admins, the customer — with a distinct text when the order was suspended over the gram ceiling — and the customer's referring sales expert. OrdersService did not inject SmsService, so a customer placing an order was told nothing. The matrix had logged only the suspended-order message, understating the gap. Read both sides; grep SmsService showed six modules wired and orders absent. ✅ FIXED 2026-10-12 — notifyOrderPlaced + notifySalesAgent, sent after the transaction commits, fire-and-forget per D20, admin numbers from ORDER_ADMIN_MOBILES. 6 tests. The «ویرایش شد» variant is «عمداً تکرار نشد» — no target path edits a placed order.
PERM-02 Medium TGT apps/admin-panel/src/views/user/permissionConst.ts Two rids are enforced by routes but absent from the permission tree, so an operator cannot grant them: newsletters (newsletter subscriber list) and persons.list.sellers.view (the ➕ target-only agent-detail screen). Only a role that already holds them from the seed can use those routes. Inherited from the reference — that file's own header recorded four such rids; the other two (buy.order.list, persons.create.customer.tak) now have no route on either side and are dead. Mechanical comparison of 68 enforced rids against 176 tree keys, 01-ADMIN-MATRIX.md §2. awaiting owner — the fix is additive (add the two keys to the tree) but it changes the permission vocabulary, so it waits like PERM-01 did.

Delivery-mode items (D19/D20, 2026-09-22)

ID Severity Where Description Evidence Suggested fix
REL-01 Critical — before the real release TGT deployment env OTP_MASTER_CODE must be removed from the environment, and the SMS credentials configured. While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. customer-auth.service.ts masterOtpCode(); main.ts boot banner; D20 Unset OTP_MASTER_CODE; set SMS_TREZ_USERNAME / SMS_TREZ_PASSWORD / SMS_TREZ_SENDER_LINE; restart and confirm the banner is gone. 🔒 ENFORCED 2026-10-08: a production API refuses to start while OTP_MASTER_CODE is set, unless TEST_SERVER_ALLOW_MASTER_CODE=yes marks a test server (common/production-checks.ts). Removing it from the live environment is still the operator's step — docs/SERVER-SETUP-CHECKLIST.md.
REL-02 High TGT apps/api/src/modules/sms/sms.service.ts D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so customers cannot receive a login code and the failure is only a log line. Deliberate, to keep the delivery unblocked. D14 (superseded) vs D20 After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. Eased by D26 (2026-10-05): the default relay driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line.
PERS-01 Medium REF AdminController.php:348 vs TGT admin-customers.service.ts update() The reference discards the submitted mobile on edit ($input['mobile'] = $customer->mobile;), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. AdminController.php:348; D19 answer B If the lock is wanted later, apply it in update() and hide/disable the field on the edit form.
PERS-02 Medium REF cities table vs TGT City model The reference's cities are a two-level hierarchy (parent + level, provinces at level 0) rendered as an a-tree-select with provinces not selectable (citiesModel.js:38). The target flattened this to a nullable province string and a flat a-select on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. 2021_09_18_171700_city_table.php:18-19; henza_admin/src/mixins/citiesModel.js:20-48; schema.prisma model City ✅ FIXED 2026-10-08 (owner: as legacy). City.parentId (self relation, Restrict) replaces province; migration 20261008170000_city_tree turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (CityTreeSelect.vue: top level with children not selectable); city form/grid have «والد». Also restored: legacy _getP — a city without its own percent takes its ancestor's (getCityPercent); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused.
PERS-03 Low — column added 2026-10-07 (Customer.birthDate, 541 legacy dates imported); form field still missing REF bulkCustomer.vue:111, singleCustomer.vue:29 vs TGT The reference collects a Jalali birth date on both the wholesale (birth_date) and retail (birth_day) customer forms via DateInput. The target's Customer model has no column for it and both forms omit the field. henza_admin/.../bulkCustomer.vue:111; schema.prisma model Customer (no birth field) Additive: add a nullable birthDate column and the picker, if the owner wants it. ✅ FIXED 2026-10-08: Jalali «تاریخ تولد» (DateInput) on the wholesale (birth_date) and retail (birth_day) forms; create/update accept either name, refuse impossible/future dates, '' clears; customer rows return both names.
PERF-01 Low TGT admin-customers.service.ts listReagents() The per-agent gold-sold total runs one order.aggregate per row inside Promise.all — an N+1 on every page of the sales-expert grid. Harmless at the current agent count. listReagents() rows.map(async ...) Replace with a single groupBy over the referring agent.

Apps — found during the delivery smoke test (2026-09-23)

ID Severity Where Description Evidence Suggested fix
APP-01 Medium (constraint, handled) TGT apps/user-app/src/views/user/LoginView.vue:43, user-app-v2 same, components/input/OtpInput.vue:43 Both apps render the login OTP as four single-character inputs and submit only on @on-complete, i.e. at exactly 4 digits. The API accepts any length, so an OTP_MASTER_CODE longer than 4 works over HTTP but cannot be typed into either app. The real OTP is 4 digits, so the box itself is correct. OtpInput.vue:43 otp.join('').length === numInputs; Math.floor(1000 + random*9000) in customer-auth.service.ts Handled without touching the shipped login UI: main.ts warns at boot when the code is not 4 digits, and .env.example states the rule.
APP-02 Medium FIXED (verified 2026-10-08) TGT apps/user-app-v2/src/api/client.ts (no getHomeSlider) vs apps/user-app/src/api/client.ts:228 user-app calls GET /home/slider and prefers admin-configured slides, falling back to category-derived ones (HomeView.vue:258). user-app-v2 has no such call at all and always derives its hero from categories, so slides configured in the admin panel never appear in v2. grep getHomeSlider finds it only in user-app Add getHomeSlider to v2's client and let HeroCarousel prefer admin slides. Deliberately deferred: v2's home is its own design (PROTECTED) and rewiring its hero during a delivery risks the page being demonstrated.
DEPLOY-01 High FIXED TGT docker-compose.yml The api service passed only DATABASE_URL and PORT, so every auth and SMS variable was silently ignored in Docker — JWT_SECRET would fall back to the development default, and OTP_MASTER_CODE would never reach the container, so the delivery login would not have worked in a deployed environment. docker compose config before/after ✅ All auth, SMS and master-OTP variables are now passed through, each defaulting to empty (which the code treats as "unset").
TEST-01 Low (fixed) TGT apps/api/scripts/test-sales-expert-roundtrip.cjs:10 The script had localhost:3077 hardcoded — a port from an earlier dev session — so it could only ever connect by accident. It failed with ECONNREFUSED on this branch. Run against the standard port ✅ Now defaults to localhost:3000 (API_PORT) and accepts an override argument. Re-ran: 33/33.

complete_profile — found while porting the page (2026-09-23)

ID Severity Where Description Evidence Suggested fix
REL-03 Critical — before the real release TGT deployment env; customer-auth.service.ts customerJwtSecret() JWT_CUSTOMER_SECRET must be set to its own value. Unset, it falls back to JWT_SECRET, so customer/sales-expert app tokens are signed with the same key as admin tokens and the two realms stop being cryptographically separate. The only remaining defence is the typ check in jwt.strategy.ts — and an expert's app token carries their AdminUser id in sub, which is exactly what that check exists to stop. Two independent defences collapse to one. customerJwtSecret() ?? process.env.JWT_SECRET; jwt.strategy.ts APP_TOKEN_TYPES Set JWT_CUSTOMER_SECRET to a distinct random value. A loud boot banner now fires while it is missing (main.ts), and DELIVERY.md lists it first. 🔒 ENFORCED 2026-10-08: production refuses to start without JWT_CUSTOMER_SECRET, or with it equal to JWT_SECRET or under 32 characters. Setting it is the operator's step — docs/SERVER-SETUP-CHECKLIST.md.
SEC-14 High (reference bug, NOT ported) REF Major/CustomerController.php:87 complete() did $customer->update($request->all()) — it mass-assigned the entire request body onto the customer row. The app could therefore set its own gram_level (credit limit), percent (making charge), remain (gold debt), active or even mobile by adding fields to the profile-completion POST. A privilege-escalation and money hole reachable from any signed-in customer. $input = $request->all(); ... $customer->update($input); Not ported. The target reads a three-field whitelist (CompleteProfileDto) and writes only the three document URLs plus the verification step. Asserted by test-complete-profile.cjs §7, which posts credit-limit/debt/mobile overrides and checks the row is unchanged.
SEC-15 Low (reference bug, NOT ported) REF Major/CustomerController.php:110-112 sendSmsToAdmin() hardcoded four real personal mobile numbers in the source. $admin_mobile = ['0990…','0913…','0912…','0919…']; Not ported. The target reads PROFILE_ADMIN_MOBILES (comma-separated, blank = notify nobody), so no personal number is committed to this repository.
APP-03 Low (reference bug, NOT ported) REF completeProfile.vue:175-181 refresh() called window.setInterval(() => window.location.reload(), 1000) — an unterminated timer that reloaded the whole page every second forever, not a one-off refresh. completeProfile.vue:178-180 Not ported. The target navigates home once after the success dialog.
UPL-01 Medium (gap, fixed) TGT admin-uploads.controller.ts The reference let the app upload documents through the same file/create the admin panel used, inside its auth:api-customer group (routes/api.php:95). The target's copy of that route is admin-guarded, which no app token can pass — so without a customer-facing upload route, complete_profile could not have worked at all. AdminUploadsController has no @CustomerAuth() route ✅ Added POST customer/me/documents, reusing AdminUploadsService unchanged so the size limit, MIME whitelist and stored-name scheme are identical on both paths. The uploader is recorded as null rather than as an admin, since that column is an AdminUser FK.

report — found while porting the page (2026-09-23)

ID Severity Where Description Evidence Suggested fix
SEC-16 High (reference bug, NOT ported) REF Customer/TahesabController.php::detail() The document-detail endpoint took the factor code straight from the URL and performed no ownership check at all — the method never consults auth(). Any signed-in customer could read any other customer's accounting document by guessing or enumerating a code. public function detail(Request $request, $id) → proxies GetHesabDetailByFactorCode?Factor_Code=$id with no customer scope Not ported. In the target the id is only a filter on top of a customerId scope taken from the token, so another customer's id returns nothing. Asserted in both directions by test-report.cjs §6.
REP-01 Medium (documented gap) REF external Tahesab system vs TGT LedgerEntry The reference's statement carried columns the target has no source for: foreign currency (rial_V2/rial_Kh2/tahesabP2), vaznPlastik, nO_kar (نام کارساخته), jens_felez (جنس فلز) and the external system's internal ids (gid/rec/reg). henza_pwa/src/type/accounting.ts; AccountingDetail.vue:34-46 The API returns null/zero for these and the UI omits the row rather than showing a fabricated value. If the business needs them, they need columns on LedgerEntry first. Update 2026-10-05 (D27): with the accounting integration on, «گزارش» reads the accounting statement and nO_kar / jens_felez are filled from it.
REP-02 Low (performance) TGT customer-report.service.ts summary() The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. summary() findMany without take If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time.
SEC-17 Medium (reference, not applicable to target) REF Customer/TahesabController.php The reference calls the external accounting system with Http::withoutVerifying() (TLS certificate verification disabled) against a bare IP, with a bearer token and an sfx secret hardcoded in the source and embedded in the URL. ->withToken("S3F1P8…"), sfx=wcG97tkF… in four URLs Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. Update 2026-10-05 (D27): a Tahesab integration now exists; it keeps TLS verification on (a custom CA can be supplied), stores the token encrypted and write-only, and never logs it.

Found by the pre-delivery Docker Compose run (2026-09-23)

ID Severity Where Description Evidence Suggested fix
DEPLOY-02 Critical FIXED TGT auth.module.ts, customer-auth.service.ts, jwt.strategy.ts, main.ts, upload-path.ts, sms.service.ts 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 "expiresIn" should be a number of seconds or string representing a timespan on every sign. Invisible to every suite that ran against a local dev process, where the variable is genuinely absent. The same pattern also meant a blank UPLOAD_DIR would have served static files from the filesystem root, and a blank PORT would have bound a random ephemeral port (Number('') === 0), making the container unreachable. Container log: ERROR [ExceptionsHandler] "expiresIn" should be a number of seconds or string representing a timespan ✅ Added src/common/env.ts (envOr / envOrNull) which treats blank as unset, and applied it to every auth, port, upload and SMS read. Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw against the real jsonwebtoken and asserts the reader can no longer produce it. Re-verified end to end in compose: 32/32.
DEPLOY-03 Medium (process) TGT apps/api/Dockerfile The image is COPY release/ ./ — it ships a pre-built bundle produced on the developer machine by pnpm build:release, not a build performed inside the image. So docker compose build happily produces an image from a stale release/app.js and reports success. At the start of this run that bundle was four days old, and the rebuilt container was missing every route added since. apps/api/release/app.js dated Sep 19 while the source was Sep 23; the container mapped none of the new routes pnpm build:release MUST be run before docker compose build. Noted in DELIVERY.md's deploy section. A longer-term fix is to build inside the Dockerfile, or to have the build fail when release/ is older than src/.

Accounting integration — found while building D27 (2026-10-05)

ID Severity Where Description Evidence Suggested fix
ACC-01 Medium (unverified) TGT accounting-sync/providers/zarrin.provider.ts The Zarrin driver follows the published docs only — there is no Zarrin installation to test on. Open points: which sign of listMandeHesabha means "owes" (a setting, default negative), whether stones belong in mablagh or mablaghSang on foroushSakhte (sent in mablagh), and the exact response envelopes. Owner: «نه، فعلاً نداریم» (no test install). Unit tests use the docs' sample outputs. Before real use: a trial on temporary documents on a real install (owner's decision 11), check one balance and one document against the Zarrin UI.
ACC-02 Low (by design) TGT D27 Order documents are sent temporary (owner ruling), and a temporary Tahesab document does not move the balance until the accountant finalises it. Until then the balance the customer sees from the accounting software does not include those orders. Measured on the Tahesab test server: balance delta 0 for a temporary VKHKar, −Vazn×Ayar/750 once final. Accountant finalises documents promptly; the report marks temporary rows «موقت — هنوز در مانده اثر ندارد».
ACC-03 High FIXED 2026-10-08 (owner-approved) — LedgerService.syncOrderDebt: on every status change the order's ORDER_STOCK entries are brought to what legacy's recomputed sts > 4 gives (full weight + rial while DELIVERED_TO_AGENT/CUSTOMER, else 0), posting only the difference as a confirmed DEBIT/CREDIT. Also fixes: straight-to-DELIVERED_TO_CUSTOMER owed nothing; rollback/cancel left the debt. Removing the «تحویل به نماینده» status is allowed again, as in legacy. Existing data: 0 orders out of line in dev and the imported copy. TGT orders/orders.service.ts updateStatus() The debt guard is DELIVERED_TO_AGENT && !order.deliveredAt, but deliveredAt is only set at DELIVERED_TO_CUSTOMER. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt twice. Read at orders.service.ts:135-160. The new accounting hook is not affected (it dedupes on the existing document row). Guard on an existing ORDER_STOCK ledger entry for the order instead of deliveredAt; needs owner approval (PROTECTED area).
ACC-04 Low (pre-existing) TGT src/prisma/migrate.ts On a completely empty database runMigrations fails: 0002_admin_schema.sql sorts before 20260910113720_first and alters tables that do not exist yet. Existing databases are unaffected. Reproduced while preparing the D27 throwaway test database (relation "City" does not exist). Rename to sort after first, with a LEGACY_NAME_ALIASES entry so applied databases do not re-run it.
ACC-05 Low (behaviour change) TGT catalog.service.ts D27 rule: a product with no image is no longer listed in the app nor orderable — for every product, not only those from the accounting software. Dev DB on 2026-10-05: 0 of 5 active products affected. Before release run select count(*) from "Product" p where p."isActive" and not exists (select 1 from "ProductImage" i where i."productId" = p.id); on production and photograph those first.
ACC-06 Medium (fixed 2026-10-05) TGT accounting-sync/accounting-http.ts A real Tahesab install reached by IP could not connect. Tahesab serves its own self-signed certificate issued for localhost / 127.0.0.1, so verification failed (DEPTH_ZERO_SELF_SIGNED_CERT), and pasting it as a CA still failed the name check (ERR_TLS_CERT_ALTNAME_INVALID). «تست اتصال» also tested only saved settings. Owner's server (https://<ip>:4040, DBname tahesabDB5): token and DB were fine (raw CheckHealth = OK). The same server also answers to the default TahesabDB — a different database (40 customer groups instead of 19). Fixed: a pasted certificate is now also a pin — exactly that certificate is accepted under any name; the chain check stays on, any other certificate is still name-checked, and verification is never switched off. New «دریافت گواهی از سرور» button shows the SHA-256 fingerprint to compare before pinning; «تست اتصال» uses the form as typed. Verified against the owner's server; accounting-http.spec.ts. Admins must check the DB name in the test result.
ACC-07 Medium (fixed 2026-10-05) TGT accounting-sync (config, worker, admin) "Connected but nothing syncs": the switch was saved on «زرین» while only Tahesab had an address/token. The worker found no usable provider and did nothing, with no message; «سینک الان» answered "started". Separately, an empty «گروه‌های مشتری برای ورود» meant all groups — on the owner's server that includes صندوق, کارمندان, سرمایه‌گذار, آبشده, کلاهبرداران… which would have become app customers. Owner's Tahesab: 228 accounts in 15 groups, 23 کارساخته; read-only provider calls (groups, customers, balances, items, stock) all fine. Fixed: saving a provider without address/token is refused; the screen shows why nothing syncs (inactiveReason); «سینک الان» refuses with the reason (or "feature off"). Customer pull now refuses to run with no groups selected; creating a customer with no «گروه مشتری جدید» goes to NEEDS_REVIEW instead of an ungrouped account.
ACC-08 Medium (done 2026-10-05) TGT accounting-sync (worker, providers, sync, catalog) Owner: sync must be real-time, not delayed or manual. It was: outbox tick 30 s, balances every 10 min, products every 30 min, stock read from the stored copy at order time. Neither Tahesab nor Zarrin has webhooks. Tahesab DoListUpdatedAsnad lists new/edited documents (≈400 ms); proven on the public test server (document posted → account+item seen in the next poll, 0.7 s). Zarrin parLastTenRadif is unverified (ACC-01). Done: fast loop (2 s) for outbox + change poll every 5 s (fixed; the admin setting was removed at the owner's request); kickSoon() right after a hook enqueues; targeted balance/stock refresh; order-time live stock with 2.5 s timeout, falling back to the last known stock (owner's ruling); balance freshness 5 s. Interval jobs stay as the safety net (deletions, edits, new customers). getChanges tests added (55 accounting tests). See plan §11.
ACC-09 Medium (done 2026-10-05) TGT accounting-sync, orders, admin-orders, admin lists, both apps' profile Owner: sync also on demand — a button on the wholesale-customer list and the product list; the app profile reads its own balance live on every visit; an optional rule for the order status that posts the temporary document (پیش‌فاکتور). Owner: buttons/profile work whenever connected, regardless of the feature switches; the rule lives on «اتصال حسابداری». Admin order delete and status rollback could leave a temporary document behind once it can be posted before the debt. Done: AccountingQuickController (admin/accounting-status, admin/customers-omde/accounting-sync, admin/products/accounting-sync, customer/me/accounting-balance), documents.triggerStatus (default DELIVERED_TO_AGENT), orderStatusReached hook from createOrder/updateStatus/rollback, withdrawal on delete/rollback, delete job independent of the order row. 8 hook tests. Plan §12.

Found during the legacy data migration (2026-10-08)

ID Severity Where Description Evidence Status
FAV-01 High (data invisible) TGT both apps stores/favoritesStore.ts; no API route Favourites were memory-only in both apps (lost on reload), and the API had no favourites route — so the 874 favourites imported from legacy major_favorites into CustomerFavorite were never shown. Legacy kept them on the server (POST add-to-fav, POST get-favorites, routes/api.php:84-85). Store header said "local-only … until that backend support exists". ✅ Fixed 2026-10-08: GET customer/me/favorites, POST customer/me/favorites/toggle (customer from the token); both apps load on sign-in, clear on sign-out, toggle optimistically and roll back on failure. Verified on the imported data: a customer with 116 legacy favourites sees all 116.

Phase 2 — item #1, MON-08 close-out (2026-10-12)

ID Severity Where Description Evidence Status
MON-08 High (money) REF ApiMobileController.php:536-541, henza_pwa/src/views/cart.vue:170-179 vs TGT apps/api/src/modules/orders/orders.service.ts The reference computed a customer's gold debt straight from percent_pay, discount and rash_weight as sent in the request, with nothing re-read from the database — so a crafted order could set its own making charge, its own discount and its own weight, i.e. decide how much gold the customer owed. DECISIONS §"Report — MON-08"; the PHP block is quoted there in full. ✅ FIXED. All four steps of the owner's ruling are in place. (1) An app order is priced by WageService.quote — variantPercent + customerPercent − promotions through the approved formula (pricing/wage-formula.ts, legacy's by default), read from the DB. (2) The client's figures are ignored and a mismatch is now logged (warnOnPriceMismatch, added 2026-10-12) — the same pattern as the customerId warning. (3) The comparison test "gives an honest app exactly the total it sent" proves an ordinary customer's total is unchanged. (4) The formula, its ordering and its rounding are untouched (D4). An operator ordering from the panel still keeps the numbers they typed, as legacy's order builder did. 16 tests on this service.

Accepted deviation, re-confirmed: the reference also trusted the client's rash_weight; the target reads the weight from the variant row. Per the owner's ruling the target's behaviour stays — identical whenever the client sends the true weight (which the reference app always does, cart.vue:178), different only for a forged request, which is the point.

Phase 2 — item #2, D3 close-out (2026-10-12)

Seven of D3's ten items were already built — all five screens exist in both apps, as do the sts_verify_step_two guard and v2's cart stock ceiling (verified in the code, not taken from a checkmark). Three were outstanding:

ID Severity Where Description Evidence Status
APP-03 Medium (UX) REF henza_pwa/src/router/index.ts:253-261 vs TGT apps/user-app/src/router/index.ts, user-app-v2 same A guest tapping a personal page (orders, transactions, profile, favourites, complete-profile) was redirected in silence, so they landed on the login box with nothing saying why. Legacy showed «ابتدا وارد شوید». Read both guards; legacy calls showNotify('ابتدا وارد شوید') then next({name:'home'}). ✅ FIXED 2026-10-12 — toast.info('ابتدا وارد شوید') in both apps' beforeEach. «عمداً تکرار نشد — D3/الف» for the destination: legacy sent the guest to home and lost where they were going; the owner chose to keep the target's query.redirect, so signing in returns them to the page they asked for. Also applied via meta.requiresAuth rather than legacy's hand-kept list of five names, which additionally covers the two sales-expert pages. Only reached when the IAM is off (the default) or unreachable — silent SSO leaves the page before this runs, and a toast on a document being replaced could not be read.
APP-04 Low REF henza_pwa/src/router/index.ts:263-266 vs TGT both apps stores/configStore.ts Legacy's isLoaded router guard sent a signed-in user to home from any route without meta.skipLoadedCheck until its one big bootstrap request had landed. Read both; the target's store header already said isLoaded was not ported. ⏹️ «عمداً تکرار نشد — D3/ب» (owner, 2026-10-12). The target has no bootstrap request — every page fetches its own data — so the flag would guard nothing and only add a bounce: opening a shared product link would land on home instead of the product. Porting it would port the breakage, not the behaviour (D4). Recorded in both apps' configStore.ts so a later reader finds the reasoning where they would look for the flag.
ANLY-01 Low REF henza_server/app/Http/Controllers/Api/LogController.php, henza_pwa/src/router/customAnalytics.ts; routes api.php:57-58 Per-page dwell-time analytics: POST /log/create/{title} opened a row (start_at, ip, title, customer_id, optional product_id), GET /log/update/{id} closed it; four admin report endpoints aggregated it. Entirely absent from the target — no table, no routes, no calls. Read the controller, the composable and the routes. ⏸️ DEFERRED — lowest priority, may never be built (owner, 2026-10-12: «ج اصلا اولیوت نداره جزو اخرین چیز هایی هست که شاید درستش کنیم»). The full design is parked in docs/PHASE2-D3-PLAN.md §"شکاف ۳", including two reference defects not to copy if it is ever built: log/create required no auth and accepted any title (anyone could fill the table from outside), and log/update was a GET that wrote. Open owner questions there too: retention (suggested 90 days — legacy never deleted) and whether to store the IP at all (suggested no).

App login — owner ruling 2026-10-12 (says what went wrong)

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

ID Severity Where Description Evidence Status
AUTH-01 High (support/UX) TGT apps/api/src/modules/customer-auth/customer-auth.service.ts requestOtp, both apps' LoginView.vue Step 1 returned { sent: true } for a number no customer holds, so the app advanced to the code box and the person waited for an SMS that was never sent. Three very different causes were indistinguishable on screen: the SMS is late, the gateway is down, or the number was never registered — the only one the person can act on. Read the guard (the opaque return { sent: true } with the enumeration note) and the apps' catch, which showed a generic «خطا در ارسال کد» for anything that was not a 429. ✅ FIXED. 404 + «این شماره در سیستم ثبت نشده است…»; both apps now surface the server's Persian message for 404 as they already did for 429. This restores the reference's behaviour (firstOrFail(), ApiMobileController::userAuth:43 → 404) — the opaque response was the deviation, not this. Enumeration stays bounded by assertSendAllowed (per-mobile and per-IP throttles), which the reference did not have, and step 2 is still a single message for every failure reason.
AUTH-02 High (support/UX) TGT same file, deliver A failed SMS send also returned success. SmsService never throws — a dead gateway is a log line (D20) — so an outage produced the identical "waiting at the code box" dead end, with nothing visible server-side either unless someone read the logs. SmsService.deliver returns { ok: false, … } and requestOtp ignored the result. ✅ FIXED. A failed send is now 503 + «ارسال پیام کوتاه در حال حاضر ممکن نیست…». D20 protects an operation from a dead gateway (an order must still be placed); in a login the SMS is the operation, so there is nothing to protect by staying quiet. The code row is left in place (harmless, expires on its own). The local-dev path is preserved: when SmsService reports its own sms-not-configured outside production the code is logged and the login completes.
AUTH-03 Medium (wrong message) TGT same file, resolveExpertTradingAccount Found while fixing AUTH-01. Three states return no trading account for a sales expert who does exist (linked customer missing/deactivated/archived, or no SalesExpertProfile). Previously all three fell into the opaque success; with AUTH-01 they would have reported «این شماره ثبت نشده است» — a false statement that would send support hunting a registration problem that is not there. Read the method: two return null paths plus the archived/inactive one. ✅ FIXED in the same pass: 403 «حساب شما غیرفعال است…» and «حساب شما کامل نیست…», each distinct from the unknown-number message. The missing-profile case is now logged at error, not warn — it means something bypassed both paths that create a profile (D17), i.e. a server-side defect.
DOC-01 Low (misleading) TGT both apps src/views/user/LoginView.vue header comment The file header still said the OTP flow was stubbed — "the SMS-send step is stubbed — no SMS provider exists", "Step 2 (OTP code) is NOT verified — any 4-digit code is accepted" — and that step 1 matched against GET /api/customers in the client, which was the login leak (D8). All three had been untrue for weeks. Read the comment against the code beside it. ✅ FIXED 2026-10-12 — corrected in both apps rather than deleted, with a note that the stale text actually misled a reader during this session.

Test gap this exposed. requestOtp had no test coverage at all: the whole 28-test customer-auth suite stayed green through a change that altered its response for every unregistered number. request-otp.spec.ts now covers 9 cases — the happy path, the unknown number, Persian-digit input, a refused gateway, the dev no-gateway fallback, that the fallback does not apply in production, and the two sales-expert states.