parity(api): port the reference price formula with tests; SMS + MON-08 reports
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c71574b1d2
commit
7b598e9e8e
@@ -529,3 +529,187 @@ The three unrelated `percent` meanings were already separated in
|
||||
`MIGRATION-STATE.md`. D13 confirms that **`ConfigStatic['percent']` (wallet
|
||||
cashback) is storefront and out of scope**, while **`customers.percent`
|
||||
(→ `makingChargeOffsetPercent`) is B2B and stays in scope**.
|
||||
|
||||
---
|
||||
|
||||
# Report — SMS provider (parity investigation, 2026-09-22)
|
||||
|
||||
Requested before implementing. **Nothing was implemented yet; awaiting approval.**
|
||||
|
||||
## 1. Which provider does the reference use?
|
||||
|
||||
**Trez SMS panel** (`smspanel.trez.ir`), called directly with Laravel's `Http`
|
||||
facade. There is **no package, no config file and no env var** — the endpoint and
|
||||
the credentials are hardcoded in one model.
|
||||
|
||||
| Item | Value | Where |
|
||||
|---|---|---|
|
||||
| Class | `App\Models\Sms` | `henza_server/app/Models/Sms.php` |
|
||||
| Live endpoint | `https://smspanel.trez.ir/SendMessageWithCode.ashx` | `Sms.php:69` (`Send`), `Sms.php:97` (`SendSms`) |
|
||||
| Method | `POST`, form-urlencoded, 20s timeout | `Sms.php:71-78` |
|
||||
| Form fields | `PhoneNumber` (sender line), `Username`, `Password`, `Mobile`, `Message` | `Sms.php:73-77` |
|
||||
| Persistence | `Sms::set()` writes every message to the `messages` table | `Sms.php:80,120-129` |
|
||||
| Queue | `SendSmsJob` / `SendSmsAfterCreate` exist, but `QUEUE_CONNECTION` defaults to `sync` (`config/queue.php:16`), so sends are inline | `app/Jobs/SendSmsJob.php:40` |
|
||||
|
||||
**A superseded provider is commented out above each call**: `0098sms.com`
|
||||
(`sendsmslink.aspx`, GET, sender line `3000164545`). Not ported — dead code.
|
||||
|
||||
WARNING — **the reference has live production credentials committed to git**
|
||||
(`Sms.php:74-76`: a panel username, a plaintext password, and a sender line).
|
||||
They are **not** copied into the target and are **not** repeated here. Logged as
|
||||
**SEC-12**. The owner should assume they are compromised and rotate them.
|
||||
|
||||
## 2. Message templates
|
||||
|
||||
`Sms::Send($to, $msg)` takes a fully-built string; there are no provider-side
|
||||
template ids. Lines are separated by a newline.
|
||||
|
||||
### OTP — the one this project needs
|
||||
|
||||
`ApiMobileController.php:47`:
|
||||
|
||||
```
|
||||
<#> code is {CODE}
|
||||
رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد {CODE} می باشد
|
||||
aMo6iFyUQaY
|
||||
```
|
||||
|
||||
The first line and the trailing `aMo6iFyUQaY` are the **Android SMS Retriever**
|
||||
hash — they must stay byte-identical or autofill breaks. This exact string is
|
||||
already in the target at `customer-auth.service.ts:219-222`, built and held
|
||||
ready, currently discarded via `void smsText`.
|
||||
|
||||
A second OTP variant exists for the web app (`WebApp/AuthController.php:21`),
|
||||
ending with the site domain and a hash. Not used by the B2B app.
|
||||
|
||||
### The other B2B messages (all in scope)
|
||||
|
||||
| # | Trigger | Text | Where |
|
||||
|---|---|---|---|
|
||||
| 1 | Customer created by admin | `هنزا گلد` / `همکار گرامی {fname} {lname}` / `نام کاربری : {mobile}` | `AdminController.php:380-383` |
|
||||
| 2 | Customer activated (`active == -1`) | `{name} عزیز ` / `حساب كاربري شما در اپليكيشن هنزا فعال شد.` / `هم اكنون مي توانيد محصولات جديد را بررسي و ثبت سفارش كنيد.` / `با مراجعه به لینک زیر می توانید وارد پروفایل کاربری خود شوید.` / `https://henzagold.com/customer/login` / `هنزا، راهكارهاي نوين تامين محصولات` | `AdminController.php:441-447` |
|
||||
| 3 | Financial document approved | `هنزا گلد` / `سند مالی شما توسط مدیر سیستم تایید شد.` | `AdminController.php:465`, `CashController.php:141` |
|
||||
| 4 | Financial document rejected | `هنزا گلد` / `سند مالی شما توسط مدیر سیستم رد شد.` | `CashController.php:144` |
|
||||
| 5 | Order approved | `هنزا گلد` / `سفارش شما با شماره سند {oId}تایید شد.` (no space before «تایید» in the reference) | `AdminController.php:522` |
|
||||
| 6 | Order placed — normal | `سفارش شما به شماره {id} به ثبت رسید. هنزا گلد` | `ApiMobileController.php:742` |
|
||||
| 7 | Order placed — over `gram_level` | `سفارش شما به شماره {id} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. هنزا گلد` | `ApiMobileController.php:739` |
|
||||
| 8 | Order placed by the sales expert for a customer | `هنزا گلد` / `همکار گرامی {fname} {lname}` / `سفارشی به وزن {allGram} گرم توسط آقای {reagentName} برای شما در سیستم ثبت شده است.` / `در صورت مورد تایید بودن سفارش بر روی لینک زیر کلیک نمایید.` / `{confirmLink}` | `ApiMobileController.php:733` |
|
||||
| 9 | To the customer's sales expert, on every order | `هنزا گلد` / `سفارشی توسط {fname} {lname} به شماره {id}و به وزن {allGram} به ثبت رسید.` | `ApiMobileController.php:749` |
|
||||
| 10 | To four hardcoded admin numbers, on every order | `هنزا گلد` / `سفارشی توسط {fname} {lname}` / ` به وزن {round(allGram)}` / `در سیستم ثبت شد.` | `ApiMobileController.php:601-605` |
|
||||
| 11 | To three hardcoded admin numbers, on findoc submit | `هنزا گلد` / `سندی توسط آقای / خانم {fname} {lname} در سیستم ثبت شد.لطفا نسبت به بررسی و تایید آن اقدام نمایید. ` | `ApiMobileController.php:154-158` |
|
||||
| 12 | Admin free-text / batch | operator-supplied | `SmsController.php:56,151` |
|
||||
|
||||
Messages 10 and 11 go to **phone numbers hardcoded in the source**
|
||||
(six different mobile numbers across `ApiMobileController.php:154-158,601-605`).
|
||||
Porting those literals would be wrong — they belong in configuration. Proposal:
|
||||
one env var holding a comma-separated list, defaulting to empty. Logged as
|
||||
**STUB-05**.
|
||||
|
||||
**Storefront messages are excluded** per D13: the cashback/wallet texts
|
||||
(`AdminController.php:921,951`), `Customer/OrderController.php:94,141-145`,
|
||||
`SingleCustomerController.php:66`, `Site/SiteController.php:182`,
|
||||
`birthdayCommand.php`, `SmsWalletCommand.php`.
|
||||
|
||||
## 3. Proposed env vars — names only, no values in code or git
|
||||
|
||||
| Name | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `SMS_PROVIDER` | no, default `trez` | selects the driver; `none` keeps today's dev behaviour |
|
||||
| `SMS_TREZ_URL` | no, defaults to the reference endpoint | provider endpoint |
|
||||
| `SMS_TREZ_USERNAME` | **yes in production** | panel username |
|
||||
| `SMS_TREZ_PASSWORD` | **yes in production** | panel password |
|
||||
| `SMS_TREZ_SENDER_LINE` | **yes in production** | the `PhoneNumber` form field |
|
||||
| `SMS_ADMIN_RECIPIENTS` | no, default empty | replaces the hardcoded admin numbers (STUB-05) |
|
||||
|
||||
Proposed behaviour, per the owner's instruction:
|
||||
|
||||
- **Key absent, `NODE_ENV !== 'production'`** — today's behaviour is unchanged:
|
||||
the code is logged once by `Logger`, a redacted audit row is written.
|
||||
- **Key absent, `NODE_ENV === 'production'`** — the server **fails to boot** with
|
||||
an explicit error naming the missing variable. No silent no-op, because a
|
||||
silent no-op means no customer can log in and nothing says why.
|
||||
- Secrets are read from `process.env` only; nothing is written to the repo, and
|
||||
`.env.example` carries the **names with empty values**.
|
||||
|
||||
**Awaiting approval before implementing.**
|
||||
|
||||
---
|
||||
|
||||
# Report — MON-08: where the pricing values come from (2026-09-22)
|
||||
|
||||
Requested: in `henza_pwa`, does the user type `wagePercent` / `discountPercent` /
|
||||
`flatRialCharge`, or does the app compute them?
|
||||
|
||||
## Answer: the app computes all of them. The user types none of them.
|
||||
|
||||
The whole order payload is built in one place,
|
||||
`henza_pwa/src/views/cart.vue:160-184` (`submitOrder`):
|
||||
|
||||
| Payload field | Target equivalent | Source | Evidence |
|
||||
|---|---|---|---|
|
||||
| `percent_pay` | `wagePercent` | `getWage(variant)` = `myStore.state.percent + variant.percentage_pay` | `cart.vue:173` -> `src/Mixins/product/index.ts:69-75` |
|
||||
| `discount` | `discountPercent` | `getPromotion(product)?.discount_percent ?? 0` | `cart.vue:170` |
|
||||
| `rial_pay` | `flatRialCharge` | **hardcoded `0`** — no UI writes it | `cart.vue:179` |
|
||||
| `rash_weight` | (target reads from DB) | `cart.variant(cartItem).grams` — the variant's own weight | `cart.vue:178` |
|
||||
| `qty` | `quantity` | the cart stepper — **the only genuine user input** | `cart.vue:177` |
|
||||
|
||||
Both computed inputs come from server-issued data:
|
||||
|
||||
- `variant.percentage_pay` — the variant's making charge, from the catalogue.
|
||||
- `myStore.state.percent` — **`customers.percent`**, delivered by `POST /me-data`
|
||||
and stored at `myDataStore.ts:29,59,122-127`. This is the same
|
||||
`makingChargeOffsetPercent` field of D9, and it **confirms that trace from the
|
||||
client side**: the offset is per-customer and it is added to the making charge.
|
||||
- `promotion.discount_percent` — from the promotion the server returned.
|
||||
|
||||
Two fields are sent as **literal constants with a `// how to get?!` comment**
|
||||
(`cart.vue:181-182`): `wage_weight: 5.7856000000000005` and
|
||||
`weight: 6.303744000000001`. The server ignores both — `setOrder` recomputes the
|
||||
weight from `rash_weight`, and the `wage_weight` use is commented out
|
||||
(`ApiMobileController.php:549-551`). Dead payload fields; logged as **DEAD-06**.
|
||||
|
||||
## What the server does with them
|
||||
|
||||
`ApiMobileController.php:536-541` computes the customer's **gold debt** directly
|
||||
from the numbers in the request:
|
||||
|
||||
```php
|
||||
$percent = ($item['percent_pay'] ?? 0) - ($item['discount'] ?? 0);
|
||||
$gram = ($item['rash_weight'] ?? 0) + (($item['rash_weight'] ?? 0) * ($percent / 100));
|
||||
return $gram * ($item['qty'] ?? 0);
|
||||
```
|
||||
|
||||
Nothing is re-read from the database and nothing is validated. A crafted request
|
||||
sets its own making charge, its own discount and its own weight — so the
|
||||
reference lets a customer decide how much gold they owe.
|
||||
|
||||
## Consequence — per the owner's ruling
|
||||
|
||||
The app computes these values, so the ruling applies:
|
||||
|
||||
> «اگر اپ حساب میکند: اولین کار Phase 2 این باشد که سرور همان مقادیر را با همان
|
||||
> منطق از DB بخواند و مقادیر کلاینت را نادیده بگیرد، با تست مقایسهای که نشان
|
||||
> دهد نتیجه برای کاربر عادی یکسان است.»
|
||||
|
||||
**Phase 2, item #1** (recorded in `PROGRESS.md`):
|
||||
|
||||
1. The server derives, per line:
|
||||
`wagePercent = customer.makingChargeOffsetPercent + variant.percentagePay`,
|
||||
`discountPercent` from the applicable promotion, `flatRialCharge = 0`.
|
||||
2. Client-sent values are **ignored**, and a warning is logged when they differ —
|
||||
the same pattern already used for `customerId` in `orders.service.ts`.
|
||||
3. **A comparison test** proves that for an ordinary customer the server-derived
|
||||
values equal what the app sends today, so the total is unchanged.
|
||||
4. The formula, its ordering and its rounding are untouched (D4).
|
||||
|
||||
This depends on `makingChargeOffsetPercent` existing, so the percent migration
|
||||
must land first.
|
||||
|
||||
## `rash_weight` — accepted deviation
|
||||
|
||||
The reference trusts the client's `rash_weight` (`ApiMobileController.php:538`);
|
||||
the target already reads the weight from the variant in the database. **Per the
|
||||
owner's ruling the target's behaviour stays.** Recorded here as a deliberate
|
||||
security deviation from the reference. The visible result is identical whenever
|
||||
the client sends the true variant weight, which the reference app always does
|
||||
(`cart.vue:178`); it differs only for a forged request, which is the point.
|
||||
|
||||
Reference in New Issue
Block a user