diff --git a/apps/api/prisma/migrations/20261008150000_wage_formula_permission/migration.sql b/apps/api/prisma/migrations/20261008150000_wage_formula_permission/migration.sql new file mode 100644 index 0000000..a9e4b00 --- /dev/null +++ b/apps/api/prisma/migrations/20261008150000_wage_formula_permission/migration.sql @@ -0,0 +1,9 @@ +-- «اجرت» admin screen (owner ruling 2026-10-08): its permission for the +-- seeded super-admin user type. A fresh seed grants every rid in ALL_RIDS; +-- an existing database gets the new one here. Data only, idempotent. +-- The formula itself needs no row: until an admin approves one, the server +-- uses legacy's formula (pricing/wage-formula.ts LEGACY_WAGE_FORMULA). +INSERT INTO "permission" ("rid", "userTypeId") +SELECT 'config.wage', 85 +WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85) +ON CONFLICT DO NOTHING; diff --git a/apps/api/src/modules/admin-misc/admin-misc.controller.ts b/apps/api/src/modules/admin-misc/admin-misc.controller.ts index 2e6a46d..4cc2dfa 100644 --- a/apps/api/src/modules/admin-misc/admin-misc.controller.ts +++ b/apps/api/src/modules/admin-misc/admin-misc.controller.ts @@ -14,6 +14,7 @@ import { TableQueryDto } from '../admin-table/table-query.dto'; import { CurrentAdmin, RequirePermission } from '../auth/guards'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; import { isAccountingSettingKey } from '../accounting-sync/accounting-config'; +import { PRICING_SETTING_PREFIX } from '../pricing/wage.service'; /** * Cities, SMS, notifications, newsletter, activity log and settings — the @@ -228,7 +229,7 @@ export class AdminMiscController { // `custom.config.statics.slider`) simply by calling `const/mobile_main_slider` // directly, since the underlying Setting row is keyed the same either way. private assertNotSliderKey(key: string) { - if (key === 'mobile_main_slider' || isAccountingSettingKey(key)) { + if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) { throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است'); } } diff --git a/apps/api/src/modules/admin-misc/admin-misc.service.ts b/apps/api/src/modules/admin-misc/admin-misc.service.ts index b0a41e2..58d7f89 100644 --- a/apps/api/src/modules/admin-misc/admin-misc.service.ts +++ b/apps/api/src/modules/admin-misc/admin-misc.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config'; +import { PRICING_SETTING_PREFIX } from '../pricing/wage.service'; /** * The remaining admin screens — legacy views/city, views/sms, views/notify, @@ -482,7 +483,7 @@ export class AdminMiscService { // lease) have their own `config.accounting` screen and never appear here // (D27); the single-key routes refuse them in the controller. const rows = await this.prisma.setting.findMany({ - where: { NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, + where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] }, orderBy: { key: 'asc' }, }); return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt })); diff --git a/apps/api/src/modules/auth/acl.constants.ts b/apps/api/src/modules/auth/acl.constants.ts index 0b09b8d..e154f99 100644 --- a/apps/api/src/modules/auth/acl.constants.ts +++ b/apps/api/src/modules/auth/acl.constants.ts @@ -34,6 +34,7 @@ export const ALL_RIDS = [ 'comment.view.edit', // D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid. 'config.accounting', + 'config.wage', 'config.report', 'config.statics', 'config.statics.brand', diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts index d7316d1..00237c7 100644 --- a/apps/api/src/modules/orders/orders.module.ts +++ b/apps/api/src/modules/orders/orders.module.ts @@ -3,9 +3,10 @@ import { OrdersService } from './orders.service'; import { OrdersController } from './orders.controller'; import { CatalogModule } from '../catalog/catalog.module'; import { AccountingModule } from '../accounting/accounting.module'; +import { PricingModule } from '../pricing/pricing.module'; @Module({ - imports: [CatalogModule, AccountingModule], + imports: [CatalogModule, AccountingModule, PricingModule], controllers: [OrdersController], providers: [OrdersService], exports: [OrdersService], diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index 75ebda9..395de63 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -30,7 +30,8 @@ describe('OrdersService.createOrder — sales expert credit', () => { }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; const accounting = { orderStatusReached: jest.fn() }; - const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never); + const wage = { quote: jest.fn() }; + const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never); return { service, created }; } @@ -49,6 +50,11 @@ describe('OrdersService.createOrder — sales expert credit', () => { }); }); +/** A server quote of 10% for every variant. */ +const wageStub = { + quote: jest.fn(async (_c: string, ids: string[]) => new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '0' }]))), +}; + /** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */ describe('OrdersService.createOrder — order ban', () => { function build(canPlaceOrders: boolean) { @@ -66,7 +72,7 @@ describe('OrdersService.createOrder — order ban', () => { $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; - return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never); + return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never); } const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; @@ -82,3 +88,34 @@ describe('OrdersService.createOrder — order ban', () => { await expect(build(true).createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' }); }); }); + +/** MON-08 (owner ruling 2026-10-08): an app order is priced by the server, never by the app. */ +describe('OrdersService.createOrder — server-side making charge', () => { + function build() { + const created: Record[] = []; + const tx = { order: { create: jest.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: 'o1', ...data }; }) } }; + const prisma = { + customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) }, + $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }; + const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; + const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never); + return { service, created }; + } + // A tampered request: zero making charge, a 50% discount and a rial charge. + const tampered = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 0, discountPercent: 50, flatRialCharge: 999 }] } as never; + + it('ignores what the app sends and uses the server quote', async () => { + const { service, created } = build(); + await service.createOrder(tampered, 'c1', { placedBy: 'customer' }); + expect(created[0].totalGoldWeightGrams.toString()).toBe('11'); + expect(created[0].flatRialCharge.toString()).toBe('0'); + }); + + it('keeps the numbers an operator entered in the panel', async () => { + const { service, created } = build(); + await service.createOrder(tampered, 'c1', { placedBy: 'operator' }); + expect(created[0].totalGoldWeightGrams.toString()).toBe('5'); + expect(created[0].flatRialCharge.toString()).toBe('999'); + }); +}); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index 8386f08..bd4c982 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -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)), }; }), ); diff --git a/apps/api/src/modules/pricing/pricing.module.ts b/apps/api/src/modules/pricing/pricing.module.ts index 6688c69..7d1c42f 100644 --- a/apps/api/src/modules/pricing/pricing.module.ts +++ b/apps/api/src/modules/pricing/pricing.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { PricingService } from './pricing.service'; import { PricingController } from './pricing.controller'; +import { WageService } from './wage.service'; +import { WageAdminController, WageCustomerController } from './wage.controller'; @Module({ - controllers: [PricingController], - providers: [PricingService], - exports: [PricingService], + controllers: [PricingController, WageAdminController, WageCustomerController], + providers: [PricingService, WageService], + exports: [PricingService, WageService], }) export class PricingModule {} diff --git a/apps/api/src/modules/pricing/wage-formula.spec.ts b/apps/api/src/modules/pricing/wage-formula.spec.ts new file mode 100644 index 0000000..cd5a87a --- /dev/null +++ b/apps/api/src/modules/pricing/wage-formula.spec.ts @@ -0,0 +1,54 @@ +import { BadRequestException } from '@nestjs/common'; +import { computeSample, computeWage, LEGACY_WAGE_FORMULA, parseWageFormula, type WageFormula } from './wage-formula'; + +/** + * The making-charge formula. The default must reproduce legacy exactly: + * final % = variant % + customer % − largest active promotion + * weight = rash × (1 + final/100) × qty + */ +describe('wage formula', () => { + const legacy = LEGACY_WAGE_FORMULA; + + it('legacy: variant + customer, no promotion', () => { + const w = computeWage(legacy, { variantPercent: '7', customerPercent: '3', promotionPercents: [] }); + expect(w.finalPercent).toBe('10'); + expect(w.wagePercentSnapshot).toBe('10'); + expect(w.discountPercentSnapshot).toBe('0'); + }); + + it('legacy: the LARGEST promotion is subtracted, not the sum', () => { + const w = computeWage(legacy, { variantPercent: '7', customerPercent: '3', promotionPercents: ['1', '2.5', '2'] }); + expect(w.promotionPercent).toBe('2.5'); + expect(w.finalPercent).toBe('7.5'); + // Stored so that wage − discount = final, which is what createOrder multiplies by. + expect(w.wagePercentSnapshot).toBe('10'); + expect(w.discountPercentSnapshot).toBe('2.5'); + }); + + it('legacy sample weight matches setOrder: 10 g × (1 + 10%) = 11 g', () => { + const r = computeSample(legacy, { rashWeightGrams: '10', quantity: 1, variantPercent: '7', customerPercent: '3', promotionPercent: '0' }); + expect(r.totalGrams).toBe('11'); + expect(r.wageGrams).toBe('1'); + }); + + it('a changed structure: no customer term, sum of promotions, +0.5, floor 5, 2 decimals', () => { + const f: WageFormula = { ...legacy, customerCoef: '0', promotionPick: 'sum', constant: '0.5', min: '5', decimals: 2 }; + expect(computeWage(f, { variantPercent: '6', customerPercent: '3', promotionPercents: ['1', '1'] }).finalPercent).toBe('5'); + const w = computeWage(f, { variantPercent: '7.333', customerPercent: '3', promotionPercents: [] }); + expect(w.finalPercent).toBe('7.83'); + expect(w.limitedBy).toBeNull(); + }); + + it('the cap is reported when it bites', () => { + const w = computeWage({ ...legacy, max: '8' }, { variantPercent: '7', customerPercent: '3', promotionPercents: [] }); + expect(w).toMatchObject({ finalPercent: '8', limitedBy: 'max', beforeLimits: '10' }); + }); + + it('rejects nonsense from the admin form', () => { + expect(() => parseWageFormula({ ...legacy, variantCoef: 'abc' })).toThrow(BadRequestException); + expect(() => parseWageFormula({ ...legacy, min: '9', max: '8' })).toThrow(BadRequestException); + expect(() => parseWageFormula({ ...legacy, promotionPick: 'avg' })).toThrow(BadRequestException); + expect(() => parseWageFormula({ ...legacy, decimals: 9 })).toThrow(BadRequestException); + expect(parseWageFormula({ ...legacy })).toEqual(legacy); + }); +}); diff --git a/apps/api/src/modules/pricing/wage-formula.ts b/apps/api/src/modules/pricing/wage-formula.ts new file mode 100644 index 0000000..74058c5 --- /dev/null +++ b/apps/api/src/modules/pricing/wage-formula.ts @@ -0,0 +1,211 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +/** + * The making-charge («اجرت») formula — one place, used to price every order + * line placed from the app and to show the same figure in the app beforehand + * (owner ruling 2026-10-08: the number shown must be the number charged, and + * nothing the app sends is trusted). + * + * Legacy, reconstructed from henza_pwa + ApiMobileController::setOrder: + * percent_pay = customer.percent + variant.percentage_pay (Mixins/product getWage) + * discount = the LARGEST active promotion for the product, + * its category or its brand (getPromotion; customer-type off) + * final % = percent_pay − discount (setOrder :558) + * weight = rash × (1 + final/100) × qty + * rial charge = 0 (cart.vue: rial_pay: 0) + * + * The admin «اجرت» screen may change the numbers AND the structure: each term + * has a coefficient (0 switches it off), the promotion can be the largest or + * the sum, a constant can be added, and the result can be floored, capped and + * rounded. LEGACY_WAGE_FORMULA reproduces legacy exactly. + */ +export interface WageFormula { + version: 1; + /** × the variant's own making charge (legacy percentage_pay). */ + variantCoef: string; + /** × the customer's own percent (legacy customers.percent). */ + customerCoef: string; + /** × the promotion discount, SUBTRACTED. */ + promotionCoef: string; + /** Which promotion counts when several apply: legacy takes the largest. */ + promotionPick: 'max' | 'sum'; + /** Added as is, in percent. */ + constant: string; + /** Floor / cap on the final percent; null = none (legacy has neither). */ + min: string | null; + max: string | null; + /** Round the final percent to this many decimals; null = none (legacy). */ + decimals: number | null; +} + +export const LEGACY_WAGE_FORMULA: WageFormula = Object.freeze({ + version: 1, + variantCoef: '1', + customerCoef: '1', + promotionCoef: '1', + promotionPick: 'max', + constant: '0', + min: null, + max: null, + decimals: null, +}); + +export interface WageInputs { + variantPercent: Prisma.Decimal.Value; + customerPercent: Prisma.Decimal.Value; + /** Every active promotion that applies, in percent. */ + promotionPercents: Prisma.Decimal.Value[]; +} + +export interface WageBreakdown { + variantTerm: string; + customerTerm: string; + /** The promotion percent picked (largest or sum) before its coefficient. */ + promotionPercent: string; + promotionTerm: string; + constant: string; + beforeLimits: string; + /** Which limit changed the result, if any. */ + limitedBy: 'min' | 'max' | null; + finalPercent: string; + /** + * What the order line stores. OrdersService computes + * weight = rash × (1 + (wage − discount)/100), so wage = final + discount term + * and discount = discount term keeps that product equal to `finalPercent`. + */ + wagePercentSnapshot: string; + discountPercentSnapshot: string; +} + +const D = (v: Prisma.Decimal.Value | null | undefined) => new Prisma.Decimal(v ?? 0); + +export function computeWage(f: WageFormula, i: WageInputs): WageBreakdown { + const promos = i.promotionPercents.map(D); + const promo = + promos.length === 0 + ? D(0) + : f.promotionPick === 'sum' + ? promos.reduce((s, p) => s.add(p), D(0)) + : promos.reduce((m, p) => (p.gt(m) ? p : m), promos[0]); + + const variantTerm = D(f.variantCoef).mul(D(i.variantPercent)); + const customerTerm = D(f.customerCoef).mul(D(i.customerPercent)); + const promotionTerm = D(f.promotionCoef).mul(promo); + const constant = D(f.constant); + const before = variantTerm.add(customerTerm).sub(promotionTerm).add(constant); + + let final = before; + let limitedBy: WageBreakdown['limitedBy'] = null; + if (f.min !== null && final.lt(D(f.min))) { + final = D(f.min); + limitedBy = 'min'; + } + if (f.max !== null && final.gt(D(f.max))) { + final = D(f.max); + limitedBy = 'max'; + } + if (f.decimals !== null) final = final.toDecimalPlaces(f.decimals, Prisma.Decimal.ROUND_HALF_UP); + + return { + variantTerm: variantTerm.toString(), + customerTerm: customerTerm.toString(), + promotionPercent: promo.toString(), + promotionTerm: promotionTerm.toString(), + constant: constant.toString(), + beforeLimits: before.toString(), + limitedBy, + finalPercent: final.toString(), + wagePercentSnapshot: final.add(promotionTerm).toString(), + discountPercentSnapshot: promotionTerm.toString(), + }; +} + +/** A worked example for the admin screen: the formula applied to one order line. */ +export interface WageSample { + rashWeightGrams: string; + quantity: number; + variantPercent: string; + customerPercent: string; + promotionPercent: string; +} + +export const DEFAULT_WAGE_SAMPLE: WageSample = Object.freeze({ + rashWeightGrams: '10', + quantity: 1, + variantPercent: '7', + customerPercent: '3', + promotionPercent: '0', +}); + +export function computeSample(f: WageFormula, s: WageSample) { + const wage = computeWage(f, { + variantPercent: s.variantPercent, + customerPercent: s.customerPercent, + promotionPercents: D(s.promotionPercent).isZero() ? [] : [s.promotionPercent], + }); + const rash = D(s.rashWeightGrams).mul(s.quantity); + // Same formula as OrdersService.createOrder. + const total = rash.mul(D(wage.finalPercent).div(100).add(1)); + return { + ...wage, + rashTotalGrams: rash.toString(), + wageGrams: total.sub(rash).toDecimalPlaces(4).toString(), + totalGrams: total.toDecimalPlaces(4).toString(), + }; +} + +/* ───────────── validation (admin input) ───────────── */ + +function num(v: unknown, field: string, lo: number, hi: number): string { + const s = String(v ?? '').trim(); + let d: Prisma.Decimal; + try { + d = new Prisma.Decimal(s); + } catch { + throw new BadRequestException(`${field}: عدد معتبر نیست`); + } + if (!d.isFinite() || d.lt(lo) || d.gt(hi)) throw new BadRequestException(`${field}: باید بین ${lo} و ${hi} باشد`); + return d.toString(); +} + +function optNum(v: unknown, field: string, lo: number, hi: number): string | null { + return v === null || v === undefined || String(v).trim() === '' ? null : num(v, field, lo, hi); +} + +export function parseWageFormula(raw: unknown): WageFormula { + const r = (raw ?? {}) as Record; + const pick = r.promotionPick === 'sum' ? 'sum' : r.promotionPick === 'max' ? 'max' : null; + if (!pick) throw new BadRequestException('نحوه‌ی انتخاب تخفیف باید «بیشترین» یا «جمع» باشد'); + const decimals = + r.decimals === null || r.decimals === undefined || r.decimals === '' ? null : Number(num(r.decimals, 'تعداد رقم اعشار', 0, 4)); + if (decimals !== null && !Number.isInteger(decimals)) throw new BadRequestException('تعداد رقم اعشار باید عدد صحیح باشد'); + const f: WageFormula = { + version: 1, + variantCoef: num(r.variantCoef, 'ضریب اجرت مدل', -10, 10), + customerCoef: num(r.customerCoef, 'ضریب درصد مشتری', -10, 10), + promotionCoef: num(r.promotionCoef, 'ضریب تخفیف', -10, 10), + promotionPick: pick, + constant: num(r.constant, 'مقدار ثابت', -100, 100), + min: optNum(r.min, 'حداقل اجرت', -100, 1000), + max: optNum(r.max, 'حداکثر اجرت', -100, 1000), + decimals, + }; + if (f.min !== null && f.max !== null && D(f.min).gt(D(f.max))) { + throw new BadRequestException('حداقل اجرت نمی‌تواند از حداکثر بیشتر باشد'); + } + return f; +} + +export function parseWageSample(raw: unknown): WageSample { + const r = (raw ?? {}) as Record; + const qty = Number(r.quantity); + if (!Number.isInteger(qty) || qty < 1 || qty > 1000) throw new BadRequestException('تعداد نمونه باید عدد صحیح بین ۱ و ۱۰۰۰ باشد'); + return { + rashWeightGrams: num(r.rashWeightGrams, 'وزن خام نمونه', 0, 100000), + quantity: qty, + variantPercent: num(r.variantPercent, 'اجرت مدل نمونه', -100, 1000), + customerPercent: num(r.customerPercent, 'درصد مشتری نمونه', -100, 1000), + promotionPercent: num(r.promotionPercent, 'تخفیف نمونه', 0, 100), + }; +} diff --git a/apps/api/src/modules/pricing/wage.controller.ts b/apps/api/src/modules/pricing/wage.controller.ts new file mode 100644 index 0000000..974e6e5 --- /dev/null +++ b/apps/api/src/modules/pricing/wage.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common'; +import { ArrayMaxSize, IsArray, IsString } from 'class-validator'; +import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; +import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards'; +import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy'; +import { WageService } from './wage.service'; + +/** + * «اجرت» admin screen — the making-charge formula, a worked sample, and the + * approval that puts a new formula in force. Behind its own rid. + */ +@Controller('admin/pricing/wage-formula') +@RequirePermission('config.wage') +export class WageAdminController { + constructor(private readonly wage: WageService) {} + + @Get() + get() { + return this.wage.getConfig(); + } + + /** Nothing is saved: the draft and the formula in force on the same sample. */ + @Post('preview') + @HttpCode(200) + preview(@Body() body: { formula?: unknown; sample?: unknown }) { + return this.wage.preview(body?.formula, body?.sample); + } + + /** Puts the formula in force, recorded with the sample it was checked on. */ + @Put() + approve(@Body() body: { formula?: unknown; sample?: unknown }, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.wage.approve(body?.formula, body?.sample, admin); + } +} + +class WageQuoteDto { + @IsArray() + @ArrayMaxSize(200) + @IsString({ each: true }) + variantIds!: string[]; +} + +/** + * The making charge the app shows on a product and in the cart — computed by + * the same code that prices the order, for the signed-in customer. + */ +@Controller('customer/me/wage-quote') +export class WageCustomerController { + constructor(private readonly wage: WageService) {} + + @CustomerAuth() + @Post() + @HttpCode(200) + async quote(@Body() dto: WageQuoteDto, @CurrentCustomer() me: AuthenticatedCustomer) { + return [...(await this.wage.quote(me.id, dto.variantIds)).values()]; + } +} diff --git a/apps/api/src/modules/pricing/wage.service.ts b/apps/api/src/modules/pricing/wage.service.ts new file mode 100644 index 0000000..ae5f337 --- /dev/null +++ b/apps/api/src/modules/pricing/wage.service.ts @@ -0,0 +1,171 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, PromotionScope } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { + computeSample, + computeWage, + DEFAULT_WAGE_SAMPLE, + LEGACY_WAGE_FORMULA, + parseWageFormula, + parseWageSample, + type WageBreakdown, + type WageFormula, + type WageSample, +} from './wage-formula'; + +/** Setting row holding the formula; kept off the generic `const` routes (see admin-misc). */ +export const WAGE_FORMULA_SETTING_KEY = 'pricing.wageFormula'; +export const PRICING_SETTING_PREFIX = 'pricing.'; + +interface Approval { + formula: WageFormula; + sample: WageSample; + approvedAt: string; + approvedBy: { id: string; name: string }; +} + +interface StoredWageFormula extends Approval { + /** Earlier approved versions, newest first (last 20). */ + history: Approval[]; +} + +type Tx = Prisma.TransactionClient | PrismaService; + +/** One variant's making charge for one customer, as charged and as shown. */ +export interface WageQuote extends WageBreakdown { + variantId: string; + variantPercent: string; + customerPercent: string; +} + +@Injectable() +export class WageService { + constructor(private readonly prisma: PrismaService) {} + + private async stored(tx: Tx = this.prisma): Promise { + const row = await tx.setting.findUnique({ where: { key: WAGE_FORMULA_SETTING_KEY } }); + return row ? (row.value as unknown as StoredWageFormula) : null; + } + + /** The formula in force; legacy's exactly until an admin approves another. */ + async formula(tx: Tx = this.prisma): Promise { + const s = await this.stored(tx); + return s ? parseWageFormula(s.formula) : { ...LEGACY_WAGE_FORMULA }; + } + + /* ───────────── admin screen ───────────── */ + + async getConfig() { + const s = await this.stored(); + const formula = s ? parseWageFormula(s.formula) : { ...LEGACY_WAGE_FORMULA }; + const sample = s ? parseWageSample(s.sample) : { ...DEFAULT_WAGE_SAMPLE }; + return { + formula, + sample, + result: computeSample(formula, sample), + approvedAt: s?.approvedAt ?? null, + approvedBy: s?.approvedBy ?? null, + isLegacyDefault: !s, + legacy: LEGACY_WAGE_FORMULA, + history: (s?.history ?? []).map((h) => ({ ...h, result: computeSample(parseWageFormula(h.formula), parseWageSample(h.sample)) })), + }; + } + + /** The draft and the formula in force, side by side, on the same sample. */ + async preview(rawFormula: unknown, rawSample: unknown) { + const draft = parseWageFormula(rawFormula); + const sample = parseWageSample(rawSample); + return { + draft: computeSample(draft, sample), + current: computeSample(await this.formula(), sample), + }; + } + + /** Approves a formula together with the sample it was checked on. */ + async approve(rawFormula: unknown, rawSample: unknown, admin: { id: string; fullName: string }) { + const formula = parseWageFormula(rawFormula); + const sample = parseWageSample(rawSample); + await this.prisma.$transaction(async (tx) => { + const prev = await this.stored(tx); + const next: StoredWageFormula = { + formula, + sample, + approvedAt: new Date().toISOString(), + approvedBy: { id: admin.id, name: admin.fullName }, + history: prev + ? [{ formula: prev.formula, sample: prev.sample, approvedAt: prev.approvedAt, approvedBy: prev.approvedBy }, ...prev.history].slice(0, 20) + : [], + }; + const value = next as unknown as Prisma.InputJsonValue; + await tx.setting.upsert({ + where: { key: WAGE_FORMULA_SETTING_KEY }, + update: { value }, + create: { key: WAGE_FORMULA_SETTING_KEY, value }, + }); + }); + return this.getConfig(); + } + + /* ───────────── pricing a customer's lines ───────────── */ + + /** + * The making charge of each variant for this customer — what the app shows + * and what an app order is charged. The promotions considered are legacy's: + * active, inside their dates, scoped to the product, its category or its + * brand (customer-scoped promotions were switched off in the legacy app). + */ + async quote(customerId: string, variantIds: string[], tx: Tx = this.prisma): Promise> { + const customer = await tx.customer.findUnique({ + where: { id: customerId }, + select: { makingChargeOffsetPercent: true }, + }); + if (!customer) throw new NotFoundException('مشتری یافت نشد'); + + const ids = [...new Set(variantIds)]; + const variants = await tx.productVariant.findMany({ + where: { id: { in: ids } }, + select: { id: true, defaultWagePercent: true, product: { select: { id: true, categoryId: true, brandId: true } } }, + }); + + const now = new Date(); + const promotions = await tx.promotion.findMany({ + where: { + isActive: true, + scope: { in: [PromotionScope.PRODUCT, PromotionScope.CATEGORY, PromotionScope.BRAND] }, + AND: [ + { OR: [{ startsAt: null }, { startsAt: { lte: now } }] }, + { OR: [{ endsAt: null }, { endsAt: { gte: now } }] }, + ], + OR: [ + { productId: { in: variants.map((v) => v.product.id) } }, + { categoryId: { in: variants.map((v) => v.product.categoryId).filter((x): x is string => !!x) } }, + { brandId: { in: variants.map((v) => v.product.brandId).filter((x): x is string => !!x) } }, + ], + }, + select: { scope: true, productId: true, categoryId: true, brandId: true, discountPercent: true }, + }); + + const formula = await this.formula(tx); + const out = new Map(); + for (const v of variants) { + const applying = promotions.filter( + (p) => + (p.scope === PromotionScope.PRODUCT && p.productId === v.product.id) || + (p.scope === PromotionScope.CATEGORY && !!v.product.categoryId && p.categoryId === v.product.categoryId) || + (p.scope === PromotionScope.BRAND && !!v.product.brandId && p.brandId === v.product.brandId), + ); + const inputs = { + variantPercent: v.defaultWagePercent, + customerPercent: customer.makingChargeOffsetPercent, + promotionPercents: applying.map((p) => p.discountPercent), + }; + out.set(v.id, { + variantId: v.id, + variantPercent: new Prisma.Decimal(v.defaultWagePercent).toString(), + customerPercent: new Prisma.Decimal(customer.makingChargeOffsetPercent).toString(), + ...computeWage(formula, inputs), + }); + } + return out; + } +} diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index 6f4beae..04b61d6 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -288,6 +288,15 @@ export interface AccountingBalance { provider: string | null } +/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */ +export interface WageQuote { + variantId: string + variantPercent: string + customerPercent: string + promotionPercent: string + finalPercent: string +} + export const api = { listProducts: () => request('/catalog/products'), /** @@ -320,6 +329,9 @@ export const api = { getAccountingBalance: () => request('/customer/me/accounting-balance'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), + /** The making charge the server will charge for these variants (MON-08). */ + getWageQuotes: (variantIds: string[]) => + request('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }), /** Favourite product ids, newest first — legacy get-favorites (FAV-01). */ getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'), /** Legacy add-to-fav: adds or removes; answers the new state. */ diff --git a/apps/user-app-v2/src/composables/useProduct.ts b/apps/user-app-v2/src/composables/useProduct.ts index 713a490..5cca318 100644 --- a/apps/user-app-v2/src/composables/useProduct.ts +++ b/apps/user-app-v2/src/composables/useProduct.ts @@ -12,14 +12,14 @@ // data exists in the new API, so `hasDiscountWage` is always null here — // the discount badge and "before/after" price never render, which is // correct given there is genuinely nothing to show, not a bug. -// - `wage`: legacy added the logged-in customer's own `percent` (a -// commission/markup field on their account) on top of the variant's -// making-charge %. No login exists in this port yet, so wage is just the -// selected variant's defaultWagePercent on its own. +// - `wage` now comes from the server (wageQuoteStore), which adds the +// customer's own percent and the promotion exactly as legacy did. // - `is_exist` (stock) and `has_warranty`: no such fields exist on our // ProductVariant model yet — always true / false respectively. import { computed, ref, unref, type Ref } from 'vue' import type { ProductSummary, ProductVariantSummary } from '../api/client' +import { useSessionStore } from '../stores/session' +import { useWageQuoteStore } from '../stores/wageQuoteStore' /** A variant can be ordered when it has stock, or when it is flagged pre-order. */ export function isVariantOrderable(v: ProductVariantSummary): boolean { @@ -47,7 +47,20 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product const variants = computed(() => product.value.variants) const selectedVariant: Ref = ref(initialVariant ?? getCheapestVariant(unref(variants))) const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0) - const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null)) + // The making charge the server will charge (MON-08): variant + the + // customer's own percent − the best active promotion, by the formula the + // admin «اجرت» screen controls. Signed in: the server's figure, hidden for + // the moment it loads rather than flashing a different number. Signed out + // (no customer, so no customer percent): the variant's own figure. + const session = useSessionStore() + const wageQuotes = useWageQuoteStore() + const wage = computed(() => { + const v = selectedVariant.value + if (!v) return null + if (!session.isLoggedIn) return parseFloat(v.defaultWagePercent) + wageQuotes.request(v.id) + return wageQuotes.finalPercent(v.id) + }) const hasDiscountWage = ref(null) // no promotions backend yet — see file header // REAL stock: ProductVariant.qty / isPreOrder exist and are returned by // GET /catalog/products. A variant is orderable when it has stock, or when diff --git a/apps/user-app-v2/src/stores/wageQuoteStore.ts b/apps/user-app-v2/src/stores/wageQuoteStore.ts new file mode 100644 index 0000000..e09723c --- /dev/null +++ b/apps/user-app-v2/src/stores/wageQuoteStore.ts @@ -0,0 +1,64 @@ +// The making charge («اجرت») the server will charge this customer for each +// variant — fetched from /customer/me/wage-quote, which runs the same formula +// that prices the order (MON-08, owner ruling 2026-10-08). The app shows this +// figure, never one it computes itself, so what is shown is what is charged. +// +// Requests are batched: many cards asking at once become one call. +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { api, type WageQuote } from '../api/client' +import { useSessionStore } from './session' + +export const useWageQuoteStore = defineStore('wageQuotes', () => { + const session = useSessionStore() + const quotes = ref>({}) + const pending = new Set() + const inFlight = new Set() + let timer: ReturnType | null = null + + // A different customer has a different percent: never reuse another's quotes. + watch( + () => session.token, + () => { + quotes.value = {} + pending.clear() + inFlight.clear() + }, + ) + + async function flush() { + timer = null + const ids = [...pending].slice(0, 200) + ids.forEach((id) => { + pending.delete(id) + inFlight.add(id) + }) + if (pending.size) timer = setTimeout(flush, 0) + if (!ids.length) return + try { + const rows = await api.getWageQuotes(ids) + const next = { ...quotes.value } + for (const r of rows) next[r.variantId] = r + quotes.value = next + } catch (e) { + console.warn('wage quote failed', e) + } finally { + ids.forEach((id) => inFlight.delete(id)) + } + } + + /** Asks for a variant's quote (idempotent; safe to call from a computed). */ + function request(variantId: string) { + if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return + pending.add(variantId) + if (!timer) timer = setTimeout(flush, 30) + } + + /** The charged percent, or null while it is still loading. */ + function finalPercent(variantId: string): number | null { + const q = quotes.value[variantId] + return q ? parseFloat(q.finalPercent) : null + } + + return { quotes, request, finalPercent } +}) diff --git a/apps/user-app-v2/src/views/CartView.vue b/apps/user-app-v2/src/views/CartView.vue index 7a13fca..d7a14f8 100644 --- a/apps/user-app-v2/src/views/CartView.vue +++ b/apps/user-app-v2/src/views/CartView.vue @@ -222,6 +222,8 @@ const submitOrder = async () => { return { productVariantId: item.vid, quantity: item.qty, + // Sent for older API builds only: the server prices app orders itself + // (MON-08) and ignores this figure. wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0, } }), diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts index 85320b1..65d357a 100644 --- a/apps/user-app/src/api/client.ts +++ b/apps/user-app/src/api/client.ts @@ -303,6 +303,15 @@ export interface AccountingBalance { provider: string | null } +/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */ +export interface WageQuote { + variantId: string + variantPercent: string + customerPercent: string + promotionPercent: string + finalPercent: string +} + export const api = { listProducts: () => request('/catalog/products'), @@ -336,6 +345,9 @@ export const api = { getAccountingBalance: () => request('/customer/me/accounting-balance'), getMyOrders: () => request('/customer/me/orders'), getMyLedgerEntries: () => request('/customer/me/ledger-entries'), + /** The making charge the server will charge for these variants (MON-08). */ + getWageQuotes: (variantIds: string[]) => + request('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }), /** Favourite product ids, newest first — legacy get-favorites (FAV-01). */ getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'), /** Legacy add-to-fav: adds or removes; answers the new state. */ diff --git a/apps/user-app/src/composables/useProduct.ts b/apps/user-app/src/composables/useProduct.ts index 1a54c7b..16ffc50 100644 --- a/apps/user-app/src/composables/useProduct.ts +++ b/apps/user-app/src/composables/useProduct.ts @@ -12,14 +12,14 @@ // data exists in the new API, so `hasDiscountWage` is always null here — // the discount badge and "before/after" price never render, which is // correct given there is genuinely nothing to show, not a bug. -// - `wage`: legacy added the logged-in customer's own `percent` (a -// commission/markup field on their account) on top of the variant's -// making-charge %. No login exists in this port yet, so wage is just the -// selected variant's defaultWagePercent on its own. +// - `wage` now comes from the server (wageQuoteStore), which adds the +// customer's own percent and the promotion exactly as legacy did. // - `is_exist` (stock) and `has_warranty`: no such fields exist on our // ProductVariant model yet — always true / false respectively. import { computed, ref, unref, type Ref } from 'vue' import type { ProductSummary, ProductVariantSummary } from '../api/client' +import { useSessionStore } from '../stores/session' +import { useWageQuoteStore } from '../stores/wageQuoteStore' /** A variant can be ordered when it has stock, or when it is flagged pre-order. */ export function isVariantOrderable(v: ProductVariantSummary): boolean { @@ -52,7 +52,20 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product () => selectedVariant.value?.image?.url ?? product.value.images?.[0]?.url, ) const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0) - const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null)) + // The making charge the server will charge (MON-08): variant + the + // customer's own percent − the best active promotion, by the formula the + // admin «اجرت» screen controls. Signed in: the server's figure, hidden for + // the moment it loads rather than flashing a different number. Signed out + // (no customer, so no customer percent): the variant's own figure. + const session = useSessionStore() + const wageQuotes = useWageQuoteStore() + const wage = computed(() => { + const v = selectedVariant.value + if (!v) return null + if (!session.isLoggedIn) return parseFloat(v.defaultWagePercent) + wageQuotes.request(v.id) + return wageQuotes.finalPercent(v.id) + }) const hasDiscountWage = ref(null) // no promotions backend yet — see file header // REAL stock: ProductVariant.qty / isPreOrder exist and are returned by // GET /catalog/products. A variant is orderable when it has stock, or when diff --git a/apps/user-app/src/stores/wageQuoteStore.ts b/apps/user-app/src/stores/wageQuoteStore.ts new file mode 100644 index 0000000..e09723c --- /dev/null +++ b/apps/user-app/src/stores/wageQuoteStore.ts @@ -0,0 +1,64 @@ +// The making charge («اجرت») the server will charge this customer for each +// variant — fetched from /customer/me/wage-quote, which runs the same formula +// that prices the order (MON-08, owner ruling 2026-10-08). The app shows this +// figure, never one it computes itself, so what is shown is what is charged. +// +// Requests are batched: many cards asking at once become one call. +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { api, type WageQuote } from '../api/client' +import { useSessionStore } from './session' + +export const useWageQuoteStore = defineStore('wageQuotes', () => { + const session = useSessionStore() + const quotes = ref>({}) + const pending = new Set() + const inFlight = new Set() + let timer: ReturnType | null = null + + // A different customer has a different percent: never reuse another's quotes. + watch( + () => session.token, + () => { + quotes.value = {} + pending.clear() + inFlight.clear() + }, + ) + + async function flush() { + timer = null + const ids = [...pending].slice(0, 200) + ids.forEach((id) => { + pending.delete(id) + inFlight.add(id) + }) + if (pending.size) timer = setTimeout(flush, 0) + if (!ids.length) return + try { + const rows = await api.getWageQuotes(ids) + const next = { ...quotes.value } + for (const r of rows) next[r.variantId] = r + quotes.value = next + } catch (e) { + console.warn('wage quote failed', e) + } finally { + ids.forEach((id) => inFlight.delete(id)) + } + } + + /** Asks for a variant's quote (idempotent; safe to call from a computed). */ + function request(variantId: string) { + if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return + pending.add(variantId) + if (!timer) timer = setTimeout(flush, 30) + } + + /** The charged percent, or null while it is still loading. */ + function finalPercent(variantId: string): number | null { + const q = quotes.value[variantId] + return q ? parseFloat(q.finalPercent) : null + } + + return { quotes, request, finalPercent } +}) diff --git a/apps/user-app/src/views/CartView.vue b/apps/user-app/src/views/CartView.vue index 62baf2b..07e3dc3 100644 --- a/apps/user-app/src/views/CartView.vue +++ b/apps/user-app/src/views/CartView.vue @@ -213,6 +213,8 @@ const submitOrder = async () => { return { productVariantId: item.vid, quantity: item.qty, + // Sent for older API builds only: the server prices app orders itself + // (MON-08) and ignores this figure. wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0, } }), diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index f561856..86f1444 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -96,7 +96,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | ID | Severity | Where | Description | Evidence | Status | |---|---|---|---|---|---| | SEC-11 | High | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` (deliver) | The OTP message text, **code included**, was persisted to `SmsMessage.body`, which the admin SMS grid lists and full-text searches (`admin-misc.service.ts:135-136`). Any operator holding the `sms` rid could read live codes — defeating the bcrypt hashing of `codeHash`. | Read both files; grid `allowed` includes `body`. | ✅ Fixed in stage 3: the persisted row is a redacted audit line. | -| MON-08 | Medium | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. | +| MON-08 | ~~Medium~~ **FIXED 2026-10-08 (owner-approved)** — app orders are priced by the server (`pricing/wage-formula.ts`, legacy by default: variant % + customer % − largest active product/category/brand promotion; rial charge 0) and the app's wage/discount/rial are ignored; the apps show the same figure from `POST customer/me/wage-quote`. Operators keep their manual numbers. Found on the way: the apps never added the customer's own percent (usually 3) nor any promotion, so the new app undercharged vs legacy. The formula is editable on the new «اجرت» admin screen (`config.wage`) with a live server-computed sample and approve-then-save. Verified end to end: 9+3=12; promotions 2,1 + expired 5 → 10; a tampered order (wage 0, discount 50, rial 999) was charged 10.5% and 0 rial under an approved +0.5 formula. | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. | ## Found while classifying scope for D13