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:
s.esmaeili
2026-09-22 07:40:28 +03:30
co-authored by Claude Opus 5
parent c71574b1d2
commit 7b598e9e8e
7 changed files with 679 additions and 2 deletions
@@ -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);
});
});
});
@@ -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;
}