Add optional SMS auto-block threshold below balance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 14:12:29 +03:30
co-authored by Cursor
parent f9b77ceaac
commit 8eba9a9b8f
4 changed files with 47 additions and 4 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `sms_accounts` ADD COLUMN `auto_block_below_rials` INTEGER NULL;
+2
View File
@@ -89,6 +89,8 @@ model SmsAccount {
balanceRials Int @default(0) @map("balance_rials") balanceRials Int @default(0) @map("balance_rials")
pricePerPartRials Int @default(2500) @map("price_per_part_rials") pricePerPartRials Int @default(2500) @map("price_per_part_rials")
isBlocked Boolean @default(false) @map("is_blocked") isBlocked Boolean @default(false) @map("is_blocked")
/// If set: block non-alert SMS when balanceRials < this. Null = only manual isBlocked.
autoBlockBelowRials Int? @map("auto_block_below_rials")
/// below this => red/critical /// below this => red/critical
alertCriticalRials Int @default(10000) @map("alert_critical_rials") alertCriticalRials Int @default(10000) @map("alert_critical_rials")
/// at/below this (and above critical) => yellow; above => green /// at/below this (and above critical) => yellow; above => green
+13
View File
@@ -6,6 +6,7 @@ import {
IsString, IsString,
Max, Max,
Min, Min,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { Type, Transform } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
@@ -19,6 +20,18 @@ export class UpdateSmsAccountDto {
@IsBoolean() @IsBoolean()
isBlocked?: boolean; isBlocked?: boolean;
/** Null clears auto-block; omit leaves unchanged. UI sends tomans×10 as rials. */
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === '' || value === undefined) return value === undefined ? undefined : null;
const n = Number(value);
return Number.isFinite(n) ? Math.trunc(n) : value;
})
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsInt()
@Min(0)
autoBlockBelowRials?: number | null;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0) @Min(0)
+30 -4
View File
@@ -146,6 +146,10 @@ export class SmsService {
data: { data: {
pricePerPartRials: dto.pricePerPartRials ?? undefined, pricePerPartRials: dto.pricePerPartRials ?? undefined,
isBlocked: dto.isBlocked ?? undefined, isBlocked: dto.isBlocked ?? undefined,
autoBlockBelowRials:
dto.autoBlockBelowRials === undefined
? undefined
: dto.autoBlockBelowRials,
alertCriticalRials: dto.alertCriticalRials ?? undefined, alertCriticalRials: dto.alertCriticalRials ?? undefined,
alertWarningRials: dto.alertWarningRials ?? undefined, alertWarningRials: dto.alertWarningRials ?? undefined,
gatewayProvider: dto.gatewayProvider ?? undefined, gatewayProvider: dto.gatewayProvider ?? undefined,
@@ -254,21 +258,25 @@ export class SmsService {
let reason: string | null = null; let reason: string | null = null;
let allowed = true; let allowed = true;
// Only the explicit block switch stops sending (parity with Hamkar/Khanegi panels). // Manual block switch, then optional auto-block threshold (empty = no auto block).
// Negative / low balance is allowed; UI can still show balanceLevel as warning. // Alert SMS (isAlert) always bypasses both. Negative balance alone does not block.
if (account.isBlocked && !isAlert) { if (account.isBlocked && !isAlert) {
allowed = false; allowed = false;
reason = 'blocked'; reason = 'blocked';
} else if (this.isAutoBlocked(account) && !isAlert) {
allowed = false;
reason = 'auto_blocked';
} else if (!isAlert && account.balanceRials < estimatedCostRials) { } else if (!isAlert && account.balanceRials < estimatedCostRials) {
reason = 'insufficient_balance'; reason = 'insufficient_balance';
} }
return { return {
allowed, allowed,
blocked: account.isBlocked, blocked: account.isBlocked || this.isAutoBlocked(account),
reason, reason,
balanceRials: account.balanceRials, balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials, pricePerPartRials: account.pricePerPartRials,
autoBlockBelowRials: account.autoBlockBelowRials,
parts: safeParts, parts: safeParts,
estimatedCostRials, estimatedCostRials,
balanceLevel, balanceLevel,
@@ -303,9 +311,16 @@ export class SmsService {
}); });
} }
if (this.isAutoBlocked(account) && !isAlert) {
throw new ForbiddenException({
message: 'ارسال پیامک به‌خاطر کمتر بودن موجودی از آستانه خودکار مسدود است',
reason: 'auto_blocked',
});
}
const costRials = parts * account.pricePerPartRials; const costRials = parts * account.pricePerPartRials;
// Allow negative wallet (same as product panels): only isBlocked stops send/consume. // Negative wallet still allowed unless manual/auto block applies.
const nextBalance = account.balanceRials - costRials; const nextBalance = account.balanceRials - costRials;
const updated = await tx.smsAccount.update({ const updated = await tx.smsAccount.update({
@@ -869,6 +884,16 @@ export class SmsService {
return `${new Intl.NumberFormat('fa-IR').format(toman)} تومان`; return `${new Intl.NumberFormat('fa-IR').format(toman)} تومان`;
} }
private isAutoBlocked(account: {
balanceRials: number;
autoBlockBelowRials: number | null;
}) {
return (
account.autoBlockBelowRials != null &&
account.balanceRials < account.autoBlockBelowRials
);
}
private chargeInfoOf(account: SmsAccount) { private chargeInfoOf(account: SmsAccount) {
return { return {
card: account.chargeCard || '', card: account.chargeCard || '',
@@ -884,6 +909,7 @@ export class SmsService {
balanceRials: account.balanceRials, balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials, pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked, isBlocked: account.isBlocked,
autoBlockBelowRials: account.autoBlockBelowRials,
alertCriticalRials: account.alertCriticalRials, alertCriticalRials: account.alertCriticalRials,
alertWarningRials: account.alertWarningRials, alertWarningRials: account.alertWarningRials,
chargeCard: account.chargeCard, chargeCard: account.chargeCard,