Adds computeVariantPrice(), a verbatim port of the live formula at henza_server/app/Models/ProductVariant.php:183-190, with the per-customer making-charge offset as an explicit input defaulting to 0. The three superseded formulas commented out above it apply VAT to the whole price and are deliberately not ported (D4). 11 unit tests assert against hand calculations derived from the PHP source rather than from the implementation, covering: with and without the offset, VAT on the non-metal portion only, floor-to-nearest-1000, the extraPercent default of 7, and the equivalence between the app folding the offset into percent_pay and the server deriving it. 18 tests pass overall. Also adds the D0 name-split review report script (read-only) and two investigations the owner asked for: - SMS: the reference uses the Trez panel (smspanel.trez.ir, Sms.php:69,97) with production credentials committed to git (SEC-12 — rotate them). All 12 message templates catalogued; env var names proposed; nothing implemented pending approval. - MON-08: henza_pwa computes percent_pay, discount and rial_pay rather than asking the user (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. rash_weight stays read from the DB as a recorded deviation. Records the owner's two new standing rules: migrations only through the standard Prisma workflow, and every data deletion logged in CHANGELOG. [ref: henza_server/app/Models/{ProductVariant,Sms}.php, henza_pwa/src/views/cart.vue] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
16 KiB
Markdown
309 lines
16 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.
|