Зачем нужны автоматические купоны ко дню рождения в e-commerce
Поздравление покупателя с днём рождения — один из самых конверсионных инструментов в email-маркетинге интернет-магазина. По данным исследований Experian, письма с купонами ко дню рождения открывают в 2,5 раза чаще, чем обычные промо-рассылки, а их конверсия в покупку выше в несколько раз. Причина проста: человек уже лоялен к магазину, он ждёт внимания, и персонализированное предложение воспринимается не как спам, а как подарок.
Проблема возникает сразу, как только база покупателей вырастает до нескольких тысяч человек. Ручная выдача купонов через панель администратора — это десятки минут ежедневной рутины. Маркетолог должен зайти в Битрикс, найти всех именинников, создать купон, скопировать код и отправить письмо. На практике это либо не делается вовсе, либо делается нерегулярно — и маркетинговый инструмент теряет эффективность.
В этой статье я покажу законченное продакшн-решение, которое автоматизирует весь цикл: ежедневный cron обходит всех активных пользователей, находит именинников через 5 дней, создаёт одноразовый купон через DiscountCouponTable и отправляет письмо через почтовое событие Битрикс. Предусмотрена защита от повторной выдачи купона в одном году, корректная обработка 29 февраля и ручной режим для единичного выпуска по ID или email пользователя.
Как работает система: общая схема автоматической выдачи купонов
Весь процесс выглядит следующим образом:
cron (birthday_coupon_cron.php)
│
▼
sendCouponsInBatches()
│
├─── resolveDiscount() ←── DiscountTable::getList() по ID 125
│ │ или fallback по NAME / DISCOUNT_VALUE
│ ▼
│ [discount array]
│
├─── processBatch() ←── loadBirthdayUsersBatch() (lastUserId пагинация)
│ │
│ ├─ parseBirthday() → месяц + день из PERSONAL_BIRTHDAY
│ ├─ resolveNextBirthdayDate() → ближайшая дата ДР
│ ├─ DAYS_BEFORE_BIRTHDAY === 5 → пропуск всех остальных
│ ├─ hasCouponForYear() → дубль за год? пропуск
│ ├─ createCoupon() → DiscountCouponTable::add()
│ └─ sendCouponEmail() → CEvent::Send() + fallback mail()
│
└─── log('done', [...]) → /local/logs/birthday_coupon_sender.log
Система даёт три ключевые гарантии:
Не дублировать. Перед созданием купона метод
hasCouponForYear()проверяет, не был ли уже выдан купон этому пользователю в текущем году. Проверка идёт по строке-маркеру в полеDESCRIPTIONтаблицы купонов — никаких дополнительных таблиц.Не пропускать 29 февраля. Метод
buildBirthdayDate()обрабатывает ситуацию, когда в невисокосный год нельзя создать дату 29 февраля: в таком случае купон выдаётся 28 февраля, и пользователь не остаётся без поздравления.Не падать при отсутствии правила скидки. Метод
resolveDiscount()имеет три уровня поиска: по фиксированному ID, затем по точному совпадению процента, затем по ключевым словам в имени. Если ничего не найдено — процесс завершается с информативным JSONL-сообщением в лог, но не бросает исключение.
Архитектура класса BirthdayCouponSender в 1С-Битрикс
Класс объявлен как final — это намеренное решение. Логика выдачи купонов — специализированная и сплочённая; наследование здесь создало бы только риски нарушить инварианты (например, переопределить hasCouponForYear() и сломать защиту от дублей). final явно сигнализирует коллегам и IDE: этот класс не предназначен для расширения.
Все методы статические, потому что класс не хранит изменяемого состояния между вызовами. Единственный исключение — статическое свойство $lastResolveDiscountError, которое накапливает диагностическое сообщение о причине неудачного поиска скидки и передаётся наружу через публичные методы. Это позволяет избежать исключений для штатной ситуации «скидка не настроена».
Публичный API класса минимален и осознан:
| Метод | Что делает | Доступ |
|---|---|---|
sendCouponsInBatches(batchSize, maxBatches, sendEmail) |
Батчевый обход всех активных пользователей | публичный |
issueCouponForUser(userId, sendEmail) |
Ручной выпуск купона на одного пользователя | публичный |
ensureAgentRegistered() |
Регистрация CAgent (альтернатива cron) | публичный |
runAgent() |
Обёртка для вызова из CAgent | публичный |
sendCoupons() |
Алиас для sendCouponsInBatches() с параметрами по умолчанию |
публичный |
resolveDiscount() |
Поиск правила скидки (три уровня) | приватный |
processBatch() |
Обработка одной пачки пользователей | приватный |
loadBirthdayUsersBatch() |
Запрос пользователей через UserTable | приватный |
hasCouponForYear() |
Проверка дубля через DESCRIPTION | приватный |
createCoupon() |
Создание записи в DiscountCouponTable | приватный |
sendCouponEmail() |
Отправка через CEvent::Send + fallback | приватный |
parseBirthday() |
Парсинг PERSONAL_BIRTHDAY в 7 форматах | приватный |
resolveNextBirthdayDate() |
Вычисление ближайшей даты ДР | приватный |
buildBirthdayDate() |
Создание DateTimeImmutable с учётом 29 фев | приватный |
buildCouponMarker() |
Генерация строки-маркера для DESCRIPTION | приватный |
log() |
JSONL-запись в файл | приватный |
Шаг 1: подключение класса в init.php
Класс BirthdayCouponSender размещается в /local/php_interface/lib/Local/User/BirthdayCouponSender.php и подключается через /local/php_interface/init.php:
use Local\User\BirthdayCouponSender;
$birthdayCouponSenderPath = __DIR__. '/lib/Local/User/BirthdayCouponSender.php';
if (file_exists($birthdayCouponSenderPath)) {
require_once $birthdayCouponSenderPath;
}
Обратите внимание на file_exists перед require_once. В init.php ошибка подключения файла приводит к фатальной ошибке PHP и падению всего сайта — поэтому проверка существования файла обязательна. require_once вместо require гарантирует, что класс не будет подключён дважды при возможном повторном включении init.php.
Если вы используете автозагрузку через Composer или собственный PSR-4 загрузчик в Битрикс, эту секцию init.php можно пропустить — класс будет найден автоматически по пространству имён LocalUser.
Шаг 2: батчевый cron для автоматической рассылки купонов ко дню рождения
Основной сценарий запуска — ежедневный cron. Скрипт размещается в /local/cli/birthday_coupon_cron.php:
<?php
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Forbidden');
}
$_SERVER['DOCUMENT_ROOT'] = realpath( __DIR__. '/../..');
$DOCUMENT_ROOT = $_SERVER['DOCUMENT_ROOT'];
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Local\User\BirthdayCouponSender;
$batchSize = BirthdayCouponSender::DEFAULT_BATCH_SIZE;
$maxBatches = 0;
$sendEmail = true;
foreach (array_slice($argv, 1) as $argument) {
if (preg_match('/^--batch=(\d+)$/', (string)$argument, $matches)) {
$batchSize = max(1, (int)$matches[1]);
continue;
}
if (preg_match('/^--max-batches=(\d+)$/', (string)$argument, $matches)) {
$maxBatches = max(0, (int)$matches[1]);
continue;
}
if ((string)$argument === '--no-email') {
$sendEmail = false;
}
}
$result = BirthdayCouponSender::sendCouponsInBatches($batchSize, $maxBatches, $sendEmail);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
exit(($result['success'] ?? false) ? 0 : 1);
Почему PHP_SAPI !== 'cli'. Скрипт использует $argv, ручное задание DOCUMENT_ROOT и prolog_before.php с неполной инициализацией сессий. Запуск через браузер привёл бы к ошибкам и потенциально к несанкционированной выдаче купонов. Проверка PHP_SAPI на первой строке — стандартная практика для CLI-скриптов Битрикс.
Константы NO_KEEP_STATISTIC и BX_NO_ACCELERATOR_RESET отключают запись статистики посещений и сброс кэша акселератора. Без них cron-скрипт создаёт «мусорные» записи в статистике и может непреднамеренно инвалидировать кэши.
Флаги командной строки:
-
--batch=N— размер пачки пользователей (по умолчанию 500). Уменьшайте при ограничении памяти. -
--max-batches=N— максимальное число итераций. По умолчанию 0 — обходить всех пользователей. Задайте 10–20, если cron ограничен по времени выполнения. -
--no-email— создать купоны без отправки писем (полезно для тестирования).
Запись в crontab:
0 8 * * * /usr/bin/php /var/www/bitrix/local/cli/birthday_coupon_cron.php >> /var/log/birthday_coupon_cron.log 2>&1
Запуск в 8:00 каждый день. Путь к PHP и к документ-руту замените на актуальные для вашего сервера. Вывод JSON-результата сохраняется в отдельный системный лог — это удобно для мониторинга через tail -f.
Шаг 3: ручной выпуск купона для конкретного пользователя
Для поддержки и маркетинговых акций предусмотрен CLI-скрипт birthday_coupon_issue.php, который выдаёт купон на одного пользователя:
<?php
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Forbidden');
}
$_SERVER['DOCUMENT_ROOT'] = realpath( __DIR__. '/../..');
$DOCUMENT_ROOT = $_SERVER['DOCUMENT_ROOT'];
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Bitrix\Main\UserTable;
use Local\User\BirthdayCouponSender;
$userId = 0;
$email = '';
$sendEmail = true;
foreach (array_slice($argv, 1) as $argument) {
if (preg_match('/^--user=(\d+)$/', (string)$argument, $matches)) {
$userId = (int)$matches[1];
continue;
}
if (preg_match('/^--email=(.+)$/', (string)$argument, $matches)) {
$email = trim((string)$matches[1]);
continue;
}
if ((string)$argument === '--no-email') {
$sendEmail = false;
}
}
if ($userId <= 0 && $email !== '') {
$user = UserTable::getList([
'select' => ['ID'],
'filter' => ['=EMAIL' => $email],
'limit' => 1,
])->fetch();
$userId = (int)($user['ID'] ?? 0);
}
if ($userId <= 0) {
echo json_encode([
'success' => false,
'message' => 'Передайте --user=ID или --email=user@example.com',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
exit(1);
}
$result = BirthdayCouponSender::issueCouponForUser($userId, $sendEmail);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
exit(($result['success'] ?? false) ? 0 : 1);
Примеры запуска:
# По ID пользователя, с отправкой письма
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --user=42
# По email, без отправки письма (только создать купон)
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --email=user@example.com --no-email
# По ID без письма
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --user=42 --no-email
Скрипт возвращает JSON с полями success, coupon, birthday_date, active_to, email_sent. При успехе код завершения — 0, при ошибке — 1, что позволяет использовать скрипт в Makefile или CI-пайплайнах.
Защита от дублей: маркер в поле DESCRIPTION купона
Главная проблема ежедневного cron — повторная выдача купона пользователю, которому он уже был выдан в этом году. Наивное решение — проверять наличие купона по USER_ID и диапазону дат — ненадёжно: если даты хранятся в разных форматах или купон был продлён вручную, фильтр может дать ложноотрицательный результат.
В этом решении используется строка-маркер, которая записывается в поле DESCRIPTION каждого созданного купона:
birthday_coupon:user=42;year=2026
Метод hasCouponForYear() выполняет точный поиск по этой строке:
private static function hasCouponForYear(int $userId, int $year): bool
{
$existingCoupon = DiscountCouponTable::getList([
'select' => ['ID'],
'filter' => [
'=USER_ID' => $userId,
'=DESCRIPTION' => self::buildCouponMarker($userId, $year),
],
'limit' => 1,
])->fetch();
return is_array($existingCoupon);
}
private static function buildCouponMarker(int $userId, int $year): string
{
return 'birthday_coupon:user=' . $userId . ';year=' . $year;
}
Почему поле DESCRIPTION, а не отдельная таблица? Таблица купонов в Битрикс уже хранит все необходимые данные. Добавлять отдельную таблицу для маркера — это усложнение без выгоды: нужно создавать таблицу, следить за её миграциями, удалять устаревшие записи. Поле DESCRIPTION доступно из коробки, индексируется через фильтр D7 ORM и не требует никакой дополнительной инфраструктуры. Строка birthday_coupon:user=N;year=YYYY достаточно уникальна, чтобы исключить ложные совпадения с другими купонами, где поле DESCRIPTION тоже может быть заполнено.
Поиск правила скидки: три уровня fallback в resolveDiscount
Метод resolveDiscount() реализует устойчивый поиск правила скидки с тремя уровнями:
Уровень 1 — фиксированный ID. Сначала ищется правило с ID = 125 (константа DISCOUNT_ID). Это самый быстрый путь: один запрос к БД. Если правило найдено, но неактивно или в нём отключены купоны (USE_COUPONS !== 'Y') — метод возвращает null с диагностическим сообщением.
Уровень 2 — точное совпадение параметров. Если правило с ID 125 не найдено на текущем сайте, происходит поиск всех активных правил с купонами, где DISCOUNT_TYPE = 'P' (процентная скидка) и DISCOUNT_VALUE = 5.0. Среди найденных выбирается лучший кандидат через pickBestDiscount().
Уровень 3 — поиск по ключевым словам. Если второй уровень не дал результата, поиск расширяется до всех активных правил с купонами. pickBestDiscount() присваивает каждому правилу очки: +100 за каждое ключевое слово из DISCOUNT_NAME_HINTS (день рождения, birthday, ДР и другие) в поле NAME или XML_ID, +20 за совпадение процента. Правило с максимальным счётом становится победителем.
private const DISCOUNT_NAME_HINTS = [
'день рождения',
'деньрождения',
'birthday',
'birth day',
'др',
];
Почему важен fallback? На боевых проектах правила скидок нередко пересоздаются после импорта или миграции между стендами — ID меняется. Трёхуровневый поиск позволяет системе найти нужное правило даже после таких операций, без ручного вмешательства в код.
Парсинг PERSONAL_BIRTHDAY и проблема 29 февраля в Битрикс
Поле PERSONAL_BIRTHDAY хранится в таблице b_user как строка и не имеет жёстко заданного формата. На реальных проектах встречаются все возможные варианты в зависимости от истории импортов и форм регистрации. Метод parseBirthday() последовательно перебирает семь форматов через DateTimeImmutable::createFromFormat:
$formats = [
'd.m.Y', // 15.03.1990 — основной формат Битрикс
'd.m.Y H:i:s', // 15.03.1990 00:00:00 — с временем
'd.m.y', // 15.03.90 — двузначный год
'Y-m-d', // 1990-03-15 — ISO 8601
'Y-m-d H:i:s', // 1990-03-15 00:00:00
'd/m/Y', // 15/03/1990
'm/d/Y', // 03/15/1990 — американский формат
];
Если ни один формат не подошёл, делается попытка через strtotime() — это последний шанс для нестандартных строк вида «15 марта 1990».
Метод возвращает только месяц и день — год рождения намеренно игнорируется, чтобы не зависеть от возможных ошибок ввода (пользователи нередко указывают год неверно).
Проблема 29 февраля. Если пользователь родился 29 февраля, то в невисокосный год эту дату создать невозможно. Метод buildBirthdayDate() обрабатывает этот случай явно:
private static function buildBirthdayDate(int $year, int $month, int $day): ?\DateTimeImmutable
{
if (checkdate($month, $day, $year)) {
return new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
if ($month === 2 && $day === 29) {
return new \DateTimeImmutable(sprintf('%04d-02-28', $year));
}
return null;
}
В невисокосный год именинники 29 февраля получат купон 28 февраля — это наиболее справедливое поведение. В следующий високосный год checkdate(2, 29, year) вернёт true, и купон выйдет в правильный день.
Почтовое событие USER_BIRTHDAY_COUPON: настройка в Битрикс
Для отправки письма используется почтовое событие Битрикс. Его нужно создать в разделе «Настройки → Почта → Типы почтовых событий» с именем USER_BIRTHDAY_COUPON и добавить шаблон письма.
Метод sendCouponEmail() передаёт в событие следующие поля:
| Поле | Значение | Описание |
|---|---|---|
EMAIL_TO |
email пользователя | Адрес получателя |
USER_NAME |
Имя Фамилия или login | Имя для обращения |
COUPON |
Код купона | Строка вида BDAY-A1B2C3
|
DISCOUNT_PERCENT |
5 |
Процент скидки |
BIRTHDAY_DATE |
dd.mm.YYYY |
Дата дня рождения |
COUPON_ACTIVE_TO |
dd.mm.YYYY |
Последний день действия купона |
DAYS_BEFORE_BIRTHDAY |
5 |
За сколько дней выдаётся купон |
В шаблоне письма обращайтесь к этим полям через синтаксис #COUPON#, #USER_NAME#, #COUPON_ACTIVE_TO#.
Fallback через mail(). Если CEvent::Send() вернула false (событие не настроено или почтовый транспорт недоступен), метод sendFallbackMail() отправляет упрощённое текстовое письмо напрямую через mail(). Это обеспечивает минимальную отказоустойчивость. В production рекомендуется всегда настраивать событие и шаблон — fallback предназначен только для экстренных ситуаций.
Логирование в JSONL-формате
Каждое действие системы записывается в файл /local/logs/birthday_coupon_sender.log в формате JSONL (JSON Lines — одна запись в строку):
{"ts":"2026-06-27T08:00:01+03:00","level":"sent","context":{"user_id":42,"email":"user@example.com","coupon":"BDAY-X1Y2Z3","birthday_date":"01.07.2026","active_to":"06.07.2026"}}
{"ts":"2026-06-27T08:00:01+03:00","level":"skip","context":{"reason":"birthday_coupon already exists for this year","user_id":15}}
{"ts":"2026-06-27T08:00:05+03:00","level":"done","context":{"success":true,"processed_users":1250,"matched_users":3,"sent_coupons":2,"batches":3,"discount_id":125}}
Уровни записей:
-
sent— купон создан и письмо отправлено -
coupon_created_without_email— купон создан, письмо не отправлялось (флаг--no-email) -
skip— пользователь пропущен (нет ДР, дубль за год, неактивен, не найдена скидка) -
error— ошибка при создании купона или другая исключительная ситуация -
done— итоговая запись после завершения всей батчевой обработки
Для просмотра последних ошибок:
grep '"level":"error"' /var/www/bitrix/local/logs/birthday_coupon_sender.log | tail -20
Для подсчёта купонов за день:
grep '"level":"sent"' /var/www/bitrix/local/logs/birthday_coupon_sender.log | grep "2026-06-27" | wc -l
Типичные ошибки при внедрении автоматических купонов в Битрикс
Купоны создаются повторно каждый год
Причина: при создании купона не записывается маркер в поле DESCRIPTION, или hasCouponForYear() ищет не по точному совпадению строки, а по LIKE.
Решение: убедитесь, что createCoupon() передаёт buildCouponMarker($userId, $year) в ключ DESCRIPTION при вызове DiscountCouponTable::add(). Фильтр '=DESCRIPTION' в hasCouponForYear() должен использовать оператор точного равенства (префикс =).
Скрипт не находит правило скидки
Причина: правило скидки создано не для текущего SITE_ID, или поле USE_COUPONS установлено в N, или правило неактивно.
Решение: проверьте лог — там будет конкретная причина отказа. Войдите в администраторскую панель, откройте правило с ID 125, убедитесь, что оно активно, привязано к нужному сайту и в нём включены купоны. Если правило было пересоздано с другим ID — обновите константу DISCOUNT_ID в классе.
Письмо не отправляется
Причина: почтовое событие USER_BIRTHDAY_COUPON не создано или для него нет активного шаблона.
Решение: создайте тип почтового события в разделе «Настройки → Почта → Типы почтовых событий» и добавьте шаблон с перечисленными выше полями. Проверьте раздел «Настройки → Почта → Очередь почтовых сообщений» — письмо может быть в очереди, а не отправлено. Также убедитесь, что настроен почтовый транспорт (SMTP или sendmail).
Cron падает с ошибкой «prolog_before.php: no such file»
Причина: переменная DOCUMENT_ROOT задаётся через realpath( __DIR__. '/../..'), но скрипт запускается из другой рабочей директории, и относительный путь не разрешается корректно.
Решение: используйте абсолютный путь к скрипту в crontab: php /var/www/html/local/cli/birthday_coupon_cron.php. Проверьте, что realpath( __DIR__. '/../..') на вашем сервере возвращает корень сайта, а не false. В случае проблем замените на явный абсолютный путь: $_SERVER['DOCUMENT_ROOT'] = '/var/www/html';.
Чеклист проверки после внедрения купонов ко дню рождения
Перед переводом в production пройдитесь по этим шести пунктам:
- Правило скидки с ID 125 активно, привязано к нужному сайту, купоны включены (
USE_COUPONS = Y) - Почтовое событие
USER_BIRTHDAY_COUPONсоздано, шаблон активен, все поля в шаблоне написаны верно - Тестовый запуск
--no-emailзавершился без ошибок:grep '"level":"error"' /local/logs/birthday_coupon_sender.log— пусто - Ручной выпуск
birthday_coupon_issue.php --user=YOUR_TEST_IDсоздал купон и прислал письмо - В таблице купонов (Битрикс → Маркетинг → Купоны) появился купон с правильным
DESCRIPTION, датами и привязкой к нужному правилу - Cron-задача зарегистрирована, проверьте
crontab -lи убедитесь, что путь к PHP и к скрипту верны
Заключение: масштабируемость и дальнейшее развитие
Решение работает стабильно на базах с десятками тысяч пользователей: пагинация через lastUserId вместо OFFSET исключает деградацию производительности при росте таблицы. Размер пачки и лимит батчей позволяют точно подстроить потребление ресурсов под ограничения вашего хостинга. JSONL-лог совместим с Loki, Elastic и любыми другими агрегаторами логов без дополнительной обработки.
Возможные направления развития: расширить процент скидки в зависимости от сегмента покупателя, добавить SMS-канал через CEvent или сторонний провайдер, ввести настройку DAYS_BEFORE_BIRTHDAY через публичный конструктор вместо константы. Практическое руководство по скидкам и купонам (в т.ч. DiscountCouponTable::add()) — в официальной документации Битрикс: Комплекты, наборы и скидки. Полная справка по CEvent::Send — в разделе справочника по модулю main.

Top comments (0)