Когда перед разработчиками встает вопрос реализации подписки, как это было с нами при разработке LPCloud, многие используют уже готовые решения, например recurly.com, chargify.com, spreedly.com и др. У них есть, конечно, свои плюсы и минусы, но мы так и не смогли найти подходящий сервис, который бы удовлетворял нас по всем факторам и мы решили написать свою собственную систему регулярных платежей. В качестве процессинга карт мы выбрали cloudpayments.ru
Для удобства работы с оплатой по картам, мы заюзали известный gem activemerchant от Shopify, но столкнулись с такой делемой – activemerchant не поддерживал cloudpayments. Мы быстренько решили эту проблему допилив гем, он доступен на нашем аккаунте на гитхабе.
Нам требовалась система, которая бы имела следующие возможности:
Мы не храним данные карт наших клиентов у себя, вместо этого на стороне cloudpayments создаются их токены, которые хранятся в модели
app/models/account.rb
Cloudpayments не передают на сервер данные карты, необходимо сначала сгенерировать криптограмму карты на клиенте, с помощью которой в дальнейшем можно провести платеж и в ответе о результате платежа получить
Чтобы сделать свою собственную форму ввода карт, мы воспользовались checkout скриптом от Cloudpayments. Просто следуем документации…
Подключаем js файл к странице:
Создаем форму для ввода карточных данных, вы можете как угодно кастомизировать внешний вид формы
Поля ввода карточных данных обязательно должны быть помечены аттрибутами:
Вот так выглядит наша форма

Чтобы оградить пользователя от ввода некорректных данных, советуем воспользоваться плагином jquery.payment от Stripe.
Далее необходимо прописать скрипт, который генерирует криптограмму карты и отправляет ее на сервер для совершения первого платежа. Алгоритм действий такой: сначала мы отправляем криптограмму на сервер и проводим первый платеж, в успешном ответе получаем token карты и записываем его в Account, потом используем этот токен для последующих оплат.
В этом методе проводится первая оплата или обновление платежных данных. Если это первая оплата, то деньги спишутся в соответствии с тарифом, если юзер просто обновляет карту, то мы авторизовываем 1 рубль на его счету (если операция пройдет успешно, то рубль сразу освободится). В обоих случаях мы получаем в ответе об операции новые данные карты, включая токен, дату истечения и первые 6 и последние 4 цифры номера карты и записываем ее к пользователю.
Информацию о подписке пользователя мы храним в моделе Subscription. У этой модели есть важное поле
Как вы, наверное, ожидали в центре рекуррентных платежей лежит cron (или ему подобные). С помощью отличного гема whenever мы настроили запуск задачи, которая проверяет кто сегодня должен оплатить подписку и у кого сегодня заканчивается триальный период. Если такие подписки найдены, система шлет email о том, что триальный период закончен, и если карта привязана к LPCloud, система проводит оплату.
config/schedule.rb
Очевидно, когда дело касается денег вы должны быть прозрачными. Мы выводим пользователю информацию о платежах: статус, сумму, дату и предыдущие платежи.

Так же выводим информацию о привязанной карте и возможность прокачать или понизить тариф

Если клиент изменит тариф, то в следующий биллинг день с клиента спишется уже цена нового тарифа.
Самым сложным моментом реализации было проектирование архитектуры, т.к. это мы делали впервые. Наша реализация не идеальна и не всем подойдет, но надеемся, что эта статья подкинет вам пару идей при разработке собственной системы рекуррентных платежей. Мы не затронули тему 3ds авторизации – обещаем рассказать об этом в следующей статье. Будем рады если вы поделитесь своим опытом в комментариях.
Для удобства работы с оплатой по картам, мы заюзали известный gem activemerchant от Shopify, но столкнулись с такой делемой – activemerchant не поддерживал cloudpayments. Мы быстренько решили эту проблему допилив гем, он доступен на нашем аккаунте на гитхабе.
Вкратце
Нам требовалась система, которая бы имела следующие возможности:
- Возможность привязки карт пользователей
- Ежемесячный/ежегодный биллинг
- Индивидуально настраиваемый триальный период для пользователей
- Возможность обновления тарифа клиентом в любое время
Мы не храним данные карт наших клиентов у себя, вместо этого на стороне cloudpayments создаются их токены, которые хранятся в модели
Account
. Эти токены бесполезны без наших public_id и секретного пароля в cloudpaymentsapp/models/account.rb
class Account
include Mongoid::Document
include Mongoid::Timestamps
extend Enumerize
# associations
belongs_to :user, index: true
# fields
field :card_first_six, type: String
field :card_last_four, type: String
field :card_type, type: String
field :issuer_bank_country, type: String
field :token, type: String
field :card_exp_date, type: String
# validations
validates :card_first_six, :card_last_four, :card_type, :user, presence: true
end
Сохраняем карту
Cloudpayments не передают на сервер данные карты, необходимо сначала сгенерировать криптограмму карты на клиенте, с помощью которой в дальнейшем можно провести платеж и в ответе о результате платежа получить
token
карты.Вьюха
Чтобы сделать свою собственную форму ввода карт, мы воспользовались checkout скриптом от Cloudpayments. Просто следуем документации…
Подключаем js файл к странице:
<script src="https://widget.cloudpayments.ru/bundles/checkout"></script>
Создаем форму для ввода карточных данных, вы можете как угодно кастомизировать внешний вид формы
<form id="paymentFormSample" autocomplete="off">
<input type="text" data-cp="cardNumber">
<input type="text" data-cp="name">
<input type="text" data-cp="expDateMonthYear">
<input type="text" data-cp="cvv">
<button type="submit">Привязать карту</button>
</form>
Поля ввода карточных данных обязательно должны быть помечены аттрибутами:
data-cp="cardNumber" — поле с номером карты
data-cp="name" — поле с именем держателя
data-cp="expDateMonthYear" — поле со сроком действия в формате MMYY
data-cp="expDateMonth" — поле с месяцем срока действия
data-cp="expDateYear" — поле с годом срока действия
data-cp="cvv" — поле с кодом CVV
Вот так выглядит наша форма

