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→ statussuccess. Allwaitingparticipants get a task to place an order (or order is auto-created at minimum tier price). - If
CURRENT_COUNT < MIN_PARTICIPANTS→ statusfailed. Participants withpaidstatus 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.







