Remove user-app v1 and ship pending order, auth, and app fixes.
Drop the duplicate customer app from the monorepo and keep user-app-v2 as the only app; include order pricing, admin cart, OTP/SMS, and dashboard cache updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,4 +23,19 @@ export class PublicSliderController {
|
||||
const value = await this.misc.getConst('mobile_main_slider');
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Custom button on the profile dashboard (ثابتها → دکمه سفارشی). */
|
||||
@Get('custom-button')
|
||||
async getCustomButton() {
|
||||
const value = await this.misc.getConst('custom_button');
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { enabled: false, title: '', link: '' };
|
||||
}
|
||||
const row = value as Record<string, unknown>;
|
||||
return {
|
||||
enabled: Boolean(row.enabled),
|
||||
title: String(row.title ?? ''),
|
||||
link: String(row.link ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ export class AdminOrdersService {
|
||||
qty: it.quantity,
|
||||
grams: it.computedWeightGrams,
|
||||
rash: it.rashWeightGramsSnapshot,
|
||||
flatRialCharge: it.flatRialCharge,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -322,7 +323,9 @@ export class AdminOrdersService {
|
||||
// negotiate a different one.
|
||||
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
|
||||
discountPercent: Number(it.discount ?? 0),
|
||||
flatRialCharge: Number(it.rial_input ?? 0),
|
||||
// Omitted rial_input is left unset so createOrder copies variant.rialWage.
|
||||
// An explicit 0 stays 0: the operator cleared the charge.
|
||||
...(it.rial_input != null ? { flatRialCharge: Number(it.rial_input) } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } from '@prisma/client';
|
||||
import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -887,10 +887,9 @@ export class CustomerAuthService {
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
|
||||
`aMo6iFyUQaY`;
|
||||
|
||||
// persist:false — the real text carries the live code, and the admin SMS
|
||||
// grid lists and searches `body` (SEC-11). The redacted audit row below
|
||||
// takes its place.
|
||||
const result = await this.sms.send(mobile, smsText, { persist: false });
|
||||
// Persist the full text (code included) so the admin SMS log can show it.
|
||||
// CustomerOtp still stores only a bcrypt hash for verification.
|
||||
const result = await this.sms.send(mobile, smsText);
|
||||
|
||||
// Only outside production, and only when no gateway is configured, so a
|
||||
// deployment can never print live codes into its logs.
|
||||
@@ -928,22 +927,6 @@ export class CustomerAuthService {
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// An audit row, NOT the message body.
|
||||
//
|
||||
// SmsMessage.body is listed and full-text searchable on the admin panel's
|
||||
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
|
||||
// persisting the real text would show every live OTP to any operator
|
||||
// holding the `sms` rid — and the whole point of hashing codeHash is that
|
||||
// the code exists nowhere in readable form. The code is redacted here.
|
||||
await this.prisma.smsMessage.create({
|
||||
data: {
|
||||
audience: MessageAudience.SINGLE_NUMBER,
|
||||
recipients: mobile,
|
||||
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمیشود)',
|
||||
sentCount: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ describe('CustomerAuthService.requestOtp — the caller is told what happened',
|
||||
const { service, sms, prisma } = build();
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
||||
expect(sms.send).toHaveBeenCalledTimes(1);
|
||||
const smsBody = String(sms.send.mock.calls[0][1]);
|
||||
expect(smsBody).toMatch(/code is \d{4}/);
|
||||
expect(smsBody).toContain('رمز عبور یکبار مصرف');
|
||||
expect(prisma.customerOtp.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('OrdersService.createOrder — order ban', () => {
|
||||
|
||||
/** 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() {
|
||||
function build(rialWage?: number | string | null) {
|
||||
const created: Record<string, any>[] = [];
|
||||
const tx = {
|
||||
order: {
|
||||
@@ -124,7 +124,13 @@ describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
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 catalog = {
|
||||
getVariantOrThrow: jest.fn(async () => ({
|
||||
id: 'v1',
|
||||
rashWeightGrams: '10',
|
||||
...(rialWage !== undefined ? { rialWage } : {}),
|
||||
})),
|
||||
};
|
||||
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
|
||||
return { service, created };
|
||||
}
|
||||
@@ -145,6 +151,35 @@ describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
expect(created[0].flatRialCharge.toString()).toBe('999');
|
||||
});
|
||||
|
||||
it('charges the variant rial wage on an app order and ignores a tampered body', async () => {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
try {
|
||||
const { service, created } = build(500000);
|
||||
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
|
||||
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000');
|
||||
expect(created[0].flatRialCharge.toString()).toBe('500000');
|
||||
const lines = warn.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(lines).toContain('flatRialCharge sent=999 used=500000');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an operator rial figure when the variant has its own wage', async () => {
|
||||
const { service, created } = build(500000);
|
||||
await service.createOrder(tampered, 'c1', { placedBy: 'operator' });
|
||||
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('999');
|
||||
expect(created[0].flatRialCharge.toString()).toBe('999');
|
||||
});
|
||||
|
||||
it('copies the variant rial wage when an operator omits the field', async () => {
|
||||
const omitted = { items: [{ productVariantId: 'v1', quantity: 2, wagePercent: 10 }] } as never;
|
||||
const { service, created } = build(500000);
|
||||
await service.createOrder(omitted, 'c1', { placedBy: 'operator' });
|
||||
expect(created[0].items.create[0].flatRialCharge.toString()).toBe('500000');
|
||||
expect(created[0].flatRialCharge.toString()).toBe('1000000');
|
||||
});
|
||||
|
||||
/**
|
||||
* MON-08 step 3 (owner ruling): «تست مقایسهای که نشان دهد نتیجه برای کاربر
|
||||
* عادی یکسان است». An unmodified app sends what the server derives
|
||||
|
||||
@@ -83,8 +83,10 @@ export class OrdersService {
|
||||
// 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.
|
||||
// The rial amount is the variant's fixed rialWage (legacy rial_pay), not a
|
||||
// zero. An operator ordering from the panel keeps the numbers they entered,
|
||||
// as legacy's order builder did; a missing rial figure falls back to the
|
||||
// same variant amount.
|
||||
const quotes =
|
||||
opts.placedBy === 'customer'
|
||||
? await this.wage.quote(customerId, dto.items.map((i) => i.productVariantId))
|
||||
@@ -98,9 +100,17 @@ export class OrdersService {
|
||||
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));
|
||||
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent);
|
||||
const netPercent = wagePercent.sub(discountPercent);
|
||||
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
|
||||
// Per unit. Customer orders always take the variant; an omitted operator
|
||||
// figure does too. An explicit operator number, including 0, is kept.
|
||||
const variantRial = new Prisma.Decimal(variant.rialWage ?? 0);
|
||||
const flatRialCharge = quotes
|
||||
? variantRial
|
||||
: item.flatRialCharge != null
|
||||
? new Prisma.Decimal(item.flatRialCharge)
|
||||
: variantRial;
|
||||
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent, flatRialCharge);
|
||||
|
||||
return {
|
||||
productVariantId: variant.id,
|
||||
@@ -109,8 +119,7 @@ export class OrdersService {
|
||||
wagePercentSnapshot: wagePercent,
|
||||
discountPercentSnapshot: discountPercent,
|
||||
computedWeightGrams,
|
||||
// Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one.
|
||||
flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)),
|
||||
flatRialCharge,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -216,15 +225,16 @@ export class OrdersService {
|
||||
* mismatch means a stale app version, a promotion that expired between the
|
||||
* product page and checkout, or a tampered request.
|
||||
*
|
||||
* `flatRialCharge` is included because the app hardcodes 0 (cart.vue:179) and
|
||||
* only an operator ever sets one — anything non-zero from a customer is a
|
||||
* client that should not be sending it.
|
||||
* `flatRialCharge` is compared only when the client actually sent one. The
|
||||
* app cart omits it and the server copies variant.rialWage, so treating a
|
||||
* missing field as 0 would warn on every order that has a rial wage.
|
||||
*/
|
||||
private warnOnPriceMismatch(
|
||||
variantId: string,
|
||||
sent: CreateOrderItemInputDto,
|
||||
wagePercent: Prisma.Decimal,
|
||||
discountPercent: Prisma.Decimal,
|
||||
flatRialCharge: Prisma.Decimal,
|
||||
) {
|
||||
const differs: string[] = [];
|
||||
if (!wagePercent.eq(sent.wagePercent)) {
|
||||
@@ -233,8 +243,8 @@ export class OrdersService {
|
||||
if (!discountPercent.eq(sent.discountPercent ?? 0)) {
|
||||
differs.push(`discountPercent sent=${sent.discountPercent ?? 0} used=${discountPercent.toString()}`);
|
||||
}
|
||||
if ((sent.flatRialCharge ?? 0) !== 0) {
|
||||
differs.push(`flatRialCharge sent=${sent.flatRialCharge} used=0`);
|
||||
if (sent.flatRialCharge != null && !flatRialCharge.eq(sent.flatRialCharge)) {
|
||||
differs.push(`flatRialCharge sent=${sent.flatRialCharge} used=${flatRialCharge.toString()}`);
|
||||
}
|
||||
if (differs.length) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Prisma } from '@prisma/client';
|
||||
* 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)
|
||||
* rial charge is not inside this weight. The variant's fixed rialWage
|
||||
* (legacy rial_pay) is copied onto the order line on its own.
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -17,9 +17,6 @@ import { isPrivateIpv4, resolveRelayHost } from './drivers/relay.driver';
|
||||
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
||||
* * the response body is returned and the callers ignore it
|
||||
*
|
||||
* The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
*
|
||||
* Failure handling matches the reference: `Sms::Send` is called inline and its
|
||||
* result is discarded, so a failed send never aborted the order, the document
|
||||
* approval or the customer creation that triggered it. `send()` therefore
|
||||
@@ -31,7 +28,7 @@ export type { SmsSendResult } from './drivers';
|
||||
export interface SmsSendOptions {
|
||||
/**
|
||||
* Persist the message body. Default true, matching `Sms::set()`.
|
||||
* OTP delivery passes false and writes its own redacted row (SEC-11).
|
||||
* Pass false only when the caller writes its own audit row.
|
||||
*/
|
||||
persist?: boolean;
|
||||
audience?: MessageAudience;
|
||||
|
||||
Reference in New Issue
Block a user