feat(pricing): the server prices app orders with legacy's making-charge formula (MON-08)

Legacy: final % = variant % + customer % − the largest active promotion
on the product, its category or its brand; rial charge 0. The apps sent
the variant's own % only, so the new app undercharged by the customer's
percent (usually 3) and ignored promotions, and the server trusted
whatever the app sent.

- pricing/wage-formula.ts: one formula, legacy by default, with
  coefficients, promotion pick, constant, floor/cap and rounding so the
  admin screen can change it; validated
- an app order is priced by it; the app's wage, discount and rial charge
  are ignored; an operator's manual numbers are kept
- POST customer/me/wage-quote; both apps show that figure
- admin/pricing/wage-formula (config.wage): read, preview, approve with
  the sample it was checked on, last 20 versions kept; the setting is
  kept off the generic const routes

Owner approval: 2026-10-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-08 11:26:55 +03:30
co-authored by Claude Opus 5.5
parent 4b7b2473fc
commit 699657e483
21 changed files with 766 additions and 22 deletions
+19 -3
View File
@@ -5,6 +5,7 @@ import { CatalogService } from '../catalog/catalog.service';
import { LedgerService } from '../accounting/ledger.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
interface ComputedItem {
productVariantId: string;
@@ -25,6 +26,7 @@ export class OrdersService {
private readonly catalog: CatalogService,
private readonly ledger: LedgerService,
private readonly accounting: AccountingHooksService,
private readonly wage: WageService,
) {}
/**
@@ -65,12 +67,25 @@ export class OrdersService {
);
}
// MON-08 (owner ruling 2026-10-08): an app order is priced by the server's
// making-charge formula (pricing/wage-formula.ts — legacy's by default), the
// same figure the app showed. Whatever the app sends for wage, discount or
// rial charge is ignored, so a tampered request cannot set its own price.
// An operator ordering from the panel keeps the numbers they entered, as
// legacy's order builder did.
const quotes =
opts.placedBy === 'customer'
? await this.wage.quote(customerId, dto.items.map((i) => i.productVariantId))
: null;
const computedItems: ComputedItem[] = await Promise.all(
dto.items.map(async (item) => {
const variant = await this.catalog.getVariantOrThrow(item.productVariantId);
const rashWeight = new Prisma.Decimal(variant.rashWeightGrams);
const wagePercent = new Prisma.Decimal(item.wagePercent);
const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0);
const quote = quotes?.get(variant.id);
if (quotes && !quote) throw new NotFoundException(`Variant ${variant.id} not found`);
const wagePercent = new Prisma.Decimal(quote ? quote.wagePercentSnapshot : item.wagePercent);
const discountPercent = new Prisma.Decimal(quote ? quote.discountPercentSnapshot : (item.discountPercent ?? 0));
const netPercent = wagePercent.sub(discountPercent);
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
@@ -81,7 +96,8 @@ export class OrdersService {
wagePercentSnapshot: wagePercent,
discountPercentSnapshot: discountPercent,
computedWeightGrams,
flatRialCharge: new Prisma.Decimal(item.flatRialCharge ?? 0),
// Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one.
flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)),
};
}),
);