Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1088 lines
58 KiB
Markdown
1088 lines
58 KiB
Markdown
# CHANGELOG — parity changes
|
||
|
||
Every change made to `gold-banking-monorepo` during the parity project, with the
|
||
reason and the reference path that justifies it.
|
||
|
||
Format:
|
||
|
||
```
|
||
## <date> — <phase> — <group title>
|
||
- **What:** <the change>
|
||
- **Why:** <the parity gap it closes>
|
||
- **Reference:** <henza_* path:line>
|
||
- **Target:** <gold-banking-monorepo path:line>
|
||
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
|
||
- **Commit:** <sha>
|
||
```
|
||
|
||
---
|
||
|
||
## 2026-09-21 — Phase 0 — Setup
|
||
|
||
- **What:** Created branch `parity/phase-0` and `docs/parity/` scaffolding
|
||
(`PROGRESS.md`, `ISSUES.md`, `CHANGELOG.md`).
|
||
- **Why:** Persistent state required by `CLAUDE.md` §6.
|
||
- **Reference:** n/a
|
||
- **Target:** `docs/parity/`
|
||
- **Matrix row:** n/a
|
||
|
||
_No source code changed in Phase 0 (read-only phase)._
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths
|
||
|
||
Branch `parity/security-hotfix`. Authorised by the user's rulings D7, T1–T3.
|
||
|
||
### 1. Ledger routes are admin-only (SEC-09)
|
||
|
||
- **What:** removed the controller-level `@Public()` from `AccountingController`
|
||
and gave each route a rid: `wallet.list` (balance), `wallet.charge`
|
||
(reconcile), `document.create` (both repayments), `document.list.confirm`
|
||
(confirm entry).
|
||
- **Why:** four of the five routes WRITE to the ledger and were reachable with no
|
||
token at all. Faithful to the reference, whose entire admin route file is
|
||
unauthenticated, but contrary to confirmed decision D1.
|
||
- **Reference:** `henza_server/routes/admin.php:12,272` (the `auth:api` wrapper is
|
||
commented out at both ends)
|
||
- **Target:** `apps/api/src/modules/accounting/accounting.controller.ts`
|
||
- **Breakage check:** `api.getBalance()` is the only app-side consumer and it has
|
||
**zero call sites** in either user app (dead code, already logged as DEAD-01/02).
|
||
|
||
### 2. Order status change is admin-only (SEC-10)
|
||
|
||
- **What:** moved `@Public()` from the class to the two customer-facing handlers,
|
||
leaving `PATCH orders/:id/status` behind `order.list.sts.submit`.
|
||
- **Why:** it let an anonymous caller move any order's status **and** attribute
|
||
the change to any admin through the DTO's `actorAdminUserId`.
|
||
- **Reference:** an admin operation; the admin panel posts to `admin/orders/status`
|
||
(`apps/admin-panel/src/api/endpoints.ts:42,48`), so this route had no caller.
|
||
- **Target:** `apps/api/src/modules/orders/orders.controller.ts`
|
||
- **Still public:** `POST /orders` and `GET /orders/:id`, because the target has
|
||
no customer session yet — closed in stage 2.
|
||
|
||
### 3. `GET /customers` no longer leaks the customer book (SEC-08)
|
||
|
||
- **What:** added `LOGIN_LOOKUP_SELECT` (`id`, `mobile`, `isActive`,
|
||
`isArchived`) and pointed `list()` at it. Removed `fullName`, `nationalCode`,
|
||
`goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, `verificationStep`,
|
||
`cityId`, `referredByAgentId`, `nationalIdImageUrl`, `businessLicenseUrl`,
|
||
`shopImageUrl`, `createdAt`, `updatedAt` from that response.
|
||
- **Why:** the route is unauthenticated because both apps match the phone number
|
||
in the browser, so it shipped every customer's national ID number, debts,
|
||
credit limit and identity-document URLs to any visitor.
|
||
- **Reference:** login was `POST userAuth` → SMS → `POST userVerfiy`, returning
|
||
exactly one customer after the code matched
|
||
(`henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78`).
|
||
- **Target:** `apps/api/src/modules/customers/customers.service.ts`,
|
||
plus `CustomerSummary` narrowed in **both** apps' `src/api/client.ts`
|
||
(parity rule: the same logic change in both).
|
||
- **Route survives** until stage 2 replaces the client-side phone match with OTP.
|
||
|
||
### 4. Test script
|
||
|
||
- **What:** added `apps/api/scripts/auth-smoke.sh` — asserts 401 without a token,
|
||
403 with a low-privilege token, and pass-through for a super admin, on every
|
||
closed route; plus that the public routes still answer 200 and that
|
||
`GET /customers` exposes only the four login fields.
|
||
- **Result:** **23 passed, 0 failed** against a live server and the dev seed.
|
||
|
||
### Not changed
|
||
|
||
`/uploads` — per ruling T2, uploaded files including identity documents stay
|
||
public. The filename check requested alongside it passed with no changes needed
|
||
(md5 of time + name + UUID; no directory listing).
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 2 — real customer sessions
|
||
|
||
Branch `parity/security-hotfix`. Authorised by D6, D7, T1 and T3.
|
||
|
||
Stage 1 narrowed `GET /customers` but, as the user pointed out, did not close
|
||
the leak: the response still carried every customer **id**, and
|
||
`/customers/:id`, `/:id/orders` and `/:id/ledger-entries` were still public — so
|
||
two requests still reconstructed the whole book. Stage 2 removes the id-bearing
|
||
surface entirely.
|
||
|
||
### 1. Customer JWT, separate from the admin one (T1)
|
||
|
||
- **New:** `src/modules/customer-auth/` — `CustomerJwtStrategy` (passport
|
||
strategy `'customer-jwt'`), `CustomerJwtGuard`, `@CustomerAuth()`,
|
||
`@CurrentCustomer()`.
|
||
- Signed with **`JWT_CUSTOMER_SECRET`**, separate from `JWT_SECRET`, and every
|
||
token carries **`typ: 'customer'`** which the strategy verifies. Either check
|
||
alone would be enough; both are present deliberately.
|
||
- Customer state is re-read from the DB on every request, so deactivating or
|
||
archiving a customer takes effect at once rather than at token expiry.
|
||
- **Reference:** the same split existed there — `auth:api` for admins,
|
||
`auth('api-customer')` for the app.
|
||
|
||
### 2. Real OTP (D6)
|
||
|
||
- **New table `CustomerOtp`** (migration `20260922000000_customer_otp`,
|
||
additive: one new table, nothing existing touched).
|
||
- `POST /api/customer/auth/request-otp` ← `ApiMobileController::userAuth` (:39-49)
|
||
- `POST /api/customer/auth/verify-otp` ← `ApiMobileController::userVerfiy` (:51-64)
|
||
- **Parity behaviour:** the 4-digit `rand(1000,9999)` range, the
|
||
active-customer filter, `Utility::faTOen()` digit normalisation on both the
|
||
number and the code, and rejection on mismatch.
|
||
- **Deliberate improvements over the reference** (user-approved, recorded in
|
||
DECISIONS.md so the matrix does not flag them later):
|
||
- the code is stored as a **bcrypt hash**, never plain text;
|
||
- it **expires** after 2 minutes (the reference's never did);
|
||
- **5 attempts** per challenge, then lockout (the reference had no limit);
|
||
- **single use** — a consumed code cannot be replayed;
|
||
- **send throttle**: 60s cooldown per number, 5 per number and 20 per IP per
|
||
10 minutes, counted off the table so it survives restarts;
|
||
- **no enumeration** — an unregistered number returns the same `{sent:true}`.
|
||
The reference's `firstOrFail()` leaked a 404 for unknown numbers.
|
||
- No SMS gateway exists in this project (STUB-02), so the code is logged in
|
||
non-production only and the message is persisted as an `SmsMessage` row. The
|
||
reference's message text is kept verbatim for when a gateway lands.
|
||
|
||
### 3. The id-bearing customer routes are gone
|
||
|
||
- **Deleted** `customers.controller.ts` — with it `GET /customers`,
|
||
`/customers/:id`, `/:id/orders`, `/:id/ledger-entries`.
|
||
- **Replaced by** `/api/customer/me`, `/me/orders`, `/me/ledger-entries`, which
|
||
take the id **from the token**. There is no longer any route that accepts a
|
||
customer id, so the IDOR is closed structurally rather than by a check.
|
||
- `CustomersService` stays and is reached only through those routes.
|
||
- The admin panel was unaffected — it uses `admin/customers-omde/*`.
|
||
|
||
### 4. Both apps migrated together (parity rule)
|
||
|
||
- `stores/session.ts`: `token` is now the credential and `isLoggedIn` is
|
||
token-based. `customerId` is kept for display only and is no longer an
|
||
identity claim.
|
||
- `api/client.ts`: sends `Authorization: Bearer`, throws `UnauthorizedError` on
|
||
401 and calls the handler registered in `main.ts`, which clears the session
|
||
and routes to login so stale personal data cannot stay on screen.
|
||
- `LoginView.vue`: real two-step OTP. Each app keeps its own markup and styling;
|
||
only the logic changed, and it is identical in both.
|
||
- The four account screens now call `getMe()` / `getMyOrders()` /
|
||
`getMyLedgerEntries()`; router guards test `isLoggedIn`.
|
||
|
||
### Tests
|
||
|
||
- `apps/api/scripts/auth-smoke.sh` extended with the stage-2 assertions:
|
||
**46 passed, 0 failed** against a live server — old routes 404, customer
|
||
routes 401 without a token, wrong/reused code 401, correct code 200, throttle
|
||
429, no enumeration, customer token rejected on admin routes and admin token
|
||
rejected on customer routes, no id-bearing surface, and `/customer/me`
|
||
returning the token's own customer.
|
||
- **jest installed** (dev-only, user-approved) with `jest.config.cjs`, so
|
||
`pnpm test` works for the first time: **7 passed** covering `faToEn`.
|
||
`faToEn` moved to its own `digits.ts` so it can be tested without booting Nest.
|
||
- Typecheck clean on api, user-app and user-app-v2; both apps build.
|
||
|
||
---
|
||
|
||
## 2026-09-22 — Security hotfix, stage 3 — SEC-02 (orders) + OTP audit
|
||
|
||
Branch `parity/security-hotfix`. The user declined to defer SEC-02 to Phase 2:
|
||
a public `POST /orders` taking `customerId` from the body let anyone place an
|
||
order on any customer's account, and an order lands on that customer's **gold
|
||
debt and ledger**. That is a money hole, so it is closed here.
|
||
|
||
### 1. `POST /orders` is customer-authenticated
|
||
|
||
- **What:** the route is behind `@CustomerAuth()`. `OrdersService.createOrder`
|
||
now takes `customerId` as a **separate argument**, supplied by the controller
|
||
from the token. The body's `customerId` is ignored entirely; a mismatch is
|
||
logged (`OrdersService` warn) and the token always wins.
|
||
- `CreateOrderDto.customerId` and the shared `CreateOrderDto` are **optional**,
|
||
so both apps keep validating while they still send it. Removed in Phase 2.
|
||
- **Reference:** `ApiMobileController::setOrder` read `auth()->id()`
|
||
(henza_server:533) behind the `api-customer` guard — this restores that.
|
||
- **Admin path unaffected:** `AdminOrdersService` places orders *for* a selected
|
||
customer and passes that id explicitly; its route is admin-guarded.
|
||
|
||
### 2. `GET /orders/:id` is scoped to the owner
|
||
|
||
- `getOwnedById(orderId, customerId)` matches on both, and a miss is **404, not
|
||
403** — a 403 would confirm the id exists, the same enumeration leak the old
|
||
`/customers/:id` routes had. A nonexistent id and someone else's id are
|
||
indistinguishable.
|
||
- Operators use the new `GET /orders/admin/:id` behind rid `order.list`.
|
||
Declared **before** `:id` because Nest matches in declaration order.
|
||
|
||
### 3. Both apps
|
||
|
||
- `CartView.vue` in **both** apps now gates on `session.isLoggedIn` (the token)
|
||
rather than on a remembered `customerId`, which is no longer a credential.
|
||
|
||
### 4. OTP audit (user request 3)
|
||
|
||
- **No master code, no bypass, no hardcoded code** anywhere in the API or either
|
||
app — verified by grep for `master.?code|bypass|backdoor|1111|0000|1234|skipOtp|DEV_OTP|TEST_OTP`.
|
||
Exactly one code path issues a token, and only after `bcrypt.compare`.
|
||
- **SMS provider: none.** No gateway is wired up (STUB-02). In development the
|
||
code is logged once via `Logger`, guarded by `NODE_ENV !== 'production'`.
|
||
- **Fixed while auditing:** `deliver()` persisted the real message text — with
|
||
the code in it — into `SmsMessage.body`, which the admin SMS grid both lists
|
||
and full-text searches (`admin-misc.service.ts:135-136`). Any operator with
|
||
the `sms` rid could read live OTP codes, defeating the point of hashing
|
||
`codeHash`. The persisted row is now a redacted audit line; the real text is
|
||
built for the future gateway and not stored. The 9 test rows created during
|
||
this session were deleted.
|
||
|
||
### Tests
|
||
|
||
`apps/api/scripts/auth-smoke.sh` extended again — **53 passed, 0 failed**:
|
||
order routes 401 without a token, a spoofed body `customerId` ignored in favour
|
||
of the token, own order readable, unknown/foreign order id 404, and the admin
|
||
order route rejecting a customer token while admitting an admin one.
|
||
Typecheck clean on api, user-app, user-app-v2 and shared-types.
|
||
|
||
---
|
||
|
||
## 2026-09-22 — migrate resolve, D13 scope, and the D0/percent migration set
|
||
|
||
### Standing rule adopted (owner, 2026-09-22)
|
||
|
||
1. **Migrations run only through the standard Prisma workflow** — `migrate dev`
|
||
in development. **`prisma db execute` is no longer used to apply schema
|
||
changes.** (It was used once, in the security hotfix, to create the
|
||
`CustomerOtp` table; that is what the resolve below repairs.)
|
||
2. **Every data deletion is recorded here** — including throwaway rows this
|
||
session creates itself.
|
||
|
||
### Data deletions (rule 2, applied retroactively to this session)
|
||
|
||
| When | What | How many | Why |
|
||
|---|---|---|---|
|
||
| Security hotfix stage 3 | `SmsMessage` rows written by the OTP flow during testing, whose `body` still held the live code before the SEC-11 fix | 9 | They contained real OTP codes in a grid any `sms`-rid operator can read. Test data only; no customer-facing message was ever sent (no gateway is wired). |
|
||
|
||
No other data has been deleted in this project.
|
||
|
||
### Ledger repair — approved and executed
|
||
|
||
```
|
||
npx prisma migrate resolve --applied 20260922000000_customer_otp
|
||
→ Migration 20260922000000_customer_otp marked as applied.
|
||
npx prisma migrate status
|
||
→ 9 migrations found. Database schema is up to date!
|
||
```
|
||
|
||
The `CustomerOtp` table already existed (created with `db execute` in stage 2);
|
||
this wrote the missing `_prisma_migrations` row **without running any SQL**, so
|
||
`migrate deploy` will no longer try to re-create the table. No schema change.
|
||
|
||
### Documentation
|
||
|
||
- **D13 recorded** — the retail storefront (wallet, cashback,
|
||
`ConfigStatic['percent']`) is preserved but out of scope. `DECISIONS.md` §D13,
|
||
`PROTECTED.md` §H, and status `⏸️` plus RULE C1 added to the audit skill.
|
||
- **Endpoint count corrected: 412, not 369.** `routes/admin.php` has 151 live
|
||
explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the
|
||
412, **109 are retail storefront**, 15 are `web.php` dev/ops, and **288 stay
|
||
in scope** (218 admin + 70 B2B app).
|
||
- **SMS provider report** — the reference uses the Trez panel
|
||
(`smspanel.trez.ir`, `Sms.php:69,97`) with **credentials committed to git**
|
||
(SEC-12, must be rotated). All 12 message templates catalogued, env var names
|
||
proposed. **Nothing implemented; awaiting approval.**
|
||
- **MON-08 answered** — `henza_pwa` **computes** `percent_pay`, `discount` and
|
||
`rial_pay`; the user types none of them (`cart.vue:170-179`,
|
||
`Mixins/product/index.ts:69-75`). So Phase 2 item #1 is to derive them
|
||
server-side and ignore the client's values, with a comparison test proving the
|
||
total is unchanged for an ordinary customer. `rash_weight` stays read from the
|
||
DB, recorded as a deliberate deviation.
|
||
|
||
### New, not yet applied — awaiting approval
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `prisma/migrations/20260922120000_customer_name_parts/migration.sql` | D0 — adds `firstName`/`lastName`, backfills first-token/rest, adds a `customer_sync_full_name` trigger keeping `fullName` in sync in both directions, indexes both parts. Additive; nothing dropped. |
|
||
| `prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql` | D9 — adds `makingChargeOffsetPercent DECIMAL(6,3) NOT NULL DEFAULT 0`. |
|
||
| `scripts/report-name-split.cjs` | Read-only review report for names with more than two tokens (D0 requirement 3), with `--csv` output. |
|
||
|
||
### Added and already passing
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `src/modules/pricing/variant-price.ts` | The reference price formula ported verbatim from `ProductVariant.php:183-190`, with the customer offset as an explicit input defaulting to 0. |
|
||
| `src/modules/pricing/variant-price.spec.ts` | 11 tests against **hand calculations** derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into `percent_pay` and the server deriving it. |
|
||
|
||
`npx jest` → **18 passed** (7 existing + 11 new), 2 suites.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (later) — D0 + percent applied, SMS provider implemented
|
||
|
||
### Migrations applied
|
||
|
||
`migrate dev` could not run (interactive-only, and this shell is not a TTY).
|
||
Drift was checked read-only first: the only difference is the pre-existing
|
||
`_goldbank_migrations` table, which Prisma would DROP — **it was not dropped**
|
||
(DEAD-07). Applied with `migrate deploy`, which touches only the pending files:
|
||
|
||
```
|
||
Applying migration `20260922120000_customer_name_parts`
|
||
Applying migration `20260922120100_customer_making_charge_offset`
|
||
All migrations have been successfully applied.
|
||
→ 11 migrations found. Database schema is up to date!
|
||
```
|
||
|
||
Backfill result (`node scripts/report-name-split.cjs`): 8 customers, 7 split
|
||
reliably, 0 single-token, 0 empty, **1 needs review** — `customer-dev-test`
|
||
(«کاربر تست توسعه»), a row this session created itself.
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| When | What | How many | Why |
|
||
|---|---|---|---|
|
||
| Round-trip test | `Customer` rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into `?` | 3 | Test fixtures. Customer count back to its original 8. |
|
||
| Trigger test | 1 temporary `Customer` row per run, deleted by the script itself and reported on stdout | 1/run | Test fixture. |
|
||
|
||
### API
|
||
|
||
- `toRow()` returns the **real** `fname`/`lname` (D0) and `percent` now reads
|
||
`makingChargeOffsetPercent` **from the customer** instead of the referring
|
||
agent's commission rate (D9 / CUST-02).
|
||
- `create()` stores the name parts, and **requires `percent`** with the
|
||
reference's own message, `وارد کردن percent الزامی است` — Laravel's stock
|
||
`required` text (`resources/lang/fa/validation.php:99`) with no attribute
|
||
translation for `percent`, so it is verbatim.
|
||
- `update()` writes the parts (letting the trigger rebuild `fullName`) and
|
||
**persists `percent`**, which it previously ignored entirely.
|
||
- `updateCredit()` accepted `percent` in its signature and never wrote it —
|
||
fixed.
|
||
- Sort/filter aliases `fname`/`lname` pointed at `fullName`; they now point at
|
||
the real columns, and `percent` is aliased too. Grid search covers both parts.
|
||
- `createSingle()` (retail form, D13) stores the parts too. No storefront
|
||
behaviour changed — only the shared columns are kept consistent.
|
||
|
||
### SMS — implemented (owner-approved)
|
||
|
||
- New `SmsModule` / `SmsService` (`@Global`), a port of `Sms.php:57-82`: POST,
|
||
form-urlencoded, 20s timeout, `PhoneNumber`/`Username`/`Password`/`Mobile`/
|
||
`Message`, body trimmed, every message persisted as `Sms::set()` did.
|
||
- **Failure handling matches the reference**: `send()` never throws. The
|
||
reference discarded `Sms::Send`'s result, so a gateway outage never aborted
|
||
the order, the document approval or the customer creation that triggered it.
|
||
Failures are logged and returned in `SmsSendResult`.
|
||
- **OTP text is byte-identical** to `ApiMobileController.php:47`, including the
|
||
`<#> code is …` first line and the trailing `aMo6iFyUQaY` — the Android SMS
|
||
Retriever hash. A unit test asserts the exact string.
|
||
- `assertSmsConfigured()` runs in `main.ts` **before the server listens**: in
|
||
production a missing key throws, naming it; outside production it warns and
|
||
codes are logged locally, so today's dev flow is unchanged.
|
||
- Credentials come from the environment only. `.env.example` lists the six names
|
||
with **empty values**; `.env` is gitignored (verified).
|
||
- The 12 B2B message templates are implemented per flow in their own phase —
|
||
admin in Phase 1, app in Phase 2 — not pre-emptively.
|
||
|
||
### Admin panel
|
||
|
||
- `singleCustomer.vue` — the `split(' ')` fallback is **removed** (D0 item 6).
|
||
- `bulkCustomer.vue` — `percent` defaults to **3** on create and is required
|
||
with the legacy message; the edit path coerces the Decimal string to a number
|
||
so diff-send does not re-send an unchanged field; `name` is only sent when a
|
||
name part actually changed, so the whole name is no longer re-submitted on
|
||
every save.
|
||
- `ReagentCustomer.vue` — **unchanged**. Its «درصد کمیسیون» is
|
||
`AdminUser.wagePercent`, a genuinely different field from `customers.percent`.
|
||
|
||
### Tests
|
||
|
||
- `npx jest` — **32 passed** (7 existing + 11 price formula + 14 SMS config/template).
|
||
- `scripts/test-name-trigger.cjs` — **9 assertions**, all three sync directions.
|
||
- `scripts/auth-smoke.sh` — **53 passed, 0 failed**.
|
||
- **Round-trip test (D12 rule E0)** on the customer entity, live:
|
||
create «محمد علی» + «احمدی» + percent 3 → edit form shows each field
|
||
separately and correctly → no-op save leaves the row **byte-identical** →
|
||
changing only `lname` changes `lastName` and `fullName` (trigger) and nothing
|
||
else → changing only `percent` changes exactly that column. The two-token
|
||
first name «محمد علی» survives intact, which is the defect the owner
|
||
originally reported.
|
||
- Typecheck: api ✅ · admin-panel ✅.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (later still) — D16 priority change, SEC-02 re-verified
|
||
|
||
### PRICE-01 stopped (D16)
|
||
|
||
Price display is retail-storefront work, so it falls under D13 and is now ⏸️.
|
||
|
||
- `apps/api/src/modules/pricing/variant-price.ts` and its 11 tests are **kept**
|
||
and still run; they are simply not used.
|
||
- **Nothing needed reverting.** `computeVariantPrice` was never called from
|
||
anywhere outside its own file and spec — verified by grep over `apps/api/src`
|
||
and `apps/admin-panel/src`. No code had been wired to display a price.
|
||
- Making-charge work (`percentage_pay`, `makingChargeOffsetPercent`, wage display
|
||
on variants and order lines) **stays in scope**.
|
||
- `ISSUES.md` PRICE-01 re-marked ⏸️ rather than deleted, so the evidence survives.
|
||
|
||
### SEC-02 re-verified live
|
||
|
||
Re-tested end to end rather than quoting the earlier run:
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| `POST /orders` with no token | 401 |
|
||
| Body `customerId: "customer-2"` + `customer-dev-test` token | landed on **customer-dev-test**; warn-logged |
|
||
| `GET /orders/:id` own | 200 |
|
||
| `GET /orders/:id` another customer's | **404** (not 403) |
|
||
| `GET /orders/:id` unknown | 404, identical |
|
||
| `GET /orders/admin/:id` with a customer token | 401 |
|
||
|
||
**9/9 passed**, plus **32/32** in `auth-smoke.sh`.
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| What | How many | Why |
|
||
|---|---|---|
|
||
| 1 `Order` created by the SEC-02 test, with its `OrderItem`, `OrderStatusEvent` and `LedgerEntry` rows | 1 order + its children | Test fixture, removed by the test itself. |
|
||
|
||
### A testing note worth keeping
|
||
|
||
The dev OTP line briefly appeared to be missing after the SMS change. It was not
|
||
a regression: a **stale API process from an earlier test still held port 3077**,
|
||
so the new instance died with `EADDRINUSE` and the log being tailed belonged to
|
||
the old process. Kill the listener (`netstat -ano | grep :3077`) before
|
||
concluding anything from a log. `smsConfigured()` returns false with no
|
||
credentials, so the dev log fires exactly as intended — confirmed once the port
|
||
was free.
|
||
|
||
---
|
||
|
||
## 2026-09-22 (evening) — D17: SalesExpertProfile + AdminUser name parts
|
||
|
||
### Migration `20260922180000_sales_expert_profile_and_admin_name_parts`
|
||
|
||
Shown, dry-run and approved before running. Applied with `migrate deploy`
|
||
(D14); drift afterwards is **zero**.
|
||
|
||
- **`SalesExpertProfile`** — `adminUserId` (unique FK, cascade), `cityId`
|
||
(FK → City, SET NULL), `gramLevel`, `phone`, `nationalCode`.
|
||
- **`AdminUser.firstName` / `lastName`** — additive, backfilled first-token/rest,
|
||
with the `admin_user_sync_full_name` trigger (**parts win** when both change).
|
||
Applies to **all** admins, closing D0 requirement 7.
|
||
- **Profile backfill** — `INSERT … WHERE NOT EXISTS` gave all 3 existing sales
|
||
experts a profile; the id is derived from the admin id, so a replay cannot
|
||
duplicate.
|
||
|
||
Owner's two corrections, both applied:
|
||
|
||
1. **Profiles for existing experts** + an `upsert` in the service on create and
|
||
on a role change to `SALES_EXPERT`, and `listReagents` **left-joins** so an
|
||
expert without a profile still lists (verified by test).
|
||
2. **`gramLevel` type matched** to `Customer.goldCreditLimitGrams` —
|
||
`DECIMAL(14,4)`, asserted identical in the dry-run.
|
||
|
||
**Validation research (as instructed).** The reference is deliberately lax:
|
||
`ncode` is `string(20)` nullable and **NOT unique**
|
||
(`2020_10_01_120430_customers_table.php:22`), `phone` is `string(15)` nullable
|
||
(`2020_10_25_155247_customers_cols.php:17`). Neither has a server rule —
|
||
`createCustomer` validates only `mobile` (required|unique) and `percent`
|
||
(required) (`AdminController.php:354`) — and both are `required: false` on the
|
||
client (`ReagentCustomer.vue:31,95`). Copied exactly; **no uniqueness invented**,
|
||
since that would reject data the reference accepts.
|
||
|
||
### Dry-run before applying
|
||
|
||
Ran the whole file inside a transaction, **twice**, then rolled back: 14
|
||
statements, table created, 3 profiles inserted, backfill correct, replay
|
||
produced no duplicates, and the rollback left the database untouched (verified
|
||
by re-querying `information_schema`).
|
||
|
||
### API
|
||
|
||
- `listReagents` returns **real** `city_id`, `city`, `gram_level`, `phone`,
|
||
`ncode`; the grid's «شهر» and «سطح اعتبار» filters map through the relation.
|
||
`remain` / `rial_remain` stay null — a sales expert is an AdminUser and has no
|
||
ledger of its own (D10).
|
||
- `findOne` includes the profile so the edit form populates.
|
||
- `create` stores name parts and creates the profile for a sales expert;
|
||
`update` writes the parts and upserts the profile.
|
||
- **Every remaining `fname: fullName, lname: ''` was fixed** — 6 more sites in
|
||
`admin-users`, `admin-comments`, `admin-documents` (×2), `admin-orders`,
|
||
`admin-preorders`.
|
||
|
||
### Admin panel
|
||
|
||
`ReagentCustomer.vue` gains «شهر», «سقف اعتبار», «کد ملی», «تلفن», and the
|
||
**city → commission autofill** ported verbatim from
|
||
`henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228` — including
|
||
the reference's falsy test, so a city percent of 0 also falls through to
|
||
`cities/get-percent-aux/{id}` (replicated, not "fixed" — D4). `name` is now sent
|
||
only when a name part changed, so diff-send is not defeated.
|
||
|
||
### Tests
|
||
|
||
| Suite | Result |
|
||
|---|---|
|
||
| `scripts/test-sales-expert-roundtrip.cjs` (new) | **33/33** |
|
||
| `npx jest` | 32/32 |
|
||
| `scripts/auth-smoke.sh` | 53/53 |
|
||
| `scripts/test-name-trigger.cjs` | 9/9 |
|
||
| Typecheck | api ✅ · admin-panel ✅ |
|
||
|
||
The round trip covers create-with-every-field → edit form populated separately →
|
||
byte-identical no-op save → single-field edits, with **«محمد علی» + «احمدی»**
|
||
surviving intact, plus both grid filters (positive **and** negative), the
|
||
left-join case, and the referrer dropdown.
|
||
|
||
**A test bug worth recording:** the filter assertions first failed because the
|
||
test sent `op: 'eq'`. The grid actually sends `op: '='`
|
||
(`XTable.vue:197-200`), and `toCondition` returns null for an unknown operator,
|
||
so the filter was silently dropped and the "returns ONLY matches" assertions
|
||
passed vacuously against an empty list. The product was correct; the test was
|
||
not. **A filter test needs a negative case, or a dropped filter looks like a
|
||
pass.**
|
||
|
||
### Data deletions (standing rule 2)
|
||
|
||
| What | How many | Why |
|
||
|---|---|---|
|
||
| `AdminUser` rows created by the round-trip test (one full expert, one profile-less) + their `SalesExpertProfile` rows | 2 per run | Test fixtures, removed by the test itself. |
|
||
| 1 temporary `Customer` row per `test-name-trigger.cjs` run | 1 per run | Test fixture. |
|
||
|
||
## 2026-09-22 — Delivery mode (D19/D20)
|
||
|
||
### API
|
||
- **`OTP_MASTER_CODE`** (D20) — `customer-auth.service.ts`. A delivery-only login
|
||
bypass, read **only** from the env var and disabled when unset or blank. It is
|
||
checked before the stored challenge so it works with no `CustomerOtp` row, and
|
||
it still enforces "an active, non-archived customer must exist". Every use is
|
||
logged with the customer's mobile. `main.ts` prints a boot banner while armed.
|
||
Token issuance was factored into `issueToken()` so the bypass and the real path
|
||
produce an identical token. Logged as **REL-01 (Critical)**.
|
||
- **SMS never blocks the boot** (D20) — `sms.service.ts`. `assertSmsConfigured()`
|
||
no longer throws in production; it warns and returns. `send()` already never
|
||
threw. Reverses D14. Logged as **REL-02**.
|
||
- **`faToEn` on admin-entered mobiles** (D19 gap 2) — `admin-customers.service.ts`
|
||
(`create`, `update`) and `admin-users.service.ts` (`create`, `update`).
|
||
Ports `Utility::faTOen` (`AdminController.php:333,364`). In `admin-users` the
|
||
normalisation runs **before** the uniqueness lookup, which previously let the
|
||
same number be registered twice as `۰۹۱۲…` and `0912…`.
|
||
- **Welcome SMS on customer create** (D19 gap 1) — `admin-customers.service.ts`.
|
||
Byte-identical to `AdminController.php:378-387` (verified by comparison,
|
||
including the trailing space in `لینک pwa `). Create-only, as in the reference,
|
||
and fire-and-forget so no gateway problem can delay or fail the save.
|
||
|
||
### Apps — user-app-v2 brought level with user-app
|
||
- **Cart stock ceiling** — `stores/cartStore.ts`. Ported `capQty`, `maxQty`, the
|
||
un-orderable guard in `addToCart` (now returning a boolean) and the clamp in
|
||
`updateFeild`. The two stores are now **byte-identical**.
|
||
- `composables/useProduct.ts` — ported `isVariantOrderable` and the
|
||
orderable-first default-variant selection. v2's variant-image `image` computed
|
||
is PROTECTED and was left untouched.
|
||
- `components/product/CartItem.vue`, `SingleProduct.vue` — the `+` button is
|
||
disabled at the ceiling; un-orderable variant chips are disabled and dimmed.
|
||
Only logic was ported: v2 keeps its own stepper DOM order, borderless rows and
|
||
chip design.
|
||
- `user-app/.../SingleProduct.vue` — `addToCart` now checks the store's return
|
||
value, so the "added" toast no longer fires when the store refused the item.
|
||
Applied to BOTH apps, per the rule that a logic fix lands in each.
|
||
|
||
### Tests
|
||
- New `customer-auth/master-otp.spec.ts` — 13 cases: the env switch, login with
|
||
the master code (including Persian digits), the audit log, refusal for
|
||
inactive/unknown customers, rejection when the var is absent, and the real code
|
||
still working both with and without the bypass armed.
|
||
- `sms/sms.service.spec.ts` — the boot-contract block rewritten for D20: never
|
||
throws, warns and names the missing variables.
|
||
- jest: **45 passed** (was 32). typecheck clean on api, admin-panel, user-app,
|
||
user-app-v2.
|
||
|
||
### Data
|
||
- No data was deleted or modified.
|
||
|
||
### Live verification of D20 (against a running API, port 3000)
|
||
|
||
Two scripts, both kept in `apps/api/scripts/`:
|
||
|
||
- `test-master-otp-live.cjs` — API started **with** `OTP_MASTER_CODE`: **8/8**.
|
||
Signs the customer in, the JWT works on `GET customer/me`, Persian digits are
|
||
accepted (faToEn), and — the RULE E1 negatives — a near-miss code, an unknown
|
||
mobile and an inactive customer are all still rejected.
|
||
- `test-master-otp-disabled.cjs` — API restarted **without** the variable:
|
||
**6/6**. The same master code now returns 401, the boot banner is absent, a
|
||
freshly requested real code still logs in, and replaying it fails (single use).
|
||
|
||
Boot behaviour confirmed from the log: with no SMS credentials the server
|
||
**started normally**, warned once, and `[dev] SMS not configured; not sending`
|
||
appeared instead of an error — no operation was blocked (D20).
|
||
|
||
**Data touched:** `test-master-otp-disabled.cjs` deletes the `CustomerOtp` rows
|
||
**for its own test number only** (to clear the 60s resend cooldown); 1 row was
|
||
deleted on the recorded run. No other data was created, changed or removed.
|
||
|
||
### Admin smoke test + a real bug it caught (D19 step 3)
|
||
|
||
New `apps/api/scripts/smoke-admin.cjs` — **43/43**. Walks the flows the owner
|
||
listed: login → create a customer → create and edit a sales expert → create and
|
||
edit a product with variants → list, view and change an order's status. Each
|
||
step asserts the DATABASE changed, not just that the call returned 200.
|
||
|
||
**ORD-01 (fixed) — retail order status editing was completely broken.**
|
||
`views/order/List.vue:203,215` posted the timeline ACTIVITY entry straight to
|
||
`single/order-sts/edit`. That object is `{status, title, proceed, uaj, note}`
|
||
(`admin-orders.service.ts:249-258`) and carries **no order id**, so
|
||
`UpdateOrderStatusDto` rejected every call with 400: neither «ثبت وضعیت» nor
|
||
«حذف وضعیت» worked on that screen. Both handlers now build the body from
|
||
`tempRow.id`, the order loaded when the dialog opens — the shape `OmdeList.vue:187`
|
||
and `TaminList.vue:169,184` already used. Those two screens were checked and
|
||
were already correct; `List.vue` was the only broken caller.
|
||
|
||
Two assertions in the first draft of the smoke test were wrong, not the product:
|
||
the product form reads `variants_all` (not `variants`), and the status endpoint
|
||
takes `orderId` (not `id`/`sts`). Both corrected.
|
||
|
||
**Data touched:** the script creates and then deletes its own rows only
|
||
(1 product + its variants/images, 1 admin user + profile, 2 customers per run).
|
||
The order status change is made against an existing order and then **restored to
|
||
its original value** in the same run; no pre-existing row is left modified.
|
||
|
||
## 2026-09-23 — Apps smoke test (D19 step 1)
|
||
|
||
New `apps/api/scripts/smoke-apps.cjs` — **43/43**. Walks the customer flow the
|
||
owner listed: login with the master OTP → see products → add to cart → place an
|
||
order → see my orders, plus the negatives (empty order → 400, unauthenticated
|
||
order → 401).
|
||
|
||
Because both apps share one API client, the network flow is exercised once; the
|
||
**cart rules are asserted against BOTH apps' real source**, loaded from disk and
|
||
executed, so "the apps work" cannot be proved by v1 alone. `capQty` was checked
|
||
over five cases (over-stock, under-stock, pre-order, zero, negative) in each app.
|
||
|
||
**Result: no behavioural divergence between user-app and user-app-v2.**
|
||
Confirmed by direct comparison:
|
||
|
||
| Surface | Result |
|
||
|---|---|
|
||
| All 9 Pinia stores | **byte-identical** |
|
||
| Router (route names) | identical |
|
||
| API client call list | identical |
|
||
| Composables | identical except `useProduct.image` — v1's variant-image picker, **PROTECTED** |
|
||
| Components | none missing from v2; its 4 extra home components are its own design |
|
||
|
||
The making-charge formula was verified against a real order:
|
||
`5.65g +15% = 6.4975` exactly, matching the reference's
|
||
`rash × (1 + (wage − discount)/100)`.
|
||
|
||
### APP-01 (constraint, not a code change) — the master code must be 4 digits
|
||
|
||
Both apps render the login OTP as **four single-character boxes** and submit
|
||
only on `@on-complete`, i.e. at exactly four digits
|
||
(`LoginView.vue:43` in each app; `OtpInput.vue:43`). The API accepts any length,
|
||
so a longer `OTP_MASTER_CODE` works over HTTP but **cannot be typed into either
|
||
app**. The real OTP is 4 digits (`Math.floor(1000 + random*9000)`), so the box
|
||
is correct — only the master code has to match it.
|
||
|
||
The apps were NOT changed (that would touch the shipped login UI for a
|
||
delivery-only feature). Instead `main.ts` now checks the code at boot and adds
|
||
an explicit warning when it is not 4 digits, and `.env.example` states the rule.
|
||
Re-ran the full apps smoke with a 4-digit code: **43/43**.
|
||
|
||
### APP-02 (logged, not fixed) — v2 does not show the admin's home slider
|
||
|
||
`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
|
||
`getHomeSlider` in its API client at all and always derives its hero from
|
||
categories. So slides configured in the admin panel appear in v1 only.
|
||
|
||
Not fixed tonight: v2's home is its own design (HeroCarousel/ProductPanel), and
|
||
wiring a different slide source into it during a delivery risks breaking the
|
||
page that is actually being demonstrated. Listed under known issues.
|
||
|
||
**Data touched:** the script creates one order and deletes it again, with its
|
||
items, status events and ledger entries. No pre-existing data is modified.
|
||
|
||
## 2026-09-23 — B2B admin scan (D19 step 2)
|
||
|
||
Two new scripts, scoped to the B2B surfaces the owner named. Retail/storefront
|
||
(`single*`, wallet, comments) was **not** scanned — D13, preserved untouched.
|
||
|
||
**`scripts/scan-admin-b2b.cjs` — 34 ok, 0 broken.** Every read endpoint the
|
||
wholesale desk opens daily: the five customer grids, the referrer dropdown,
|
||
customer detail/dashboard/orders/documents, the sales-expert list and detail,
|
||
products, variants, brands, collections, product detail and its variants,
|
||
wholesale and tamin order grids, order detail, the documents grid and detail,
|
||
and the supporting lookups (cities, branches, workshops, pre-orders, users).
|
||
|
||
**`scripts/smoke-admin-b2b-writes.cjs` — 25/25.** The money-touching writes a
|
||
read-only scan cannot cover:
|
||
|
||
- Placing a wholesale order from the admin panel: the making charge computes
|
||
exactly (`8.133g +2% = 8.2957` per unit, ×2 = `16.5913` total), the order
|
||
lands on the right customer and appears in the grid with a status timeline.
|
||
- Recording a cash payment document: the reference's formula
|
||
`price / (price_per_gram / 4.331802)` gives 0.8664g, the entry is created
|
||
**unconfirmed** (the intentional deviation from legacy's auto-confirm),
|
||
confirming moves the customer's gold debt 8.25 → 7.3836, and confirming again
|
||
**toggles it back** and restores the balance exactly.
|
||
|
||
No B2B breakage was found. Three failures in the first drafts were test bugs,
|
||
not product bugs, each corrected after checking the real caller:
|
||
|
||
| Wrong assumption | What the admin panel actually sends |
|
||
|---|---|
|
||
| `POST cities/list` | `admin/cities/list` (endpoints.ts:152) |
|
||
| `by-ids` takes `ids` | `products_id` (stores/cart.ts:102) |
|
||
| document `type: 2` is cash | `type: 4` is «پولی»; 2 is «حواله» and takes a weight in `value` (addFinDoc.vue:33) |
|
||
|
||
**Data touched:** both scripts delete only what they create (one order with its
|
||
items/status events/ledger entries; one ledger entry). The write script also
|
||
asserts the customer's gold and rial balances end exactly as they started.
|
||
|
||
## 2026-09-23 — Delivery branch prepared (D19 step 4)
|
||
|
||
**`release/delivery`** created from `parity/phase-1-admin`, which already
|
||
contained every commit of `parity/security-hotfix` — so the merge was a
|
||
fast-forward and there were **no conflicts**. `main` untouched.
|
||
|
||
### DEPLOY-01 (fixed) — the compose file dropped every auth and SMS variable
|
||
|
||
`docker-compose.yml`'s `api` service passed only `DATABASE_URL` and `PORT`.
|
||
`JWT_SECRET`, `JWT_CUSTOMER_SECRET`, `OTP_MASTER_CODE` and all five `SMS_TREZ_*`
|
||
variables were **never forwarded into the container**, so in a deployed
|
||
environment the admin JWT would have fallen back to the well-known development
|
||
secret and the delivery master code would simply not have existed — the demo
|
||
login would have failed with no obvious cause. All of them are now passed
|
||
through, each defaulting to empty, which the code already treats as "unset"
|
||
(verified with `docker compose config`, with and without values set).
|
||
|
||
### TEST-01 (fixed) — a stale hardcoded port
|
||
|
||
`test-sales-expert-roundtrip.cjs` pointed at `localhost:3077`, a port from an
|
||
earlier dev session, so it failed with ECONNREFUSED. Now defaults to
|
||
`localhost:3000` (the repo's `API_PORT`) and takes an override argument.
|
||
Re-ran: **33/33**.
|
||
|
||
### `DELIVERY.md` written
|
||
|
||
Covers the env variables (names only, no values), dev/build/deploy commands, how
|
||
migrations run (the in-container runner and why `_goldbank_migrations` exists
|
||
alongside `_prisma_migrations`), the full verification table, and the known
|
||
issues — the four missing app pages, APP-02, PERS-01/02/03, PERF-01, and the
|
||
parity work Phase 1 has not reached. The two pre-release actions (remove
|
||
`OTP_MASTER_CODE`, configure SMS) are at the top of the file and logged as
|
||
REL-01/REL-02 in ISSUES.
|
||
|
||
### Verification on release/delivery — all green
|
||
|
||
build 5/5 · jest 45/45 · admin smoke 43/43 · apps smoke 43/43 ·
|
||
B2B scan 34 ok / 0 broken · B2B writes 25/25 · auth-smoke 32/32 ·
|
||
sales-expert 33/33 · name trigger 9/9 · typecheck clean on all four apps.
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 1 close-out — order SMS (SMS-03) and the §2 rid matrix
|
||
|
||
### 1. Order-placement SMS — SMS-03
|
||
|
||
- **What:** `OrdersService` now injects `SmsService` and, after the order
|
||
transaction commits, texts three audiences: the admins listed in
|
||
`ORDER_ADMIN_MOBILES`, the customer (different text when the order was
|
||
suspended for exceeding the gram ceiling), and the customer's referring sales
|
||
expert. Brand «منتزر» replaces «هنزا گلد»; every other character of the four
|
||
texts is the reference's.
|
||
- **Why:** the matrix had logged only the suspended-order message as missing. On
|
||
reading the reference, **all four** messages were absent and `OrdersService`
|
||
did not inject `SmsService` at all — a customer placing an order was told
|
||
nothing, and neither was anyone else.
|
||
- **Reference:** `henza_server/app/Http/Controllers/Api/ApiMobileController.php:733-766`
|
||
- **Target:** `apps/api/src/modules/orders/orders.service.ts`
|
||
(`notifyOrderPlaced`, `notifySalesAgent`), `orders.module.ts`, `.env.example`
|
||
- **Matrix row:** `01-ADMIN-MATRIX.md` §4, SMS-03
|
||
- **Deliberate non-replication:** the «در سیستم ویرایش شد» variant (`:734-737`)
|
||
is **not** ported — no target path lets a customer edit a placed order, so the
|
||
branch could never fire. Annotated «عمداً تکرار نشد — SMS-03» per D4.
|
||
- **Decisions carried over:** admin numbers come from an env var rather than the
|
||
reference's four hardcoded personal numbers, matching the existing
|
||
`PROFILE_ADMIN_MOBILES` pattern; SMS is fire-and-forget per D20, so a dead
|
||
gateway can never fail an order; the messages are sent **after** the
|
||
transaction commits, as the reference did, so a rolled-back order texts nobody.
|
||
`Notify::store` (Firebase push) beside each message stays deferred as PUSH-01.
|
||
- **Tests:** 6 new cases in `orders.service.spec.ts` (customer placed/suspended
|
||
text, admin fan-out, no-admins-configured, sales expert, expert without a
|
||
mobile). Full api suite **283/283**, typecheck clean.
|
||
- **Found while testing:** the pre-existing "sales expert credit" suite mocked no
|
||
`adminUser`, so the new agent lookup hit its catch and logged an error while
|
||
the test still passed green. Mock completed — a passing test now means the
|
||
path actually ran.
|
||
|
||
### 2. §2 of the admin matrix — the D1 rid matrix
|
||
|
||
- **What:** filled in the section that had stood as *(pending)* since the phase
|
||
began, mechanically from the target's `@RequirePermission` decorators and
|
||
`permissionConst.ts`: 163 rid-guarded routes, 68 distinct rids, 176 tree keys,
|
||
mapped per module onto the reference controller each one ports.
|
||
- **Why:** D1 required proof that no admin operation is left without an
|
||
equivalent permission. It is the last unfinished item of Phase 1.
|
||
- **Result:** 66 of the 68 enforced rids are grantable from the permission
|
||
screen. The 2 that are not — `newsletters` and `persons.list.sellers.view` —
|
||
are logged as **PERM-02**; both are inherited from the reference, whose own
|
||
`permissionConst.ts` header had already recorded four such rids (the other two
|
||
now have no route on either side).
|
||
- **Target:** `docs/parity/01-ADMIN-MATRIX.md` §2
|
||
- **Note on method:** the first extraction pass read the rid that followed each
|
||
HTTP decorator and produced an off-by-one map (it claimed
|
||
`reconcile → wallet.list`). Caught by spot-checking against this CHANGELOG's
|
||
own stage-1 entry, which records `reconcile → wallet.charge`. The corrected
|
||
pass was re-verified against five independently documented mappings before any
|
||
table was written.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — item #1: MON-08 step 2 (the discarded client price is now logged)
|
||
|
||
- **What:** `OrdersService.warnOnPriceMismatch` — when an app order is priced by
|
||
the server and the client had sent different figures, the difference is logged
|
||
(`wagePercent sent=… used=…`, same for `discountPercent`, plus any non-zero
|
||
`flatRialCharge`). The order is still charged the server's figures; nothing
|
||
about the pricing changed.
|
||
- **Why:** step 2 of the owner's MON-08 ruling. Steps 1, 3 and 4 had landed with
|
||
the wage-formula work, but the client's values were being discarded **in
|
||
silence** — so a stale app version, a promotion that expired between the
|
||
product page and checkout, and a tampered request were indistinguishable and
|
||
left no trace at all.
|
||
- **Reference:** `henza_server/app/Http/Controllers/Api/ApiMobileController.php:536-541`
|
||
(debt computed from the request, nothing re-read from the DB);
|
||
`henza_pwa/src/views/cart.vue:170-179` (the app computes all three).
|
||
- **Target:** `apps/api/src/modules/orders/orders.service.ts` (the call site in
|
||
the per-item map; the method beside `adminMobiles`).
|
||
- **Matrix row:** MON-08 in `ISSUES.md` §"Phase 2 — item #1".
|
||
- **Commit:** see below.
|
||
|
||
**Also added — the comparison test the ruling asked for.** Step 3 required «تست
|
||
مقایسهای که نشان دهد نتیجه برای کاربر عادی یکسان است», and the suite did not
|
||
have one: it only proved that a *tampered* request gets overridden. The new
|
||
`"gives an honest app exactly the total it sent"` places the same honest order
|
||
once as a customer (server-priced) and once as an operator (client-priced) and
|
||
asserts the two totals are identical — which is the actual claim, that an
|
||
ordinary customer sees no change. Two more cover the logging itself: the
|
||
tampered order logs all three fields, and an honest one logs nothing (so a green
|
||
"quiet" test cannot be green merely because the method never ran).
|
||
|
||
`orders.service.spec.ts`: 13 → 16 tests. Full api suite **286/286, 26 suites**,
|
||
`tsc --noEmit` clean, and the run log inspected for swallowed errors.
|
||
|
||
**STUB-05 closed** in the same pass: the status still read "Pending the SMS
|
||
approval" though `PROFILE_ADMIN_MOBILES` and `ORDER_ADMIN_MOBILES` had replaced
|
||
the reference's six hardcoded staff numbers. No literal number is in the
|
||
target's git history.
|
||
|
||
**Deliberately unchanged:** the target reads `rashWeightGrams` from the variant
|
||
row rather than trusting the client, unlike the reference. Per the owner's
|
||
earlier ruling this deviation stays, and it is re-recorded in `ISSUES.md` so it
|
||
is not "fixed" later by someone restoring parity for its own sake.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — item #2: D3's three outstanding behaviours
|
||
|
||
- **What:** (1) A guest bounced off a personal page now sees «ابتدا وارد شوید»
|
||
in both apps. (2) Legacy's `isLoaded` router guard is recorded as deliberately
|
||
not ported. (3) Route analytics parked as ANLY-01.
|
||
- **Why:** D3's remaining items. Seven of its ten were already built — all five
|
||
screens in **both** apps, the `sts_verify_step_two` guard and v2's cart stock
|
||
ceiling — confirmed by reading the code rather than trusting the checkmarks.
|
||
- **Reference:** `henza_pwa/src/router/index.ts:253-261` (the notify + home),
|
||
`:263-266` (the `isLoaded` guard), `router/customAnalytics.ts` +
|
||
`henza_server/app/Http/Controllers/Api/LogController.php` (analytics).
|
||
- **Target:** both apps' `src/router/index.ts` and `src/stores/configStore.ts`.
|
||
- **Matrix row:** APP-03, APP-04, ANLY-01 in `ISSUES.md` §"Phase 2 — item #2".
|
||
- **Commit:** see below.
|
||
|
||
**The one judgement call.** Legacy showed the toast and then sent the guest to
|
||
`home`, discarding where they were going. The target redirected to `/login` with
|
||
`query.redirect`, so signing in returns the guest to the page they wanted — that
|
||
part is better than the reference, and the gap was only the missing message.
|
||
Rather than choose silently, the trade-off went to the owner, who kept the
|
||
target's destination. So the message is the parity row and the destination is
|
||
**«عمداً تکرار نشد — D3/الف»**. The guard also keys off `meta.requiresAuth`
|
||
instead of legacy's hand-kept list of five route names, which means the two
|
||
sales-expert pages are covered too.
|
||
|
||
**`isLoaded` — argued against, and the owner agreed.** It guarded legacy's one
|
||
big bootstrap request, which the target does not have (each page fetches its own
|
||
data). Added, it would guard nothing and introduce a bounce: a shared product
|
||
link would open `home` instead of the product. That is porting the breakage, not
|
||
the behaviour (D4). The reasoning is written into both apps' `configStore.ts`,
|
||
beside the line that says the flag was not ported, so the next reader finds it
|
||
where they would actually look.
|
||
|
||
**Analytics deferred by the owner** («ج اصلا اولیوت نداره»). The design is kept
|
||
in `docs/PHASE2-D3-PLAN.md` so it does not have to be re-derived, including the
|
||
two reference defects not to copy — `log/create` was unauthenticated and took any
|
||
title, so anyone could fill the table from outside, and `log/update` was a `GET`
|
||
that wrote — plus the two questions only the owner can answer: how long to keep
|
||
per-customer browsing history (legacy never deleted it) and whether to store the
|
||
IP at all.
|
||
|
||
**Verification.** Neither app has a test runner, so the check is `vue-tsc -b` on
|
||
both (clean) plus reading the three things that make the toast actually appear
|
||
rather than merely compile: `toast.info` exists (`composables/useToast.ts:66`),
|
||
`<ToastHost />` is mounted in each `App.vue:25`, and the call sits before the
|
||
guard's `return`. A compiling toast with no host would have been invisible.
|
||
Noted in the code: with the IAM enabled, silent SSO leaves the page before this
|
||
guard runs, so the toast is a no-op there by design, not a defect.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — App login — say what went wrong (AUTH-01/02/03, DOC-01)
|
||
|
||
- **What:** Step 1 of the app login now reports an unregistered number (404) and
|
||
a failed SMS send (503) instead of answering success in both cases. A sales
|
||
expert whose trading account cannot be used gets its own 403 rather than the
|
||
unknown-number message. Both apps surface the server's Persian message for a
|
||
404 as they already did for a 429.
|
||
- **Why:** owner report — a number that was not in the system produced no error:
|
||
the app moved to the code box and «واقعا نمیشد فهمید خطا از پروژه هست یا نه
|
||
پیامک نمیاد یا نه واقعا نیست». Three causes looked identical on screen, and
|
||
only one of them was something the person could act on.
|
||
- **Reference:** `ApiMobileController::userAuth:43` — `firstOrFail()`, i.e. 404
|
||
for a number with no active customer. **The opaque answer was the deviation;
|
||
this restores the reference.**
|
||
- **Target:** `apps/api/src/modules/customer-auth/customer-auth.service.ts`
|
||
(`requestOtp`, `deliver`, `resolveExpertTradingAccount`), both apps'
|
||
`src/views/user/LoginView.vue`.
|
||
- **Matrix row:** AUTH-01, AUTH-02, AUTH-03, DOC-01 in `ISSUES.md`.
|
||
- **Commit:** see below.
|
||
|
||
**The security trade-off, stated plainly.** The opaque response existed to stop
|
||
an outsider enumerating which mobile numbers are registered. That protection is
|
||
now reduced at step 1, deliberately, on the owner's instruction: the cost was
|
||
being paid by real users and by support on every login problem. What still
|
||
stands — the per-mobile and per-IP send throttles (`assertSendAllowed`), which
|
||
the reference did not have, so enumeration is rate-bounded rather than free; and
|
||
`verifyOtp`, which keeps one message for every failure reason. `ISSUES.md`
|
||
SEC-03's "no enumeration" claim was corrected rather than left standing.
|
||
|
||
**Two further problems found while fixing the reported one.** Both were the same
|
||
shape — an operation reporting success while doing nothing:
|
||
|
||
1. A failed SMS send also returned `{ sent: true }`, because `SmsService` never
|
||
throws (D20). A dead gateway therefore produced the exact same dead end. D20
|
||
is about not letting a failed SMS break an *operation*; in a login the SMS is
|
||
the operation, so it now fails loudly. The local-dev path is preserved by
|
||
keying 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 test caught
|
||
exactly this**, on the first run.
|
||
2. Three states return no trading account for a sales expert who *does* exist.
|
||
Under the new 404 they would each have claimed the number was not registered
|
||
— a false message that sends support looking for the wrong thing.
|
||
|
||
**A stale comment that actively misled.** Both apps' `LoginView.vue` header still
|
||
described the login as stubbed: no SMS provider, any 4-digit code accepted, and
|
||
step 1 matching against the customer list in the client (which was the login
|
||
leak, D8). All three had been untrue for weeks, and it misled a reader during
|
||
this very session. Corrected in place, with that noted.
|
||
|
||
**Verification.** `requestOtp` had **no tests at all** — the 28-test customer-auth
|
||
suite stayed green through a change to its response for every unregistered
|
||
number, which is why the suite's greenness was not treated as evidence.
|
||
`request-otp.spec.ts` adds 9 cases, including that the dev fallback does *not*
|
||
apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
|
||
clean.
|
||
|
||
_No data was deleted in this entry._
|
||
|
||
---
|
||
|
||
## 2026-10-12 — Phase 2 — «ورود به حساب مشتری» restored (EXP-01/02/03, D29)
|
||
|
||
- **What:** A sales expert can again open one of their referred customers'
|
||
accounts and use the whole app as them — browse, see balance, debt and
|
||
reports, and place orders that land on the customer's account. Entered from
|
||
the customer's card on «لیست مشتریان»; a permanent bar names whose account it
|
||
is and exits.
|
||
- **Why:** owner report — the reference had it («یکجورایی انگار با اکانت اون ها
|
||
لاگین کرده … و این خیلی کاربردی بود ولی الان نیست») and D21 had deliberately
|
||
left it out as a session-hijack primitive. The owner asked for the capability
|
||
back, with the implementation left to judgement: «همان قابلیت هارو میخوام حالا
|
||
هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره».
|
||
- **Reference:** `customerChoice.vue:155-167` → `myDataStore.ts:159-169` →
|
||
`tokenManage.ts:8` → `plugins/axios.ts:18`; server
|
||
`ApiMobileController.php:197-201`.
|
||
- **Target:** `customer-auth/app-principal.ts`, `customer-jwt.strategy.ts`,
|
||
`orders.controller.ts`, `orders.service.ts`, `sales-expert-app.controller.ts`;
|
||
both apps' `stores/session.ts`, `api/client.ts`, `stores/queryCache.ts`,
|
||
`stores/favoritesStore.ts`, `stores/wageQuoteStore.ts`,
|
||
`views/expert/CustomerChoiceView.vue`, `components/expert/ActingAsBar.vue`,
|
||
`App.vue`.
|
||
- **Matrix row:** EXP-01, EXP-02, EXP-03 in `ISSUES.md`; D29 in `DECISIONS.md`;
|
||
D21's "deliberately not ported" paragraph marked superseded.
|
||
- **Commit:** see below.
|
||
|
||
**Scope is the reference's, in full.** Its axios interceptor swapped the bearer
|
||
token, so *every* request went as the customer — including document upload. No
|
||
route is closed in act-as mode. (The plan had proposed closing documents and
|
||
logout; the owner asked for the reference's behaviour, so that was dropped.)
|
||
|
||
**The mechanism is not the reference's.** It minted a permanent `api_token` per
|
||
customer and handed it over. Instead the expert keeps their own token and sends
|
||
`X-Act-As-Customer`, resolved in one place — `CustomerJwtStrategy.validateExpert`.
|
||
That single point is why all 17 customer routes work unchanged: `validate()`
|
||
already returns a principal whose `id` is a Customer id, and every customer route
|
||
reads it. Four consequences, each fixing a real defect of the original:
|
||
|
||
1. **Ownership is re-checked on every request**, with `referredByAgentId` in the
|
||
same `where` as the id — so another expert's customer and a non-existent id
|
||
give the same answer (403), and a customer moved to another expert loses
|
||
access at once rather than at token expiry.
|
||
2. **Nothing durable is handed out.** The reference's token sat in
|
||
`localStorage` with no expiry and kept working after the expert left. Here
|
||
the target is in `sessionStorage` and is only a hint; the authority is the
|
||
expert's own token plus the per-request check.
|
||
3. **The real actor survives.** `adminUserId` is kept, so an order placed this
|
||
way is logged as `sales expert <id> acting as them`. In the reference the
|
||
request was literally the customer's and nothing could be attributed
|
||
afterwards.
|
||
4. **403, not 401**, for a customer that is not theirs: the expert's own session
|
||
is valid, so the app clears the act-as state instead of logging them out.
|
||
|
||
**Performance.** One `findFirst` on the primary key, and only when the header is
|
||
present — it *replaces* the `tradingCustomer` lookup rather than adding to it, so
|
||
the query count is unchanged in act-as mode and untouched for everyone else.
|
||
|
||
**Three things the reference did not need, because it swapped the whole session.**
|
||
Found by reading the stores rather than assuming:
|
||
|
||
- `queryCache` (`me:` keys), `favoritesStore` and `wageQuoteStore` each dropped
|
||
their data on a **token** change. Act-as changes whose data they hold without
|
||
changing the token, so without this an expert would have seen their own
|
||
profile, orders, balance, ledger and favourites inside the customer's account.
|
||
The wage-quote one is the money case: making charge is per customer, so the
|
||
figure on screen would not have been the one the order was priced at. Each
|
||
watcher's own comment already stated the rule it was about to break.
|
||
- `uploadDocument` bypasses `request()` and builds its own headers, so an
|
||
expert's upload would have been filed against their own account, silently.
|
||
`grep Authorization` on the client returning two call sites is what caught it.
|
||
- The cart is in `localStorage`, so it is cleared in **both** directions —
|
||
otherwise the expert's own basket follows them in and ends up on the
|
||
customer's order.
|
||
|
||
**Added on purpose, with no reference equivalent:** the permanent «در حال مشاهده
|
||
بهعنوان …» bar. There, impersonation was invisible once the token had been
|
||
swapped — the only way to tell whose account you were in was to notice the data
|
||
looked wrong, and placing an order on the wrong customer's gold debt was one tap
|
||
away. It is also the exit; the reference's only way out was the logout button,
|
||
which ended the expert's own session too.
|
||
|
||
**Verification.** `CustomerJwtStrategy` had **no test coverage at all** before
|
||
this, and it is now where the whole authorisation boundary of the feature lives,
|
||
so `act-as-customer.spec.ts` covers 11 cases: the happy path, the scoped query
|
||
itself, another expert's customer, an inactive customer, a deactivated expert, a
|
||
demoted expert, the header being **ignored** on a customer token, the no-header
|
||
fallback (asserting the extra query does not run), blank and duplicated headers,
|
||
and a revoked session short-circuiting before the header is read. Plus 2 cases on
|
||
the order audit trail. Full api suite **308/308, 28 suites**; both apps
|
||
`vue-tsc` clean.
|
||
|
||
_No data was deleted in this entry._
|