209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|