Pull to refresh
0
Rating

Биллинг в SaaS-приложениях на Ruby on Rails

LPCloud corporate blog Ruby on Rails *SaaS / S+S *
Когда перед разработчиками встает вопрос реализации подписки, как это было с нами при разработке LPCloud, многие используют уже готовые решения, например recurly.com, chargify.com, spreedly.com и др. У них есть, конечно, свои плюсы и минусы, но мы так и не смогли найти подходящий сервис, который бы удовлетворял нас по всем факторам и мы решили написать свою собственную систему регулярных платежей. В качестве процессинга карт мы выбрали cloudpayments.ru

Для удобства работы с оплатой по картам, мы заюзали известный gem activemerchant от Shopify, но столкнулись с такой делемой – activemerchant не поддерживал cloudpayments. Мы быстренько решили эту проблему допилив гем, он доступен на нашем аккаунте на гитхабе.

Вкратце


Нам требовалась система, которая бы имела следующие возможности:
  • Возможность привязки карт пользователей
  • Ежемесячный/ежегодный биллинг
  • Индивидуально настраиваемый триальный период для пользователей
  • Возможность обновления тарифа клиентом в любое время


Мы не храним данные карт наших клиентов у себя, вместо этого на стороне cloudpayments создаются их токены, которые хранятся в модели Account. Эти токены бесполезны без наших public_id и секретного пароля в cloudpayments

app/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 авторизации – обещаем рассказать об этом в следующей статье. Будем рады если вы поделитесь своим опытом в комментариях.
Tags:
Hubs:
Total votes 2: ↑2 and ↓0 +2
Views 6.4K
Comments 7
Comments Comments 7

Information

Location
Россия
Website
lpcloudapp.com
Employees
2–10 employees
Registered