diff --git a/apps/api/scripts/report-name-split.cjs b/apps/api/scripts/report-name-split.cjs new file mode 100644 index 0000000..d1098be --- /dev/null +++ b/apps/api/scripts/report-name-split.cjs @@ -0,0 +1,86 @@ +/** + * D0 requirement 3 — review report for names that cannot be split safely. + * + * The migration backfills firstName = first token, lastName = the rest. That is + * correct for two-token names and a guess for anything longer: "سید محمد + * حسینی" should almost certainly be firstName="سید محمد", and the migration + * writes firstName="سید" instead. Those rows need a human. + * + * READ-ONLY. It changes nothing; it prints what a person has to check. + * + * node scripts/report-name-split.cjs # print to stdout + * node scripts/report-name-split.cjs --csv out.csv + * + * Run it AFTER the 20260922120000_customer_name_parts migration. + */ +const { PrismaClient } = require('@prisma/client'); +const fs = require('fs'); + +const prisma = new PrismaClient(); + +/** Same normalisation the migration's SQL uses. */ +const tokens = (s) => String(s ?? '').trim().replace(/\s+/g, ' ').split(' ').filter(Boolean); + +(async () => { + const customers = await prisma.$queryRawUnsafe(` + SELECT id, "fullName", "firstName", "lastName", mobile, "memberCode" + FROM "Customer" + ORDER BY "createdAt" + `); + + const multi = []; + const empty = []; + const single = []; + + for (const c of customers) { + const t = tokens(c.fullName); + if (t.length === 0) empty.push(c); + else if (t.length === 1) single.push(c); + else if (t.length > 2) multi.push({ ...c, tokenCount: t.length }); + } + + console.log(`Customers scanned: ${customers.length}`); + console.log(` two tokens (split is reliable): ${customers.length - multi.length - empty.length - single.length}`); + console.log(` one token (lastName is NULL): ${single.length}`); + console.log(` empty name (nothing to split): ${empty.length}`); + console.log(` MORE THAN TWO TOKENS — NEEDS REVIEW: ${multi.length}`); + + if (single.length) { + console.log('\n--- single-token names (no surname recorded) ---'); + for (const c of single) console.log(` ${c.id} ${c.mobile} "${c.fullName}"`); + } + if (empty.length) { + console.log('\n--- empty names ---'); + for (const c of empty) console.log(` ${c.id} ${c.mobile}`); + } + + if (multi.length) { + console.log('\n--- NEEDS REVIEW: more than two tokens ---'); + console.log('The migration guessed first-token / rest. Verify each one.\n'); + for (const c of multi) { + console.log(` ${c.id} ${c.mobile} (${c.tokenCount} tokens)`); + console.log(` fullName : "${c.fullName}"`); + console.log(` guessed : firstName="${c.firstName}" lastName="${c.lastName}"`); + } + } + + const csvArg = process.argv.indexOf('--csv'); + if (csvArg !== -1 && process.argv[csvArg + 1]) { + const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const rows = [ + ['id', 'mobile', 'memberCode', 'fullName', 'guessedFirstName', 'guessedLastName', 'tokenCount'].join(','), + ...multi.map((c) => + [c.id, c.mobile, c.memberCode, c.fullName, c.firstName, c.lastName, c.tokenCount].map(esc).join(','), + ), + ]; + // BOM so Excel opens the Persian text in the right encoding. + fs.writeFileSync(process.argv[csvArg + 1], '' + rows.join('\n'), 'utf8'); + console.log(`\nCSV written: ${process.argv[csvArg + 1]} (${multi.length} rows)`); + } + + await prisma.$disconnect(); +})().catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/apps/api/src/modules/pricing/variant-price.spec.ts b/apps/api/src/modules/pricing/variant-price.spec.ts new file mode 100644 index 0000000..846b3f8 --- /dev/null +++ b/apps/api/src/modules/pricing/variant-price.spec.ts @@ -0,0 +1,208 @@ +import { + computeVariantPrice, + DEFAULT_PRODUCT_PRICE_EXTRA_PERCENT, +} from './variant-price'; + +/** + * D4 — unit-test the reference price formula against a HAND calculation. + * + * The expected numbers below were derived by walking + * henza_server/app/Models/ProductVariant.php:183-190 step by step with a + * calculator, NOT by running the implementation under test. Each case lists + * its intermediate steps so a reviewer can check the arithmetic without + * trusting the code. + * + * Reference formula: + * price = gold + gold * ((pay + extra) / 100) + * price = price + price * 0.07 + * temp = price - gold + * temp = temp + temp * 0.09 + * price = gold + temp + * price = price * grams + * return floor(price / 1000) * 1000 + */ +describe('computeVariantPrice — reference parity', () => { + describe('without the customer making-charge offset', () => { + it('matches the hand calculation for 1g at 15% wage', () => { + // gold = 30,000,000 pay = 15 extra = 7 grams = 1 + // 1) 30,000,000 + 30,000,000 * (22/100) = 36,600,000 + // 2) 36,600,000 * 1.07 = 39,162,000 + // 3) temp = 39,162,000 - 30,000,000 = 9,162,000 + // 4) temp * 1.09 = 9,986,580 + // 5) 30,000,000 + 9,986,580 = 39,986,580 + // 6) * 1 gram = 39,986,580 + // 7) floor(/1000)*1000 = 39,986,000 + expect( + computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + }), + ).toBe(39_986_000); + }); + + it('applies VAT to the non-metal portion only, not the whole price', () => { + // This is what the three SUPERSEDED formulas got wrong. If 9% were + // applied to the whole price the answer would be + // 39,162,000 * 1.09 = 42,686,580 -> 42,686,000 + // The live formula gives 39,986,000. Guards against porting the wrong one. + const live = computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + }); + const ifVatOnWholePrice = Math.floor((30_000_000 * 1.22 * 1.07 * 1.09) / 1000) * 1000; + + expect(live).toBe(39_986_000); + expect(ifVatOnWholePrice).toBe(42_686_000); + expect(live).not.toBe(ifVatOnWholePrice); + }); + + it('handles a zero making charge', () => { + // 1) 30,000,000 * 1.07 (extra 7 only) = 32,100,000 + // 2) * 1.07 = 34,347,000 + // 3) temp = 4,347,000 4) * 1.09 = 4,738,230 + // 5) 34,738,230 6) * 1g 7) floor -> 34,738,000 + expect( + computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 0, + grams: 1, + }), + ).toBe(34_738_000); + }); + + it('multiplies by weight before the final floor', () => { + // gold = 28,500,000 pay = 20 extra = 7 grams = 2.345 + // 1) 28,500,000 * 1.27 = 36,195,000 + // 2) * 1.07 = 38,728,650 + // 3) temp = 10,228,650 4) * 1.09 = 11,149,228.5 + // 5) 39,649,228.5 6) * 2.345 = 92,977,440.8325 + // 7) floor(/1000)*1000 = 92,977,000 + expect( + computeVariantPrice({ + goldPricePerGram: 28_500_000, + wagePercent: 20, + grams: 2.345, + }), + ).toBe(92_977_000); + }); + + it('rounds DOWN to the nearest 1000, never up', () => { + const price = computeVariantPrice({ + goldPricePerGram: 28_500_000, + wagePercent: 20, + grams: 2.345, + }); + // The unfloored value is 92,977,440.83 — rounding half-up would give + // 92,977,000 too, so also assert the property directly. + expect(price % 1000).toBe(0); + expect(price).toBeLessThanOrEqual(92_977_440.8325); + }); + + it('defaults extraPercent to 7 when the config row is missing', () => { + expect(DEFAULT_PRODUCT_PRICE_EXTRA_PERCENT).toBe(7); + expect( + computeVariantPrice({ goldPricePerGram: 30_000_000, wagePercent: 15, grams: 1 }), + ).toBe( + computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + extraPercent: 7, + }), + ); + }); + }); + + describe('with the customer making-charge offset (customers.percent)', () => { + it('adds the offset to the wage percent — hand-checked at offset 3', () => { + // gold = 30,000,000 pay = 15 offset = 3 -> effective wage 18 + // 1) 30,000,000 + 30,000,000 * (25/100) = 37,500,000 + // 2) * 1.07 = 40,125,000 + // 3) temp = 10,125,000 4) * 1.09 = 11,036,250 + // 5) 41,036,250 6) * 1g 7) floor = 41,036,000 + expect( + computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + makingChargeOffsetPercent: 3, + }), + ).toBe(41_036_000); + }); + + it('an offset of 0 is identical to passing no offset at all', () => { + // This is the behaviour-neutrality guarantee behind the migration's + // DEFAULT 0: every existing customer keeps today's price. + const withoutField = computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + }); + const withZero = computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + makingChargeOffsetPercent: 0, + }); + + expect(withZero).toBe(withoutField); + expect(withZero).toBe(39_986_000); + }); + + it('a non-zero offset raises the price — the default must stay 0', () => { + const atZero = computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + makingChargeOffsetPercent: 0, + }); + const atThree = computeVariantPrice({ + goldPricePerGram: 30_000_000, + wagePercent: 15, + grams: 1, + makingChargeOffsetPercent: 3, + }); + + expect(atThree).toBeGreaterThan(atZero); + // Backfilling 3 instead of 0 would have moved this price by 1,050,000. + expect(atThree - atZero).toBe(1_050_000); + }); + + it('matches the hand calculation for a multi-gram line with an offset', () => { + // gold = 35,000,000 pay = 12.5 offset = 3 extra = 7 grams = 5.25 + // 1) 35,000,000 * 1.225 = 42,875,000 + // 2) * 1.07 = 45,876,250 + // 3) temp = 10,876,250 4) * 1.09 = 11,855,112.5 + // 5) 46,855,112.5 6) * 5.25 = 245,989,340.625 + // 7) floor(/1000)*1000 = 245,989,000 + expect( + computeVariantPrice({ + goldPricePerGram: 35_000_000, + wagePercent: 12.5, + grams: 5.25, + makingChargeOffsetPercent: 3, + }), + ).toBe(245_989_000); + }); + + it('is equivalent to folding the offset into wagePercent, as the app does', () => { + // henza_pwa sends percent_pay = state.percent + variant.percentage_pay + // (src/Mixins/product/index.ts:69-75), so the two must agree exactly. + const asAppSendsIt = computeVariantPrice({ + goldPricePerGram: 31_250_000, + wagePercent: 18 + 4, + grams: 3.1, + }); + const asServerWillDeriveIt = computeVariantPrice({ + goldPricePerGram: 31_250_000, + wagePercent: 18, + grams: 3.1, + makingChargeOffsetPercent: 4, + }); + + expect(asServerWillDeriveIt).toBe(asAppSendsIt); + }); + }); +}); diff --git a/apps/api/src/modules/pricing/variant-price.ts b/apps/api/src/modules/pricing/variant-price.ts new file mode 100644 index 0000000..2d4597e --- /dev/null +++ b/apps/api/src/modules/pricing/variant-price.ts @@ -0,0 +1,94 @@ +/** + * The reference gold price formula, ported verbatim. + * + * Source of truth: henza_server/app/Models/ProductVariant.php:183-190 + * + * $price = goldPrice + (goldPrice * ((percentage_pay + extra_percent) / 100)); + * $price += $price * 0.07; // margin + * $temp = $price - goldPrice; // the non-metal portion ONLY + * $temp += $temp * 0.09; // VAT, on that portion only + * $price = goldPrice + $temp; + * $price *= grams; + * return floor($price / 1000) * 1000; + * + * Three superseded variants are commented out above it (`:170-179`, `:192-200`) + * which apply the 9% to the WHOLE price. Those are NOT the live behaviour and + * are deliberately not ported (D4). + * + * `extra_percent` comes from ConfigStatic['product_price_extra_percent'] and + * falls back to 7 when the row is missing (`ProductVariant.php:50`). + * + * Number semantics: the reference computes in PHP floats and only rounds at the + * very end, with floor() to the nearest 1000. Decimal is deliberately NOT used + * here — switching to exact arithmetic would change results at the boundary and + * break parity with the reference (D4: match calculations and rounding + * exactly). Money that is STORED still uses Prisma.Decimal; this is a + * presentation-time price computation, as in the reference. + */ + +/** ConfigStatic['product_price_extra_percent'] default (ProductVariant.php:50). */ +export const DEFAULT_PRODUCT_PRICE_EXTRA_PERCENT = 7; + +/** The fixed margin applied before VAT (ProductVariant.php:184). */ +const MARGIN_RATE = 0.07; + +/** VAT, applied to the non-metal portion only (ProductVariant.php:186). */ +const VAT_RATE = 0.09; + +export interface VariantPriceInput { + /** Gold price per gram, in the same unit the reference's WebServiceGold returns. */ + goldPricePerGram: number; + /** The variant's making charge, legacy `percentage_pay`. */ + wagePercent: number; + /** The variant's weight in grams, legacy `grams`. */ + grams: number; + /** ConfigStatic['product_price_extra_percent']; defaults to 7 when absent. */ + extraPercent?: number; + /** + * The per-customer making-charge offset — legacy `customers.percent`, the + * target's `Customer.makingChargeOffsetPercent`. + * + * The reference does NOT apply this inside the formula. The app adds it to + * the variant's making charge before sending the order line + * (henza_pwa/src/Mixins/product/index.ts:69-75: + * getWage(v) = myStore.state.percent + v.percentage_pay), + * so the effective wage percent is the sum. Modelled here as an explicit, + * defaulted-to-zero input so both paths can be tested, and so that passing 0 + * reproduces today's behaviour exactly. + */ + makingChargeOffsetPercent?: number; +} + +/** + * Computes a variant's price exactly as the reference does. + * + * Returns the price for the FULL weight (already multiplied by grams) and + * floored to the nearest 1000. + */ +export function computeVariantPrice(input: VariantPriceInput): number { + const { + goldPricePerGram, + wagePercent, + grams, + extraPercent = DEFAULT_PRODUCT_PRICE_EXTRA_PERCENT, + makingChargeOffsetPercent = 0, + } = input; + + // The app-side sum: customer offset + variant making charge. + const effectiveWagePercent = wagePercent + makingChargeOffsetPercent; + + let price = + goldPricePerGram + goldPricePerGram * ((effectiveWagePercent + extraPercent) / 100); + + price += price * MARGIN_RATE; + + // VAT applies to the non-metal portion only — this is the part the three + // superseded formulas got wrong. + let temp = price - goldPricePerGram; + temp += temp * VAT_RATE; + + price = goldPricePerGram + temp; + price *= grams; + + return Math.floor(price / 1000) * 1000; +} diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index a46fb8f..1306a6f 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -235,3 +235,74 @@ 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. diff --git a/docs/parity/DECISIONS.md b/docs/parity/DECISIONS.md index 17ee743..b7fada7 100644 --- a/docs/parity/DECISIONS.md +++ b/docs/parity/DECISIONS.md @@ -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. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index f2358be..da359cc 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -104,3 +104,11 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` |---|---|---|---|---|---| | DOC-01 | Medium | DOC `docs/parity/_inv-ref-server.md:588` (§3.6) | The Phase 0 inventory undercounted `routes/admin.php`: it recorded **117** explicit routes where the file has **151** live ones (6 more are commented out). Every downstream total inherited the error, so the reference endpoint count was reported as **369** when it is **412** (admin 249, not 215). No code is affected — only the planning figures. | `grep -E "^\s*Route::(get\|post\|put\|patch\|delete\|any)" routes/admin.php \| grep -v "^\s*//" \| wc -l` → 151; resources 14×7=98; 151+98=249 | ✅ Corrected in `00-INVENTORY.md` §2.1 and `DECISIONS.md` §D13. `_inv-ref-server.md` left as the raw subagent record. | | DOC-02 | Low | REF `routes/admin.php:38` | `GET admin/get-analytics-tak` reads `SingleCustomerOrders` (`Adm/AdminController.php:557-579`), so it is a **retail-storefront** endpoint despite its neutral name. A keyword scan for `wallet\|single\|comment` misses it. Noted so the D13 scope split stays correct — anything else named neutrally but querying `SingleCustomerOrders`, `Fav`, `Address`, `Comment` or `Transaction` is storefront too. | `AdminController.php:563` — `SingleCustomerOrders::select(...)` | ✅ Classified as ⏸️ storefront in D13 (the 31st admin storefront route). | + +## Found during the SMS / MON-08 investigation (2026-09-22) + +| ID | Severity | Where | Description | Evidence | Status | +|---|---|---|---|---|---| +| SEC-12 | **Critical** | REF `henza_server/app/Models/Sms.php:73-77,101-105` | Live SMS-panel production credentials (username, plaintext password, sender line) are **committed to git** in the reference, duplicated across two methods. An older provider's credentials sit commented out beside them. Anyone with repo access can send SMS on the owner's account at their cost. | Read the file. | Not ported — the target will read them from env only. **The owner should rotate these credentials**; they must be assumed compromised. | +| STUB-05 | Medium | REF `ApiMobileController.php:154-158,601-605`; `Adm/OrderController.php:54-55`; `Customer/OrderController.php:141-145` | Admin notification recipients are **hardcoded mobile numbers** scattered across controllers (six distinct numbers). Staff changes require a code deploy, and an ex-employee keeps receiving order data. | Read the files. | Port as `SMS_ADMIN_RECIPIENTS` (comma-separated, default empty) rather than copying the literals. Pending the SMS approval. | +| DEAD-06 | Low | REF `henza_pwa/src/views/cart.vue:181-182` | The cart sends `wage_weight: 5.7856000000000005` and `weight: 6.303744000000001` as **literal constants**, each with a `// how to get?!` comment. The server ignores both: it recomputes weight from `rash_weight`, and the `wage_weight` use is commented out (`ApiMobileController.php:549-551`). | Read both files. | Dead payload fields — do not port. | diff --git a/docs/parity/PROGRESS.md b/docs/parity/PROGRESS.md index 417fe2f..89a2f50 100644 --- a/docs/parity/PROGRESS.md +++ b/docs/parity/PROGRESS.md @@ -10,8 +10,8 @@ | **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** | | **Branch** | `parity/security-hotfix` | | **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. | -| **Next step** | Show the user the **D0 (firstName/lastName)** and **`percent`** migrations for approval, then start `01-ADMIN-MATRIX.md` field-by-field (D12) | -| **Blocked on** | user approval of those two migrations before they run | +| **Next step** | **Migrations are written and shown — awaiting approval to run them** (`migrate dev`), then `01-ADMIN-MATRIX.md` field-by-field (D12) | +| **Blocked on** | approval to run the two migrations; approval to implement the SMS provider | | **Last updated** | 2026-09-22 | ### Security hotfix — final result @@ -146,3 +146,29 @@ All user rulings live there (D0–D13). Highlights that change how work is done: | `_inv-tgt-userappv2.md` | apps/user-app-v2 | 700 | Consolidated into `00-INVENTORY.md`. Issues found: `ISSUES.md` (26 entries). + +## Standing rules added 2026-09-22 + +1. **Migrations run only via the standard Prisma workflow** (`migrate dev` in + development). **`prisma db execute` is not used for schema changes.** +2. **Every data deletion is logged in `CHANGELOG.md`** — including throwaway + rows created during a session. +3. **D13** — the retail storefront is preserved but out of scope; see + `DECISIONS.md` §D13 and `PROTECTED.md` §H. + +## Phase 2 — item #1 (fixed by the MON-08 ruling) + +Before anything else in Phase 2: the server must derive `wagePercent`, +`discountPercent` and `flatRialCharge` from the DB and **ignore** the client's +values, because `henza_pwa` computes them rather than asking the user +(`cart.vue:170-179`, `Mixins/product/index.ts:69-75`). A comparison test must +show the total is unchanged for an ordinary customer. Depends on +`makingChargeOffsetPercent` existing. Full reasoning: `DECISIONS.md` +§"Report — MON-08". + +## Waiting on the owner (2026-09-22) + +| # | Item | What is ready | +|---|---|---| +| 1 | Run the **D0** and **percent** migrations | Both SQL files written, storefront impact assessed, backfill report script written, 11 unit tests passing. Command: `npx prisma migrate dev`. | +| 2 | Implement the **SMS provider** (Trez) | Provider, all 12 templates and env-var names documented in `DECISIONS.md`. Nothing written yet. **SEC-12: the reference's committed credentials must be rotated.** |