Настройка рекуррентных платежей на 1С-Битрикс
Рекуррентный платёж — автоматическое списание по сохранённым платёжным данным без участия покупателя. Ключевой нюанс, который часто упускают при проектировании: данные карты (номер, CVV) никогда не хранятся на сервере магазина. Магазин хранит только токен — непрозрачный идентификатор, выданный банком. При следующих списаниях передаётся только он.
Механизм токенизации
При первой оплате покупатель вводит карту на форме банка. Если в запросе передан флаг рекурренции, банк возвращает rebill_id (или payment_method_id — называния у разных эквайеров разные). Этот ID привязан к карте внутри банковской системы. Магазин хранит его в базе.
Поддержка по эквайерам
| Эквайер | Флаг первого платежа | Метод повторного списания |
|---|---|---|
| Тинькофф | Recurrent: 'Y' |
POST /v2/Charge + RebillId |
| ЮКасса | save_payment_method: true |
POST /payments + payment_method_id |
| CloudPayments | createToken: true |
POST /payments/tokens/charge |
| Сбербанк | clientId в Init |
paymentOrderBinding.do |
Первый платёж: инициализация токена (Тинькофф)
$params = [
'TerminalKey' => TINKOFF_TERMINAL,
'Amount' => (int)($order->getPrice() * 100),
'OrderId' => $order->getAccountNumber(),
'Description' => 'Подписка — первый платёж',
'Recurrent' => 'Y', // ← флаг токенизации
'CustomerKey' => (string)$userId, // уникальный ключ покупателя
'NotificationURL' => 'https://shop.ru/bitrix/tools/sale_ps_result.php',
];
$params['Token'] = tinkoffSign($params, TINKOFF_SECRET);
$response = tinkoffPost('/v2/Init', $params);
// Редирект на $response['PaymentURL']
В webhook после успешной оплаты придёт RebillId:
$data = json_decode(file_get_contents('php://input'), true);
if ($data['Status'] === 'CONFIRMED' && !empty($data['RebillId'])) {
// Сохраняем rebill_id для будущих списаний
$db->query("INSERT INTO b_user_payment_tokens
(user_id, paysystem, rebill_id, card_mask, card_type)
VALUES (?, 'tinkoff', ?, ?, ?)",
[$data['CustomerKey'], $data['RebillId'], $data['Pan'] ?? '', $data['CardType'] ?? '']
);
}
Повторное списание без участия покупателя
function chargeRecurring(string $customerKey, int $amountKopecks, string $newOrderId): bool
{
$rebillId = getRebillId($customerKey, 'tinkoff');
// Шаг 1: инициализируем новый платёж
$init = tinkoffPost('/v2/Init', [
'TerminalKey' => TINKOFF_TERMINAL,
'Amount' => $amountKopecks,
'OrderId' => $newOrderId,
'CustomerKey' => $customerKey,
'Recurrent' => 'Y',
'Token' => tinkoffSign([...], TINKOFF_SECRET),
]);
// Шаг 2: списываем по RebillId
$charge = tinkoffPost('/v2/Charge', [
'TerminalKey' => TINKOFF_TERMINAL,
'PaymentId' => $init['PaymentId'],
'RebillId' => $rebillId,
'Token' => tinkoffSign([...], TINKOFF_SECRET),
]);
return $charge['Success'] ?? false;
}
Хранение токенов
CREATE TABLE b_user_payment_tokens (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
paysystem VARCHAR(32) NOT NULL,
rebill_id VARCHAR(128) NOT NULL,
card_mask VARCHAR(20),
card_type VARCHAR(10),
created_at TIMESTAMP DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
Retry-логика при отказе карты
foreach (getFailedCharges() as $sub) {
// Повторяем через 1, 3, 7 дней
$delays = [1, 3, 7];
$delay = $delays[$sub['retry_count']] ?? 7;
if (daysSinceLastAttempt($sub) < $delay) continue;
if ($sub['retry_count'] >= 3) {
suspendSubscription($sub['id']);
sendSuspendedEmail($sub['user_id']);
continue;
}
$success = chargeRecurring($sub['customer_key'], $sub['amount'], generateOrderId());
updateRetryCount($sub['id'], $success);
}
Сроки
| Задача | Срок |
|---|---|
| Первый платёж с токенизацией | 1 день |
| Автосписание + хранение токенов | 1–2 дня |
| Retry-логика и уведомления | 0.5–1 день |
| ЛК управления картами | 1–2 дня |







