📜 ⬆️ ⬇️

Billing in SaaS applications on Ruby on Rails

When developers are faced with the question of subscription implementation, as was the case with us when developing LPCloud , many use ready-made solutions, such as recurly.com , chargify.com , spreedly.com , etc. They have, of course, their pros and cons, but we could not find a suitable service that would satisfy us on all factors and we decided to write our own system of regular payments. We chose cloudpayments.ru as the processing of maps .

For the convenience of working with payment by cards, we started the well-known gem activemerchant from Shopify, but faced such a dilemma - activemerchant did not support cloudpayments. We quickly solved this problem by finishing the heme, it is available on our githaba account.

In short


We needed a system that would have the following capabilities:


We do not store our clients' card data, but instead, on the side of cloudpayments, their tokens are created, which are stored in the Account model. These tokens are useless without our public_id and secret password in 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 


Save the map


Cloudpayments do not transmit card data to the server, you must first generate a card cryptogram on the client, with which you can later make a payment and receive a token card in the response to the result of the payment.

View

To make our own card entry form, we used the Cloudpay checkout script. Just follow the documentation ...

We connect the js file to the page:
 <script src="https://widget.cloudpayments.ru/bundles/checkout"></script> 


Create a form for entering card data, you can customize the appearance of the form
 <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> 


Card data entry fields must be marked with attributes:
 data-cp="cardNumber" —     data-cp="name" —     data-cp="expDateMonthYear" —       MMYY data-cp="expDateMonth" —      data-cp="expDateYear" —      data-cp="cvv" —    CVV 


This is how our form looks like.


To protect the user from entering incorrect data, we recommend using the plugin jquery.payment from Stripe.

Next, you need to register a script that generates a cryptogram of the card and sends it to the server for making the first payment. The algorithm of actions is as follows: first, we send the cryptogram to the server and make the first payment, in a successful response we receive the token cards and write it in the Account, then use this token for subsequent payments.

 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")); }); 


Controller

 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 

This method is the first payment or update of billing information. If this is the first payment, the money will be written off according to the tariff, if the user simply updates the card, then we authorize 1 ruble on his account (if the operation is successful, then the ruble will be released immediately). In both cases, we receive new card data in the response about the operation, including the token, expiration date and the first 6 and last 4 digits of the card number and write it to the user.

Recurrent part


The subscription information of the user is stored in the Subscription model. This model has an important field next_payment_due , which stores the date of the next payment.

 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 


As you probably expected in the center of recurrent payments lies cron (or the like). With the help of a great gem whenever we set up a launch task, which checks who should pay for the subscription today and who has the trial period ending today. If such subscriptions are found, the system sends an email stating that the trial period is over, and if the card is linked to LPCloud, the system makes a payment.

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 


Interface


Obviously, when it comes to money, you need to be transparent. We display information about payments to the user: status, amount, date and previous payments.



We also display information about the linked card and the ability to pump or lower the tariff.



If the client changes the tariff, then on the next billing day the price of the new tariff will be charged to the customer.

Conclusion


The most difficult moment of implementation was architecture design, since We did this for the first time. Our implementation is not perfect and will not suit everyone, but we hope that this article will give you a couple of ideas when developing your own recurring payment system. We did not touch upon the topic of 3ds authorization - we promise to tell about it in the next article. We will be happy if you share your experiences in the comments.

Source: https://habr.com/ru/post/236453/


All Articles