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>
20 KiB
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-0anddocs/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()fromAccountingControllerand 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(theauth:apiwrapper 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, leavingPATCH orders/:id/statusbehindorder.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 /ordersandGET /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 pointedlist()at it. RemovedfullName,nationalCode,goldDebtGrams,fiatDebtRial,goldCreditLimitGrams,verificationStep,cityId,referredByAgentId,nationalIdImageUrl,businessLicenseUrl,shopImageUrl,createdAt,updatedAtfrom 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, plusCustomerSummarynarrowed 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 thatGET /customersexposes 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 fromJWT_SECRET, and every token carriestyp: '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:apifor admins,auth('api-customer')for the app.
2. Real OTP (D6)
- New table
CustomerOtp(migration20260922000000_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'sfirstOrFail()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
SmsMessagerow. 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 itGET /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. CustomersServicestays 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:tokenis now the credential andisLoggedInis token-based.customerIdis kept for display only and is no longer an identity claim.api/client.ts: sendsAuthorization: Bearer, throwsUnauthorizedErroron 401 and calls the handler registered inmain.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 testisLoggedIn.
Tests
apps/api/scripts/auth-smoke.shextended 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/mereturning the token's own customer.- jest installed (dev-only, user-approved) with
jest.config.cjs, sopnpm testworks for the first time: 7 passed coveringfaToEn.faToEnmoved to its owndigits.tsso 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.createOrdernow takescustomerIdas a separate argument, supplied by the controller from the token. The body'scustomerIdis ignored entirely; a mismatch is logged (OrdersServicewarn) and the token always wins. CreateOrderDto.customerIdand the sharedCreateOrderDtoare optional, so both apps keep validating while they still send it. Removed in Phase 2.- Reference:
ApiMobileController::setOrderreadauth()->id()(henza_server:533) behind theapi-customerguard — this restores that. - Admin path unaffected:
AdminOrdersServiceplaces 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/:idroutes had. A nonexistent id and someone else's id are indistinguishable.- Operators use the new
GET /orders/admin/:idbehind ridorder.list. Declared before:idbecause Nest matches in declaration order.
3. Both apps
CartView.vuein both apps now gates onsession.isLoggedIn(the token) rather than on a rememberedcustomerId, 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 afterbcrypt.compare. - SMS provider: none. No gateway is wired up (STUB-02). In development the
code is logged once via
Logger, guarded byNODE_ENV !== 'production'. - Fixed while auditing:
deliver()persisted the real message text — with the code in it — intoSmsMessage.body, which the admin SMS grid both lists and full-text searches (admin-misc.service.ts:135-136). Any operator with thesmsrid could read live OTP codes, defeating the point of hashingcodeHash. 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)
- Migrations run only through the standard Prisma workflow —
migrate devin development.prisma db executeis no longer used to apply schema changes. (It was used once, in the security hotfix, to create theCustomerOtptable; that is what the resolve below repairs.) - 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.phphas 151 live explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the 412, 109 are retail storefront, 15 areweb.phpdev/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_pwacomputespercent_pay,discountandrial_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_weightstays 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 realfname/lname(D0) andpercentnow readsmakingChargeOffsetPercentfrom the customer instead of the referring agent's commission rate (D9 / CUST-02).create()stores the name parts, and requirespercentwith the reference's own message,وارد کردن percent الزامی است— Laravel's stockrequiredtext (resources/lang/fa/validation.php:99) with no attribute translation forpercent, so it is verbatim.update()writes the parts (letting the trigger rebuildfullName) and persistspercent, which it previously ignored entirely.updateCredit()acceptedpercentin its signature and never wrote it — fixed.- Sort/filter aliases
fname/lnamepointed atfullName; they now point at the real columns, andpercentis 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 ofSms.php:57-82: POST, form-urlencoded, 20s timeout,PhoneNumber/Username/Password/Mobile/Message, body trimmed, every message persisted asSms::set()did. - Failure handling matches the reference:
send()never throws. The reference discardedSms::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 inSmsSendResult. - OTP text is byte-identical to
ApiMobileController.php:47, including the<#> code is …first line and the trailingaMo6iFyUQaY— the Android SMS Retriever hash. A unit test asserts the exact string. assertSmsConfigured()runs inmain.tsbefore 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.examplelists the six names with empty values;.envis 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— thesplit(' ')fallback is removed (D0 item 6).bulkCustomer.vue—percentdefaults 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;nameis only sent when a name part actually changed, so the whole name is no longer re-submitted on every save.ReagentCustomer.vue— unchanged. Its «درصد کمیسیون» isAdminUser.wagePercent, a genuinely different field fromcustomers.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
lnamechangeslastNameandfullName(trigger) and nothing else → changing onlypercentchanges exactly that column. The two-token first name «محمد علی» survives intact, which is the defect the owner originally reported. - Typecheck: api ✅ · admin-panel ✅.