Applies the two approved additive migrations and fixes every consumer.
D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.
The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.
D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).
update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.
SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.
Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.
Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.
Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).
[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
401 lines
20 KiB
Markdown
401 lines
20 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 ✅.
|