feat: move brand name to one config and redesign the accounting card

اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 13:31:20 +03:30
co-authored by Claude Haiku 5.5
parent 5d2a1efb0c
commit 532ceee189
100 changed files with 1870 additions and 579 deletions
+16
View File
@@ -0,0 +1,16 @@
import { envOr } from './env';
/**
* The brand name and public site address used in every SMS and message the
* server sends. Read on each call, so changing BRAND_NAME / BRAND_SITE_URL in
* the environment and restarting renames every outgoing text at once — no code
* change. The frontends read the same values from their own brand files; see
* docs/BRANDING.md for the full list.
*/
export function brandName(): string {
return envOr('BRAND_NAME', 'زرتاج');
}
export function brandSiteUrl(): string {
return envOr('BRAND_SITE_URL', 'https://bnk.montzar.ir');
}
@@ -29,7 +29,7 @@ describe('AdminCustomersService', () => {
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
const sms = { send: jest.fn(async () => undefined) };
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never);
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
return { service, writes, accounting };
}
@@ -57,6 +57,9 @@ describe('AdminCustomersService', () => {
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Accepting step one activates the account (an app self-registration
// is created inactive).
isActive: true,
});
});
@@ -9,6 +9,8 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
import { brandName, brandSiteUrl } from '../../common/brand';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
@@ -137,12 +139,13 @@ export class AdminCustomersService {
private readonly table: TableService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
private readonly accountingConfig: AccountingConfigService,
) {}
/**
* The welcome SMS the reference sends after every new customer is created
* (AdminController.php:378-387), with the brand and the PWA link changed to
* منتزر; same line order. The commented-out Android/iOS lines are not sent
* BRAND_NAME / BRAND_SITE_URL (common/brand.ts); same line order. The commented-out Android/iOS lines are not sent
* there and are not sent here.
*
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
@@ -153,11 +156,11 @@ export class AdminCustomersService {
*/
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
const body =
'منتزر\n' +
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
`نام کاربری : ${mobile}\n` +
'لینک pwa \n' +
'https://bnk.montzar.ir';
brandSiteUrl();
void this.sms.send(mobile, body).catch((e: unknown) => {
// send() is documented never to throw; this is belt-and-braces so an
@@ -182,6 +185,8 @@ export class AdminCustomersService {
'goldCreditLimitGrams',
'goldDebtGrams',
'fiatDebtRial',
'accountingGoldBalance',
'accountingRialBalance',
'referredByAgent.fullName',
// legacy filter keys, accepted so the ported table models work unchanged
'name',
@@ -205,6 +210,8 @@ export class AdminCustomersService {
gram_level: 'goldCreditLimitGrams',
remain: 'goldDebtGrams',
rial_remain: 'fiatDebtRial',
acc_remain: 'accountingGoldBalance',
acc_rial_remain: 'accountingRialBalance',
caj: 'createdAt',
uaj: 'updatedAt',
'reagent.name': 'referredByAgent.fullName',
@@ -289,7 +296,7 @@ export class AdminCustomersService {
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
orderBy: AdminCustomersService.nullsLast(orderBy) as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
@@ -297,7 +304,61 @@ export class AdminCustomersService {
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
const linked = await this.accountingLinked(rows.map((r) => r.id));
return this.table.respond(
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
total,
query,
page,
limit,
);
}
/**
* The accounting balance is null for a customer not linked to the software,
* and Postgres puts nulls FIRST on a descending sort — the biggest debtors
* would sit under every unlinked customer.
*/
private static nullsLast(orderBy: Record<string, unknown>) {
const [field, dir] = Object.entries(orderBy)[0] ?? [];
return field === 'accountingGoldBalance' || field === 'accountingRialBalance'
? { [field]: { sort: dir, nulls: 'last' } }
: orderBy;
}
/**
* D27 — which of these customers are linked to the CONNECTED accounting
* software; null when none is connected (the list then hides its columns).
* The provider is checked because a balance read from a previous provider
* stays on the customer row.
*/
private async accountingLinked(ids: string[]): Promise<Set<string> | null> {
const config = await this.accountingConfig.get();
if (config.provider === 'none' || this.accountingConfig.inactiveReason(config)) return null;
const links = await this.prisma.accountingLink.findMany({
where: { provider: config.provider, entityType: 'CUSTOMER', entityId: { in: ids } },
select: { entityId: true },
});
return new Set(links.map((l) => l.entityId));
}
/** The balance in the accounting software (app convention: positive = owes). */
private static accountingCells(
c: {
id: string;
accountingGoldBalance: Prisma.Decimal | null;
accountingRialBalance: Prisma.Decimal | null;
accountingBalanceAt: Date | null;
},
linked: Set<string> | null,
) {
if (!linked) return {};
const on = linked.has(c.id);
return {
acc_remain: on ? c.accountingGoldBalance : null,
acc_rial_remain: on ? c.accountingRialBalance : null,
acc_at: on ? c.accountingBalanceAt : null,
};
}
/** Legacy field names preserved so the ported table models bind unchanged. */
@@ -1022,6 +1083,10 @@ export class AdminCustomersService {
: VerificationStep.VERIFIED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Reference MajorCustomerController.php:108-110 — accepting step one
// activates the account. An app self-registration is created inactive
// and can sign in only from here on.
...(payload.sts_verify_step_one !== undefined ? { isActive: true } : {}),
};
} else {
data = {
@@ -5,6 +5,7 @@ import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { brandName } from '../../common/brand';
/**
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
@@ -345,8 +346,8 @@ export class AdminDocumentsService {
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
const text = confirmed
? 'منتزر\nسند مالی شما توسط مدیر سیستم تایید شد.'
: 'منتزر\nسند مالی شما توسط مدیر سیستم رد شد.';
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
: `${brandName()}\nسند مالی شما توسط مدیر سیستم رد شد.`;
this.prisma.customer
.findUnique({ where: { id: customerId }, select: { mobile: true } })
@@ -7,6 +7,7 @@ import { AdminSessionService } from '../sessions/admin-session.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { ALL_RIDS } from '../auth/acl.constants';
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
/**
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
@@ -92,15 +93,20 @@ export class AdminUsersService {
defaultOrderBy: { createdAt: 'desc' },
});
// The support super admin is not listed: to everyone else it does not exist.
const visible: Prisma.AdminUserWhereInput = {
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
};
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: where as Prisma.AdminUserWhereInput,
where: visible,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true, avatarFile: true },
}),
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
this.prisma.adminUser.count({ where: visible }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
@@ -170,7 +176,7 @@ export class AdminUsersService {
salesExpertProfile: { include: { city: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
@@ -263,7 +269,7 @@ export class AdminUsersService {
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کاربر یافت نشد');
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
@@ -386,7 +392,7 @@ export class AdminUsersService {
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید');
@@ -4,6 +4,7 @@ import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../auth/guards';
import { CurrentCustomer, CustomerAuth } from './customer.guards';
import type { AuthenticatedCustomer } from './customer-jwt.strategy';
@@ -36,6 +37,14 @@ export class CustomerAuthController {
return this.auth.requestOtp(dto.username, ip);
}
/** App self-registration. The new customer waits in the admin's pending list. */
@Public()
@Post('auth/register')
@HttpCode(200)
register(@Body() dto: RegisterDto, @Ip() ip: string) {
return this.auth.register(dto, ip);
}
/** Step 2. Returns the token and that one customer, as the reference did. */
@Public()
@Post('auth/verify-otp')
@@ -1,5 +1,7 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -9,7 +11,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
@@ -25,6 +27,8 @@ import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
import type { RegisterDto } from './dto/register.dto';
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
class TooManyRequestsException extends HttpException {
@@ -44,6 +48,9 @@ const MAX_SENDS_PER_IP = 20;
const SEND_WINDOW_MS = 10 * 60 * 1000;
/** Minimum gap between two sends to the same number. */
const RESEND_COOLDOWN_MS = 60 * 1000;
/** Self-registrations allowed per source IP inside the window. */
const MAX_REGISTRATIONS_PER_IP = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
/**
* D20 — the delivery master OTP.
@@ -177,6 +184,15 @@ export class CustomerAuthService {
// caps a single IP at MAX_SENDS_PER_IP per window, which it did not do in
// the reference.
if (!customer) {
// A self-registration still awaiting review is inactive by design; tell
// them so rather than claim the number was never registered.
const pending = await this.prisma.customer.findFirst({
where: { mobile, isActive: false, isArchived: false, verificationStep: VerificationStep.STEP_ONE_SUBMITTED },
select: { id: true },
});
if (pending) {
throw new ForbiddenException('ثبت نام شما در انتظار تایید کارشناسان است. پس از تایید می‌توانید وارد شوید.');
}
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
throw new NotFoundException('این شماره در سیستم ثبت نشده است. لطفاً با پشتیبانی تماس بگیرید.');
}
@@ -197,6 +213,89 @@ export class CustomerAuthService {
return { sent: true };
}
/**
* Self-registration from the app — port of Major/CustomerController::register
* (henza_server :19-75). Before this the app's register form was a stub that
* showed "registered" and saved nothing, so new customers never reached the
* admin's «مشتریان در انتظار» list.
*
* Uses the reference's ORIGINAL review flow (its commented-out "نیاز به تایید
* ادمین" block), not the later auto-approve: the row is inactive with step one
* awaiting review, which is exactly what that list shows. Approving step one
* in the admin panel activates it (AdminCustomersService.verify). Credit limit
* and wage offset stay at their defaults for the admin to set on review.
*/
async register(dto: RegisterDto, ip: string | undefined) {
const mobile = faToEn(dto.mobile);
if (!/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
this.assertRegisterAllowed(ip);
const fname = dto.fname.trim();
const lname = dto.lname.trim();
const existing = await this.prisma.customer.findUnique({ where: { mobile }, select: { id: true } });
if (existing) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
try {
await this.prisma.customer.create({
data: {
fullName: `${fname} ${lname}`,
firstName: fname,
lastName: lname,
mobile,
passwordHash: OTP_ONLY_PASSWORD_HASH,
kind: CustomerKind.APP_SHOPKEEPER,
storeName: dto.store_name.trim(),
phone: faToEn(dto.phone),
address: dto.address.trim(),
cityNameRaw: dto.city.trim(),
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
isActive: false,
},
});
} catch (e) {
// Two submits racing on the same number: the unique index catches the second.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
throw e;
}
// Reference :57-71 (its "awaiting review" wording). Fire-and-forget: a
// failed SMS never fails the registration (D20).
const brand = brandName();
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((err: unknown) => {
this.logger.error(`Registration SMS to ${to} failed: ${String(err)}`);
});
};
fire(
mobile,
`${brand}\nهمکار گرامی ${fname} ${lname}\nثبت نام شما با موفقیت انجام شد و در انتظار تایید کارشناسان است.`,
);
for (const admin of envList('PROFILE_ADMIN_MOBILES')) {
fire(admin, `${brand}\nمشتری با نام ${fname} ${lname} ثبت نام کرد و در انتظار تایید است.`);
}
return { ok: true };
}
/** Public endpoint with an SMS side effect: cap how many accounts one IP can open. */
private readonly registrationsByIp = new Map<string, number[]>();
private assertRegisterAllowed(ip: string | undefined) {
if (!ip) return;
const now = Date.now();
const recent = (this.registrationsByIp.get(ip) ?? []).filter((t) => now - t < REGISTER_WINDOW_MS);
if (recent.length >= MAX_REGISTRATIONS_PER_IP) {
throw new TooManyRequestsException('تعداد ثبت نام‌ها بیش از حد مجاز است. لطفاً بعداً تلاش کنید.');
}
recent.push(now);
this.registrationsByIp.set(ip, recent);
}
/**
* D21 — find an active sales expert by mobile and return their trading
* Customer account, creating it on first sign-in.
@@ -779,12 +878,12 @@ export class CustomerAuthService {
*/
private async deliver(mobile: string, code: string) {
// The reference's text (ApiMobileController.php:47) with the brand changed
// to منتزر. The leading "<#> code is <CODE>" line and the trailing
// to BRAND_NAME. The leading "<#> code is <CODE>" line and the trailing
// "aMo6iFyUQaY" are the Android SMS Retriever app hash — change either and
// autofill stops working on every installed client. Do not "tidy" them.
const smsText =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
`aMo6iFyUQaY`;
// persist:false — the real text carries the live code, and the admin SMS
@@ -0,0 +1,44 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* Body of POST customer/auth/register.
*
* The fields of the reference's Major/CustomerController::register
* (henza_server :23-31), all required there and here.
*/
export class RegisterDto {
@IsString()
@IsNotEmpty({ message: 'نام الزامی است' })
@MaxLength(100)
fname!: string;
@IsString()
@IsNotEmpty({ message: 'نام خانوادگی الزامی است' })
@MaxLength(100)
lname!: string;
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@MaxLength(20)
mobile!: string;
@IsString()
@IsNotEmpty({ message: 'نام فروشگاه الزامی است' })
@MaxLength(150)
store_name!: string;
@IsString()
@IsNotEmpty({ message: 'تلفن فروشگاه الزامی است' })
@MaxLength(20)
phone!: string;
@IsString()
@IsNotEmpty({ message: 'آدرس الزامی است' })
@MaxLength(500)
address!: string;
@IsString()
@IsNotEmpty({ message: 'شهر الزامی است' })
@MaxLength(100)
city!: string;
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { PrismaService } from '../../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { CompleteProfileDto } from './dto/complete-profile.dto';
import { brandName } from '../../common/brand';
/**
* "تکمیل پروفایل" — the customer uploads their three documents.
@@ -104,15 +105,15 @@ export class CustomerProfileService {
* reference likewise queued them (SendSmsAfterCreate::dispatch, :100).
*/
private notify(mobile: string, firstName: string, lastName: string) {
// Major/CustomerController::complete:97-99, brand changed to منتزر.
// Major/CustomerController::complete:97-99, brand read from BRAND_NAME.
const message =
'منتزر\n' +
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
'تکمیل پروفایل شما با موفقیت انجام شد.';
// :103-105, brand changed to منتزر.
// :103-105, brand read from BRAND_NAME.
const adminMessage =
'منتزر\n' + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
`${brandName()}\n` + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((e: unknown) => {
+2 -2
View File
@@ -24,7 +24,7 @@ describe('IamService', () => {
IAM_ISSUER: 'https://auth.test.local',
IAM_CLIENT_ID: 'goldbank_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'montzar',
IAM_TENANT_SLUG: 'zartaj',
IAM_LOGIN_DELEGATION: 'fallback',
...overrides,
};
@@ -110,7 +110,7 @@ describe('IamService', () => {
expect(body.identifier).toBe('09120000000');
expect(body.client_id).toBe('goldbank_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('montzar');
expect(body.tenant_slug).toBe('zartaj');
// Without this the IAM would rate-limit on this server's IP and lock
// out every admin after a handful of attempts.
expect(body.client_ip).toBe('203.0.113.7');
@@ -241,7 +241,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. منتزر');
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
expect(toCustomer?.body).not.toContain('معلق');
});
@@ -7,6 +7,7 @@ import { AccountingHooksService } from '../accounting-sync/accounting-hooks.serv
import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
interface ComputedItem {
productVariantId: string;
@@ -242,7 +243,7 @@ export class OrdersService {
}
/**
* Legacy ApiMobileController::setOrder:733-766, brand changed to منتزر.
* Legacy ApiMobileController::setOrder:733-766, brand read from BRAND_NAME.
*
* Three audiences: the admins, the customer, and the customer's sales expert.
* Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is
@@ -269,14 +270,14 @@ export class OrdersService {
// dropped, which is what Decimal's own string form gives.
const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString();
const adminMessage = `منتزر\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
const adminMessage = `${brandName()}\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
fire(
customer.mobile,
isSuspendedOverCreditLimit
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. منتزر`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. منتزر`,
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. ${brandName()}`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. ${brandName()}`,
);
if (customer.referredByAgentId) {
@@ -294,7 +295,7 @@ export class OrdersService {
if (!agent?.mobile) return;
await this.sms.send(
agent.mobile,
`منتزر\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
`${brandName()}\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
);
} catch (e: unknown) {
this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`);
+3 -3
View File
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
/**
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
* the brand منتزر — the first line and the trailing token are the Android SMS
* the brand زرتاج — the first line and the trailing token are the Android SMS
* Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the expected text byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر 1234 می باشد\naMo6iFyUQaY',
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
);
});