Developing a cashback system using 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1175
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    811
  • image_bitrix-bitrix-24-1c_development_of_an_online_appointment_booking_widget_for_a_medical_center_594_0.webp
    Development based on Bitrix, Bitrix24, 1C for the company Development of an Online Appointment Booking Widget for a Medical Center
    564
  • image_bitrix-bitrix-24-1c_mirsanbel_458_0.webp
    Development based on 1C Enterprise for MIRSANBEL
    747
  • image_crm_dolbimby_434_0.webp
    Website development on CRM Bitrix24 for DOLBIMBY
    655
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

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