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:
mahmood
2026-10-28 16:22:32 +03:30
co-authored by Cursor
parent bc881a0a8a
commit 520454481b
367 changed files with 335 additions and 28536 deletions
@@ -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
+20 -10
View File
@@ -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(
+2 -1
View File
@@ -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
+1 -4
View File
@@ -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;