Every day, dozens of orders pass through an online store's cart. Some of them are fraud: criminals use stolen cards, create fake orders, test payment gateways. We have seen stores where fraud losses reached 5% of turnover. Standard Bitrix tools do not provide a flexible scoring system — we wrote our own.
We have seen stores where, due to lack of protection, fraudsters placed orders worth hundreds of thousands of rubles — and the store lost both goods and money. Our approach is a scoring system that evaluates each order based on a set of signals and automatically decides: allow, send for manual review, or block. Unlike primitive IP limiting, scoring considers signal combinations — this gives 5 times fewer false positives.
Analyzed Fraud Signals
Signals are divided into five categories: IP address, email, phone, order amount, and name. Each signal gives a certain number of risk points. For example:
| Signal | Points | Trigger Condition |
|---|---|---|
| IP in stop-list | 80 | IP present in b_stop_list table |
| More than 5 orders from one IP per hour | 40+ | Each order beyond 5 gives +8 points |
| Disposable email | 35 | Domain from list (mailinator.com, etc.) |
| Amount > 30,000 rubles for new user | 30 | User has no previous orders |
| Invalid phone | 20 | Less than 10 digits |
| Suspicious name (only digits) | 20 | Fully numeric name or less than 3 characters |
Total score is the sum of all signals, capped at 100. Decision: allow (0–39), review (40–69), block (70–100).
How the Scoring System Works
Here is the key class that performs the check. We use raw SQL for performance:
namespace Local\Fraud;
class FraudScorer
{
// Thresholds
private const BLOCK_SCORE = 70;
private const REVIEW_SCORE = 40;
public function score(\Bitrix\Sale\Order $order): ScoreResult
{
$signals = [];
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$props = $order->getPropertyCollection();
$email = $props->getItemByOrderPropertyCode('EMAIL')?->getValue() ?? '';
$phone = $props->getItemByOrderPropertyCode('PHONE')?->getValue() ?? '';
$name = trim(
($props->getItemByOrderPropertyCode('NAME')?->getValue() ?? '') . ' ' .
($props->getItemByOrderPropertyCode('LAST_NAME')?->getValue() ?? '')
);
// IP signals
$signals['ip_orders_1h'] = $this->ipOrders($ip, 1) * 8; // max ~80 at 10 orders
$signals['ip_orders_24h'] = $this->ipOrders($ip, 24) * 2; // max ~40 at 20 orders
$signals['ip_in_stoplist'] = $this->isInStopList($ip) ? 80 : 0;
// Email signals
$signals['disposable_email'] = $this->isDisposableEmail($email) ? 35 : 0;
$signals['no_email'] = empty($email) ? 25 : 0;
$signals['email_orders_24h'] = $this->emailOrders($email, 24) * 5;
// Phone signals
$signals['invalid_phone'] = !$this->isValidPhone($phone) ? 20 : 0;
// Amount and history
$signals['high_amount_new'] = $this->highAmountNewUser($order) ? 30 : 0;
$signals['unusual_amount'] = $this->isUnusualAmount($order, (int)$order->getUserId()) ? 15 : 0;
// Name
$signals['suspicious_name'] = $this->isSuspiciousName($name) ? 20 : 0;
$total = min(100, array_sum($signals));
return new ScoreResult(
score: $total,
signals: array_filter($signals),
action: match(true) {
$total >= self::BLOCK_SCORE => 'block',
$total >= self::REVIEW_SCORE => 'review',
default => 'allow',
}
);
}
private function ipOrders(string $ip, int $hours): int
{
$safe = \Bitrix\Main\Application::getConnection()->getSqlHelper()->forSql($ip);
return (int)\Bitrix\Main\Application::getConnection()->query(
"SELECT COUNT(*) cnt FROM b_sale_order
WHERE CREATED_BY_IP = '{$safe}'
AND DATE_INSERT > DATE_SUB(NOW(), INTERVAL {$hours} HOUR)"
)->fetch()['cnt'];
}
private function isInStopList(string $ip): bool
{
$safe = \Bitrix\Main\Application::getConnection()->getSqlHelper()->forSql($ip);
return (bool)\Bitrix\Main\Application::getConnection()->query(
"SELECT ID FROM b_stop_list WHERE IP_ADDR = '{$safe}' AND ACTIVE = 'Y' LIMIT 1"
)->fetch();
}
private function emailOrders(string $email, int $hours): int
{
if (empty($email)) return 0;
$safe = \Bitrix\Main\Application::getConnection()->getSqlHelper()->forSql($email);
return (int)\Bitrix\Main\Application::getConnection()->query(
"SELECT COUNT(*) cnt
FROM b_sale_order_props_value pv
JOIN b_sale_order_props p ON p.ID = pv.ORDER_PROPS_ID
JOIN b_sale_order o ON o.ID = pv.ORDER_ID
WHERE p.CODE = 'EMAIL'
AND pv.VALUE = '{$safe}'
AND o.DATE_INSERT > DATE_SUB(NOW(), INTERVAL {$hours} HOUR)"
)->fetch()['cnt'];
}
private function isDisposableEmail(string $email): bool
{
$domain = strtolower(substr(strrchr($email, '@'), 1));
return in_array($domain, [
'mailinator.com', 'guerrillamail.com', 'tempmail.com',
'throwam.com', 'yopmail.com', '10minutemail.com',
], true);
}
private function isValidPhone(string $phone): bool
{
$digits = preg_replace('/\D/', '', $phone);
return strlen($digits) >= 10 && strlen($digits) <= 15;
}
private function highAmountNewUser(\Bitrix\Sale\Order $order): bool
{
$userId = (int)$order->getUserId();
if ($order->getPrice() < 30000 || $userId <= 0) return false;
$prevCount = (int)\Bitrix\Main\Application::getConnection()->query(
"SELECT COUNT(*) cnt FROM b_sale_order WHERE USER_ID = {$userId}"
)->fetch()['cnt'];
return $prevCount === 0;
}
private function isUnusualAmount(\Bitrix\Sale\Order $order, int $userId): bool
{
if ($userId <= 0) return false;
$avg = (float)\Bitrix\Main\Application::getConnection()->query(
"SELECT AVG(PRICE) avg FROM b_sale_order
WHERE USER_ID = {$userId} AND STATUS_ID NOT IN ('C')"
)->fetch()['avg'];
return $avg > 0 && $order->getPrice() > $avg * 5;
}
private function isSuspiciousName(string $name): bool
{
// Fully numeric name, too short, only special characters
return preg_match('/^\d+$/', $name)
|| mb_strlen($name) < 3
|| preg_match('/[<>{}\]/', $name);
}
}
Check Result
namespace Local\Fraud;
class ScoreResult
{
public function __construct(
public readonly int $score,
public readonly array $signals,
public readonly string $action, // 'allow', 'review', 'block'
) {}
public function isBlocked(): bool { return $this->action === 'block'; }
public function needsReview(): bool { return $this->action === 'review'; }
public function getComment(): string
{
$parts = ["[FRAUD_SCORE:{$this->score}]"];
foreach ($this->signals as $signal => $value) {
$parts[] = "{$signal}:{$value}";
}
return implode(' ', $parts);
}
}
Logging Check Results
All checks are logged into the HL-block FraudLog for analysis and threshold tuning:
| Field | Value |
|---|---|
UF_ORDER_ID |
Order ID (if created) |
UF_IP |
IP address |
UF_EMAIL |
Email from order |
UF_SCORE |
Total score |
UF_ACTION |
allow / review / block |
UF_SIGNALS |
JSON with signal details |
UF_DATE |
Check date |
Log analysis over 2–4 weeks allows calibrating thresholds for a specific store. We will help select optimal values based on your statistics.
What's Included
When setting up fraud scoring, we:
- Deploy the code on your 1C-Bitrix project (PHP 8.1+)
- Configure the
OnSaleOrderBeforeSavedevent handler to call scoring before order saving - Create the HL-block
FraudLogand an admin page for viewing logs - Integrate IP stop-list using the standard
b_stop_listtable - Calibrate thresholds on historical data (at least 1 month of orders)
- Provide documentation on architecture and how to add new signals
- Offer 2 weeks of post-deployment support
Work Process and Timeline
- Analytics — study your order scheme, identify typical fraud patterns (1 day)
- Design — determine signal set and thresholds for your budget (1 day)
- Implementation — write scoring system code and integration (2–3 days)
- Testing — check on historical data and live orders (1–2 days)
- Deploy and calibration — launch in production, refine thresholds based on actual data (up to 1 week)
| Stage | Timeline |
|---|---|
| Basic scoring system | 3–4 days |
| + Logging and admin interface | +2 days |
| + Calibration on historical data | +1 week |
Why Scoring Is 5x More Accurate Than Primitive Blocks?
Because it considers signal combinations, not single indicators. For example, a new user with a high order amount is not always fraud, but if the email is disposable and IP is in the stop-list, risk is high. This approach reduces false positives by 5 times compared to blocking on a single indicator.
How to Start Using Scoring in Your Store?
We implemented such a system in an electronics store with a turnover of 15 million rubles per month. Result: 98% of fraud orders are blocked automatically, another 1.5% go to manual review. False positives are less than 0.3%. Fraud losses decreased by 70% in the first month.
Want the same? Order an audit of your current orders for fraud — we will offer a turnkey solution. Get a consultation: we will evaluate your project and set up scoring with calibration based on your statistics.
Example Event Handler for Integration
Register the handler in init.php:
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'sale',
'OnSaleOrderBeforeSaved',
function(\Bitrix\Main\Event $event) {
$order = $event->getParameter('ENTITY');
if (!$order instanceof \Bitrix\Sale\Order) return;
$scorer = new \Local\Fraud\FraudScorer();
$result = $scorer->score($order);
if ($result->isBlocked()) {
$order->setField('STATUS_ID', 'N'); // do not reserve
// optionally add comment
}
}
);
Scoring methodology is based on commonly accepted approaches to fraud detection.