Чтобы оградить пользователя от ввода некорректных данных, советуем воспользоваться плагином jquery.payment от Stripe.
Далее необходимо прописать скрипт, который генерирует криптограмму карты и отправляет ее на сервер для совершения первого платежа. Алгоритм действий такой: сначала мы отправляем криптограмму на сервер и проводим первый платеж, в успешном ответе получаем token карты и записываем его в Account, потом используем этот токен для последующих оплат.
createCryptogram = function () {
var result = checkout.createCryptogramPacket();
if (result.success) {
cryptogram = result.packet
// сформирована криптограмма, можем отправлять на сервер в метод контроллера purchase, этот метод отвечает success, если все ок, иначе может потребоваться 3ds
}
};
$(function () {
/* Создание checkout */
checkout = new cp.Checkout(
// public id из личного кабинета
"test_api_00000000000000000000001",
// тег, содержащий поля данными карты
document.getElementById("paymentFormSample"));
});
Контроллер
def purchase
updating_card = current_user.account.present?
options = {
:IpAddress => request.ip,
:AccountId => current_user.email,
:Name => params[:name],
:JsonData => { plan: params[:plan], updating_card: updating_card }.to_json,
:Currency => current_subscription.currency,
:Description => "Storing card details"
}
current_subscription.plan = Plan.find(params[:plan]) if params[:plan].present?
amount = updating_card ? 1 : current_subscription.amount
response = gateway.purchase(params[:cryptogram], amount, options, true)
# making response as action controller params
@params = parametrize(response.params)
if response.success?
resp = { json: success_transaction(@params) }
else
# if 3d-secure needed
if @params and @params['PaReq'].present?
resp = { json: { response: @params, type: '3ds' }, status: 422 }
else
resp = { json: { response: response, type: 'error' }, status: 422 }
end
end
render resp
end
private
def gateway
ActiveMerchant::Billing::CloudpaymentsGateway.new(public_id: configatron.cloudpayments.public_id, api_secret: configatron.cloudpayments.api_secret)
end
В этом методе проводится первая оплата или обновление платежных данных. Если это первая оплата, то деньги спишутся в соответствии с тарифом, если юзер просто обновляет карту, то мы авторизовываем 1 рубль на его счету (если операция пройдет успешно, то рубль сразу освободится). В обоих случаях мы получаем в ответе об операции новые данные карты, включая токен, дату истечения и первые 6 и последние 4 цифры номера карты и записываем ее к пользователю.
Рекуррентная часть
Информацию о подписке пользователя мы храним в моделе Subscription. У этой модели есть важное поле
next_payment_due
, в котором хранится дата следующего платежа.class Subscription
include Mongoid::Document
include Mongoid::Timestamps
include AASM
extend Enumerize
belongs_to :user, index: true
belongs_to :plan, index: true
has_many :transactions, dependent: :restrict
default_scope -> { order_by(:created_at.desc) }
field :aasm_state
field :last_charge_error, type: String
field :next_payment_due, type: Date
field :trial_due, type: Date
field :failed_transactions_number, type: Integer, default: 0
field :successful_transactions_number, type: Integer, default: 0
field :plan_duration
enumerize :plan_duration, in: [:month, :year], default: :month, predicates: true, scope: true
scope :billable, -> { self.or({aasm_state: 'active'}, {aasm_state: 'past_due'}) }
scope :billable_on, -> (date) { where(next_payment_due: date) }
scope :trial_due_on, -> (date) { where(trial_due: date) }
validates :plan_duration, presence: true
# callbacks
before_validation :set_trial, on: :create
# [...]
def gateway
ActiveMerchant::Billing::CloudpaymentsGateway.new(public_id: configatron.cloudpayments.public_id, api_secret: configatron.cloudpayments.api_secret)
end
# высчитываем стоимость подписки для пользователя, в сооветствии с его биллинг периодом и валютой
def amount
if plan_duration.year?
user.language.ru? ? plan.clear_price(plan.year_price_rub) : plan.clear_price(plan.year_price_usd)
else
user.language.ru? ? plan.clear_price(plan.price_rub) : plan.clear_price(plan.price_usd)
end
end
def account
user.account
end
# биллим подписку
def renew!
opts = {:Currency => currency, :AccountId => user.email}
response = gateway.purchase(account.token, amount, opts)
update_subscription! response
end
def currency
user.language.ru? ? 'RUB' : 'USD'
end
def update_subscription!(response)
if response.success?
activate_subscription!
else
logger.error "****Subscription Error****"
logger.error response.message
self.next_payment_due = self.next_payment_due + configatron.retry_days_number # переносим следующую дату попытки списания на n-дней
self.last_charge_error = response.message
self.failed_transactions_number += 1
# если первые n попыток неудачно – просрочена, если n+1 попыток – реджектнута и удален аккаунт, придется заново привязать карту
if self.failed_transactions_number < configatron.retry_number
self.past_due! || self.save!
else
self.to_pending! do
UserMailer.delay.subscription_cancelled(self.user.id)
end
end
end
record_transaction!(response.params)
end
# активируем подписку, если нужно, применяем план и записываем транзакцию (транзакции, относящиеся к обновлению карты пользователя на 1 руб. мы не записываем, т.к. сразу отменяем ее)
def activate_subscription!(plan=nil, params=nil)
record_transaction!(params) if params.present?
self.plan = Plan.find(plan) if plan.present?
self.last_charge_error = nil
self.next_payment_due = next_billing_date(next_payment_due)
self.failed_transactions_number = 0
self.successful_transactions_number += 1
self.activate! # Saves next_payment_due too
end
# вычисляем дату следующего платежа
def next_billing_date(date=Date.today)
date ||= Date.today
period = plan_duration.month? ? 1.month : 1.year
date + period
end
# биллим подписки на сегодня
def self.renew!
billable.billable_on(Date.today).each do |subscription|
subscription.renew!
end
end
# переводим подписки из trial режима в pending и ждем привязки карты, как только карту привяжут – подписка станет active
def self.pending!
trial.trial_due_on(Date.today).each do |subscription|
subscription.to_pending! do
UserMailer.delay.trial_over(subscription.user.id)
end
end
end
private
def set_trial
self.trial_due = Date.today + configatron.trial
end
def record_transaction!(params)
transactions.create! cp_transaction_attrs(params)
end
def cp_transaction_attrs(attrs)
attrs = ActionController::Parameters.new attrs
p = attrs.permit(:TransactionId, :Amount, :Currency, :DateTime, :IpAddress, :IpCountry, :IpCity, :IpRegion, :IpDistrict, :Description, :Status, :Reason, :AuthCode).transform_keys!{ |key| key.to_s.underscore rescue key }
p[:status] = p[:status].underscore if p[:status].present?
p[:reason] = p[:reason].titleize if p[:reason].present?
p[:date_time] = DateTime.parse(attrs[:CreatedDateIso]) if attrs[:CreatedDateIso].present?
p
end
end
Как вы, наверное, ожидали в центре рекуррентных платежей лежит cron (или ему подобные). С помощью отличного гема whenever мы настроили запуск задачи, которая проверяет кто сегодня должен оплатить подписку и у кого сегодня заканчивается триальный период. Если такие подписки найдены, система шлет email о том, что триальный период закончен, и если карта привязана к LPCloud, система проводит оплату.
config/schedule.rb
every :day, at: '0:00 am' do
runner "Subscription.renew!"
end
every :day, at: '0:00 am' do
runner "Subscription.pending!"
end
Интерфейс
Очевидно, когда дело касается денег вы должны быть прозрачными. Мы выводим пользователю информацию о платежах: статус, сумму, дату и предыдущие платежи.

Так же выводим информацию о привязанной карте и возможность прокачать или понизить тариф

Если клиент изменит тариф, то в следующий биллинг день с клиента спишется уже цена нового тарифа.
Заключение
Самым сложным моментом реализации было проектирование архитектуры, т.к. это мы делали впервые. Наша реализация не идеальна и не всем подойдет, но надеемся, что эта статья подкинет вам пару идей при разработке собственной системы рекуррентных платежей. Мы не затронули тему 3ds авторизации – обещаем рассказать об этом в следующей статье. Будем рады если вы поделитесь своим опытом в комментариях.