Developing a group purchasing 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
    1189
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    813
  • 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
    657
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

Developing a Group Buying System for 1C-Bitrix

A group purchase is a mechanism where the product's price drops as the number of participants grows. Box-standard Bitrix can't do this: the sale module handles individual orders, and the discount mechanism b_catalog_discount is not tied to a real-time participant counter. All logic is written on top of the standard architecture.

Data Model

The system is built around the "group purchase promotion" entity. It's most convenient to store it in a separate HL block or custom table.

Table b_group_deal:

CREATE TABLE b_group_deal (
    ID INT AUTO_INCREMENT PRIMARY KEY,
    PRODUCT_ID INT NOT NULL,          -- ID of infoblock element / SKU
    DATE_START DATETIME NOT NULL,
    DATE_END DATETIME NOT NULL,
    MIN_PARTICIPANTS INT NOT NULL,    -- minimum to activate discount
    MAX_PARTICIPANTS INT,             -- participant limit (NULL = unlimited)
    CURRENT_COUNT INT DEFAULT 0,
    STATUS ENUM('active','success','failed','closed') DEFAULT 'active',
    INDEX idx_product (PRODUCT_ID),
    INDEX idx_status_date (STATUS, DATE_END)
);

Table b_group_deal_tier — discount scale:

CREATE TABLE b_group_deal_tier (
    ID INT AUTO_INCREMENT PRIMARY KEY,
    DEAL_ID INT NOT NULL,
    PARTICIPANTS_FROM INT NOT NULL,   -- from N participants
    DISCOUNT_PERCENT DECIMAL(5,2),   -- discount in %
    PRICE_FIXED DECIMAL(10,2),        -- or fixed price
    FOREIGN KEY (DEAL_ID) REFERENCES b_group_deal(ID)
);

Table b_group_deal_participant — participants:

CREATE TABLE b_group_deal_participant (
    ID INT AUTO_INCREMENT PRIMARY KEY,
    DEAL_ID INT NOT NULL,
    USER_ID INT NOT NULL,
    ORDER_ID INT,                     -- NULL until order confirmed
    DATE_ADD DATETIME NOT NULL,
    STATUS ENUM('waiting','paid','cancelled','refunded')
);

Counter CURRENT_COUNT is updated only by paid status — preliminary participation without payment doesn't count.

Participation Logic and Counter Synchronization

The main engineering problem is race condition when multiple participants join simultaneously. If two clients simultaneously read CURRENT_COUNT = 9 when MIN_PARTICIPANTS = 10, both might become the "activating" participant.

Protection — atomic update:

use Bitrix\Main\Application;

$connection = Application::getConnection();
$connection->startTransaction();

try {
    // Lock the promotion row
    $row = $connection->query(
        "SELECT * FROM b_group_deal WHERE ID = {$dealId} AND STATUS = 'active' FOR UPDATE"
    )->fetch();

    if (!$row || strtotime($row['DATE_END']) < time()) {
        $connection->rollbackTransaction();
        return ['error' => 'Deal not available'];
    }

    // Insert participant
    $connection->query(
        "INSERT INTO b_group_deal_participant (DEAL_ID, USER_ID, DATE_ADD, STATUS)
         VALUES ({$dealId}, {$userId}, NOW(), 'waiting')"
    );

    // Increment counter
    $connection->query(
        "UPDATE b_group_deal SET CURRENT_COUNT = CURRENT_COUNT + 1 WHERE ID = {$dealId}"
    );

    $connection->commitTransaction();
} catch (\Exception $e) {
    $connection->rollbackTransaction();
    throw $e;
}

After joining, the participant gets status waiting — they see current progress, but the order is not yet placed. Payment occurs in two scenarios:

Scenario A — Prepayment: participant immediately places and pays an order. If the promotion doesn't reach MIN_PARTICIPANTS by DATE_END, money is automatically refunded via agent handler.

Scenario B — Deferred Order: participant "reserves" a spot without payment. When minimum is reached, all participants get a notification offering to place an order at the reduced price. Deadline — 24–48 hours.

Pricing and Discounts

The current discount is calculated dynamically from the b_group_deal_tier table. Can't use the standard discount system b_catalog_discount — it can't work with dynamic participant counter.

Active tier calculation:

function getActiveTier(int $dealId, int $currentCount): ?array
{
    $connection = Application::getConnection();
    return $connection->query(
        "SELECT * FROM b_group_deal_tier
         WHERE DEAL_ID = {$dealId} AND PARTICIPANTS_FROM <= {$currentCount}
         ORDER BY PARTICIPANTS_FROM DESC
         LIMIT 1"
    )->fetch() ?: null;
}

When adding to cart, price is substituted via OnSaleBasketItemRefreshData handler — we intercept cart recalculation and substitute the price from the active tier instead of the catalog price.

Visual Progress Bar

The progress component is an AJAX widget, updated every 30 seconds. Data is returned by a controller:

// /local/ajax/group-deal-status.php
$deal = $connection->query(
    "SELECT gd.*, gt.DISCOUNT_PERCENT, gt.PARTICIPANTS_FROM as NEXT_TIER
     FROM b_group_deal gd
     LEFT JOIN b_group_deal_tier gt ON gt.DEAL_ID = gd.ID
         AND gt.PARTICIPANTS_FROM > gd.CURRENT_COUNT
     WHERE gd.ID = {$dealId}
     ORDER BY gt.PARTICIPANTS_FROM ASC
     LIMIT 1"
)->fetch();

header('Content-Type: application/json');
echo json_encode([
    'current'    => (int)$deal['CURRENT_COUNT'],
    'next_tier'  => (int)$deal['NEXT_TIER'],
    'discount'   => (float)$deal['DISCOUNT_PERCENT'],
    'time_left'  => strtotime($deal['DATE_END']) - time(),
]);

Progress bar calculates percentage current / next_tier * 100 and shows how many participants are needed for the next discount tier.

Promotion Completion and Refunds

Agent CAgent runs every 5 minutes and checks promotions with expired DATE_END:

  • If CURRENT_COUNT >= MIN_PARTICIPANTS → status success. All waiting participants get a task to place an order (or order is auto-created at minimum tier price).
  • If CURRENT_COUNT < MIN_PARTICIPANTS → status failed. Participants with paid status get a refund via \Bitrix\Sale\PaySystem\Manager::refund() or REST request to payment system.

Notifications — via \Bitrix\Main\Mail\Event::send() with custom email templates like GROUP_DEAL_SUCCESS, GROUP_DEAL_FAILED.

Implementation Timeline

Scale Functionality Timeline
MVP (one promotion, one discount tier, manual management) HL block + handlers + AJAX counter 1–1.5 weeks
Full System (tiers, agents, refunds, participant account) Custom tables + transactions + module + email templates 2–3 weeks
Promotion Marketplace (multiple vendors, storefront) Full-featured module with admin interface + API 4–5 weeks

Critical points — atomicity when updating counter and correctness of refunds when promotion fails. Without transactions and FOR UPDATE, the system inevitably produces incorrect data under concurrent requests.