Cashback System Development on 1C-Bitrix
Cashback is the return of a portion of a purchase's value to the user's account in the form of points or money. 1C-Bitrix includes a built-in bonus points mechanism (the sale module, discount subsystem), but it only covers basic scenarios: accruing a fixed percentage. A full-featured cashback system — with accrual history, category-based rules, expiration dates, and payment restrictions — requires custom development on top of 1C-Bitrix.
Data Storage Architecture
Cashback is a separate entity, not equivalent to 1C-Bitrix "bonus points." 1C-Bitrix stores bonus points in b_sale_user_account and b_sale_account_user_balance. But for a complete cashback system with history, accrual rules, and expiration, it is better to create a custom schema:
-- User cashback account
CREATE TABLE b_cashback_account (
ID INT AUTO_INCREMENT PRIMARY KEY,
USER_ID INT NOT NULL UNIQUE,
BALANCE DECIMAL(10,2) NOT NULL DEFAULT 0.00,
TOTAL_EARNED DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- total earned
TOTAL_SPENT DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- total spent
UPDATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (USER_ID)
);
-- Transaction history
CREATE TABLE b_cashback_transaction (
ID INT AUTO_INCREMENT PRIMARY KEY,
USER_ID INT NOT NULL,
ORDER_ID INT NULL,
TYPE ENUM('earn', 'spend', 'expire', 'adjust') NOT NULL,
AMOUNT DECIMAL(10,2) NOT NULL,
BALANCE_AFTER DECIMAL(10,2) NOT NULL,
DESCRIPTION VARCHAR(500) NOT NULL DEFAULT '',
STATUS ENUM('pending', 'confirmed', 'cancelled') NOT NULL DEFAULT 'pending',
EXPIRES_AT DATE NULL, -- expiration date for accrual transactions
CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_type (USER_ID, TYPE),
INDEX idx_order (ORDER_ID),
INDEX idx_expires (EXPIRES_AT, STATUS)
);
-- Accrual rules
CREATE TABLE b_cashback_rule (
ID INT AUTO_INCREMENT PRIMARY KEY,
NAME VARCHAR(255) NOT NULL,
CONDITION_TYPE ENUM('category', 'brand', 'product', 'order_total', 'all') NOT NULL,
CONDITION_VALUE VARCHAR(1000) NULL, -- JSON: {"iblock_section_ids": [1,2,3]}
CASHBACK_PERCENT DECIMAL(5,2) NOT NULL,
MIN_ORDER_AMOUNT DECIMAL(10,2) NOT NULL DEFAULT 0.00,
ACTIVE CHAR(1) NOT NULL DEFAULT 'Y',
SORT INT NOT NULL DEFAULT 100,
DATE_FROM DATE NULL,
DATE_TO DATE NULL,
INDEX idx_active_sort (ACTIVE, SORT)
);
Calculating Cashback Percentage for Cart Items
Rules are applied by priority (SORT). For each cart item, we find the most applicable rule:
// /local/lib/Cashback/RuleCalculator.php
namespace Local\Cashback;
class RuleCalculator
{
public static function calculateForOrder(\Bitrix\Sale\Order $order): array
{
$result = [];
$basket = $order->getBasket();
$activeRules = self::getActiveRules();
foreach ($basket as $basketItem) {
$productId = $basketItem->getProductId();
$price = $basketItem->getFinalPrice();
$qty = $basketItem->getQuantity();
// Get the product's category and brand associations
$productMeta = self::getProductMeta($productId);
$matchedRule = self::findRule($productMeta, $order->getPrice(), $activeRules);
if ($matchedRule) {
$cashbackAmount = round($price * $qty * $matchedRule['CASHBACK_PERCENT'] / 100, 2);
$result[] = [
'PRODUCT_ID' => $productId,
'PRODUCT_NAME' => $basketItem->getField('NAME'),
'RULE_ID' => $matchedRule['ID'],
'RULE_NAME' => $matchedRule['NAME'],
'PERCENT' => $matchedRule['CASHBACK_PERCENT'],
'CASHBACK_AMOUNT' => $cashbackAmount,
];
}
}
return $result;
}
private static function findRule(array $productMeta, float $orderTotal, array $rules): ?array
{
foreach ($rules as $rule) {
if ($rule['MIN_ORDER_AMOUNT'] > 0 && $orderTotal < $rule['MIN_ORDER_AMOUNT']) {
continue;
}
switch ($rule['CONDITION_TYPE']) {
case 'all':
return $rule;
case 'category':
$catIds = json_decode($rule['CONDITION_VALUE'], true)['iblock_section_ids'] ?? [];
if (array_intersect($productMeta['SECTION_IDS'], $catIds)) {
return $rule;
}
break;
case 'brand':
$brands = json_decode($rule['CONDITION_VALUE'], true)['brands'] ?? [];
if (in_array($productMeta['BRAND'], $brands)) {
return $rule;
}
break;
case 'product':
$productIds = json_decode($rule['CONDITION_VALUE'], true)['product_ids'] ?? [];
if (in_array($productMeta['ID'], $productIds)) {
return $rule;
}
break;
}
}
return null;
}
private static function getProductMeta(int $productId): array
{
$el = \CIBlockElement::GetByID($productId)->GetNextElement();
if (!$el) {
return ['ID' => $productId, 'SECTION_IDS' => [], 'BRAND' => ''];
}
$fields = $el->GetFields();
$props = $el->GetProperties();
// Collect all sections (including parent sections)
$sectionIds = [];
if ($fields['IBLOCK_SECTION_ID']) {
$sectionIds = \Local\Catalog\SectionHelper::getParentIds(
(int)$fields['IBLOCK_SECTION_ID']
);
}
return [
'ID' => $productId,
'SECTION_IDS' => $sectionIds,
'BRAND' => $props['BRAND']['VALUE'] ?? '',
];
}
}
Cashback Accrual: pending → confirmed
Cashback is accrued with pending status immediately after order placement and confirmed once the order is fulfilled (status F). This protects against refunds:
// Accrual on order creation (pending)
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'sale', 'OnSaleOrderSaved',
function (\Bitrix\Main\Event $event) {
$order = $event->getParameter('ENTITY');
if (!$order->isNew()) {
return;
}
$calculations = \Local\Cashback\RuleCalculator::calculateForOrder($order);
$totalCashback = array_sum(array_column($calculations, 'CASHBACK_AMOUNT'));
if ($totalCashback <= 0) {
return;
}
$holdDays = (int)\Bitrix\Main\Config\Option::get('local.cashback', 'hold_days', 14);
\Local\Cashback\AccountManager::createTransaction(
$order->getUserId(),
'earn',
$totalCashback,
"Cashback for order #{$order->getId()} (awaiting confirmation)",
$order->getId(),
'pending',
date('Y-m-d', strtotime("+{$holdDays} days"))
);
}
);
// Confirmation on order fulfillment
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'sale', 'OnSaleOrderStatusChange',
function (\Bitrix\Main\Event $event) {
$order = $event->getParameter('ENTITY');
if ($order->getField('STATUS_ID') !== 'F') {
return;
}
\Local\Cashback\AccountManager::confirmOrderTransactions($order->getId());
}
);
Cashback Redemption at Payment
Cashback can be used to pay for part of the next order. The limit is no more than 50% of the order total (or another percentage from settings):
// /local/lib/Cashback/PaymentProcessor.php
class PaymentProcessor
{
public static function applyToOrder(
\Bitrix\Sale\Order $order,
float $cashbackToSpend
): \Bitrix\Main\Result {
$result = new \Bitrix\Main\Result();
$userId = $order->getUserId();
$maxSpend = $order->getPrice() * 0.5; // maximum 50%
$available = AccountManager::getBalance($userId);
$toSpend = min($cashbackToSpend, $available, $maxSpend);
if ($toSpend <= 0) {
$result->addError(new \Bitrix\Main\Error('Insufficient cashback'));
return $result;
}
// Create a discount in 1C-Bitrix
$discount = \Bitrix\Sale\OrderDiscount::create($order);
$discount->setFields([
'DISCOUNT_VALUE' => $toSpend,
'DISCOUNT_TYPE' => 'F', // Fixed amount
'DISCOUNT_NAME' => 'Cashback payment',
]);
// Record the spend transaction
AccountManager::createTransaction(
$userId,
'spend',
$toSpend,
"Cashback redeemed against order #{$order->getId()}",
$order->getId(),
'confirmed'
);
$result->setData(['applied' => $toSpend]);
return $result;
}
}
Expiration of Unused Cashback
A 1C-Bitrix agent runs daily to expire overdue cashback:
// Agent: \Local\Cashback\ExpirationAgent::run()
$connection = \Bitrix\Main\Application::getConnection();
// Find expired pending/confirmed transactions
$expired = $connection->query("
SELECT USER_ID, SUM(AMOUNT) as TOTAL_AMOUNT
FROM b_cashback_transaction
WHERE TYPE = 'earn'
AND STATUS = 'confirmed'
AND EXPIRES_AT IS NOT NULL
AND EXPIRES_AT < CURDATE()
GROUP BY USER_ID
")->fetchAll();
foreach ($expired as $row) {
AccountManager::createTransaction(
$row['USER_ID'],
'expire',
$row['TOTAL_AMOUNT'],
'Cashback expired',
null,
'confirmed'
);
// Mark expired transactions as processed
$connection->queryExecute("
UPDATE b_cashback_transaction
SET STATUS = 'cancelled'
WHERE USER_ID = ? AND TYPE = 'earn' AND STATUS = 'confirmed'
AND EXPIRES_AT < CURDATE()
", [$row['USER_ID']]);
}
Personal Account: History and Balance
In the customer's personal account — a cashback block:
// Template data
$balance = \Local\Cashback\AccountManager::getBalance($USER->GetID());
$history = \Local\Cashback\AccountManager::getHistory($USER->GetID(), 10);
$pending = \Local\Cashback\AccountManager::getPendingAmount($USER->GetID());
Displayed: current balance, pending amount (from incomplete orders), transaction history with date and description.
Development Timeline
| Stage | Content | Duration |
|---|---|---|
| Data schema | Tables, indexes, Account Manager | 1–2 days |
| Accrual rules | CRUD admin interface + calculator | 2–3 days |
| Accrual and confirmation | Order event handlers | 1–2 days |
| Redemption at payment | Integration with 1C-Bitrix discounts | 2–3 days |
| Expiration and agent | Agent + expiry logic | 1 day |
| Personal account | History, balance, payment interface | 2–3 days |







