require 'recurly' module JamRuby class RecurlyClient def initialize() @log = Logging.logger[self] end def create_account(current_user, billing_info) options = account_hash(current_user, billing_info) account = nil begin #puts "Recurly.api_key: #{Recurly.api_key}" account = Recurly::Account.create(options) if account.errors.any? puts "Errors encountered while creating account: #{account.errors}" raise RecurlyClientError.new(account.errors) if account.errors.any? end rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s else if account current_user.update_attribute(:recurly_code, account.account_code) end end account end def has_account?(current_user) account = get_account(current_user) !!account end def delete_account(current_user) account = get_account(current_user) if account begin account.destroy rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end else raise RecurlyClientError, "Could not find account to delete." end account end def update_desired_subscription(current_user, plan_code) subscription = nil account = nil current_user.desired_plan_code = plan_code current_user.desired_plan_code_set_at = DateTime.now current_user.save(validate: false) puts "updating desired subscription for #{current_user.email} to #{plan_code}" account = get_account(current_user) if account if plan_code.nil? || plan_code == '' begin # user wants a free subscription. If they have a subscription, let's cancel it. subscription, account = find_subscription(current_user, account) if subscription puts "Canceling user's #{current_user.email} subscription" subscription.cancel # upon cancelation, take the user's current monthly payment plan subscription = Recurly::Subscription.find(subscription.uuid) current_user.subscription_plan_code = get_highest_plan(subscription) current_user.subscription_plan_code_set_at = DateTime.now current_user.save(validate: false) # do not delete the recurly_subscription_id ; we'll use that to try and reactivate later if they user re-activates their account else # if no subscription and past trial, you goin down -- because there must have never been payment?? if current_user.subscription_trial_ended? current_user.subscription_plan_code = nil current_user.subscription_plan_code_set_at = DateTime.now current_user.save(validate: false) end end # do not set the subscription _plan_code either; because the user has paid through the month; they still # get their old plan #current_user.subscription_plan_code = nil #current_user.save(validate: false) rescue => e puts "Could not cancel subscription for user #{current_user.email}. #{e}" return false, subscription, account end else # user wants to pay. let's get it goin return handle_create_subscription(current_user, plan_code, account) end end return true, subscription, account end def get_account(current_user) begin account = current_user && current_user.recurly_code ? Recurly::Account.find(current_user.recurly_code) : nil rescue Recurly::Error => x puts "Swallow find acct for user #{current_user.email} error initial #{x}" end # check again, assuming account_code is the user ID (can happen in error scenarios where we create the account # on recurly, but couldn't save the account_code to the user.recurly_code field) puts "get_account for #{current_user.email} found #{account}" if !account begin account = Recurly::Account.find(current_user.id) rescue Recurly::Error => x puts "Swallow find acct for user #{current_user.email} error #{x}" end # repair user local account info if !account.nil? current_user.update_attribute(:recurly_code, account.account_code) end end account rescue Recurly::Error => x raise RecurlyClientError, x.to_s end def update_account(current_user, billing_info=nil) account = get_account(current_user) if(account.present?) options = account_hash(current_user, billing_info) begin account.update_attributes(options) rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end end account end def list_invoices(account) invoices = [] count = 0 account.invoices.find_each do |invoice| count = count + 1 invoices << invoice if count == 50 break end end invoices end def payment_history(current_user, params ={}) limit = params[:limit] limit ||= 20 limit = limit.to_i cursor = params[:cursor] payments = [] account = get_account(current_user) if(account.present?) begin account.transactions.paginate(per_page:limit, cursor:cursor).each do |transaction| # XXX this isn't correct because we create 0 dollar transactions too (for free stuff) #if transaction.amount_in_cents > 0 # Account creation adds a transaction record payments << { :created_at => transaction.created_at, :amount_in_cents => transaction.amount_in_cents, :tax_in_cents=> transaction.tax_in_cents, :status => transaction.status, :action => transaction.action, :payment_method => transaction.payment_method, :reference => transaction.reference, :currency => transaction.currency } #end end rescue Recurly::Error, NoMethodError => x puts "Recurly error #{current_user.email} #{x}" raise RecurlyClientError, x.to_s end end payments end def invoice_history(current_user, params ={}) limit = params[:limit] limit ||= 20 limit = limit.to_i cursor = params[:cursor] payments = [] account = get_account(current_user) if(account.present?) begin account.invoices.paginate(per_page:limit, cursor:cursor).each do |invoice| # XXX this isn't correct because we create 0 dollar transactions too (for free stuff) #if transaction.amount_in_cents > 0 # Account creation adds a transaction record payments << { :created_at => invoice.created_at, :subtotal_in_cents => invoice.subtotal_in_cents, :tax_in_cents=> invoice.tax_in_cents, :total_in_cents => invoice.total_in_cents, :state => invoice.state, :description => invoice.line_items.map(&:description).join(", "), :currency => invoice.currency } #end end rescue Recurly::Error, NoMethodError => x puts "Recurly error #{current_user.email} #{x}" raise RecurlyClientError, x.to_s end end return payments, account end def update_billing_info(current_user, billing_info=nil, account = nil) account = get_account(current_user) if account.nil? if account.present? begin account.billing_info = billing_info account.billing_info.save rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end raise RecurlyClientError.new(account.errors) if account.errors.any? else raise RecurlyClientError, "Could not find account to update billing info." end account end # token was created in the web ui. we can tell recurly to update the billing info on the account with just the token def update_billing_info_from_token(current_user, account, recurly_token) account.billing_info = { token_id: recurly_token } account.billing_info.save! end def refund_user_subscription(current_user, jam_track) jam_track_right=JamRuby::JamTrackRight.where("user_id=? AND jam_track_id=?", current_user.id, jam_track.id).first if jam_track_right refund_subscription(jam_track_right) else raise RecurlyClientError, "The user #{current_user} does not have a subscription to #{jam_track}" end end def refund_subscription(jam_track_right) account = get_account(jam_track_right.user) if (account.present?) terminated = false begin jam_track = jam_track_right.jam_track account.subscriptions.find_each do |subscription| #puts "subscription.plan.plan_code: #{subscription.plan.plan_code} / #{jam_track.plan_code} / #{subscription.plan.plan_code == jam_track.plan_code}" if(subscription.plan.plan_code == jam_track.plan_code) subscription.terminate(:full) raise RecurlyClientError.new(subscription.errors) if subscription.errors.any? terminated = true end end if terminated jam_track_right.destroy() else raise RecurlyClientError, "Subscription '#{jam_track.plan_code}' not found for this user; could not issue refund." end rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end else raise RecurlyClientError, "Could not find account to refund order." end account end def find_jam_track_plan(jam_track) plan = nil begin plan = Recurly::Plan.find(jam_track.plan_code) rescue Recurly::Resource::NotFound end plan end def create_jam_track_plan(jam_track) plan = Recurly::Plan.create(accounting_code: "", bypass_hosted_confirmation: false, cancel_url: nil, description: jam_track.description, display_donation_amounts: false, display_phone_number: false, display_quantity: false, name: "JamTrack: #{jam_track.name}", payment_page_css: nil, payment_page_tos_link: nil, plan_code: jam_track.plan_code, plan_interval_length: 1, plan_interval_unit: "months", setup_fee_in_cents: Recurly::Money.new(:USD => 0), # success_url: "", tax_exempt: false, total_billing_cycles: 1, trial_interval_length: 0, trial_interval_unit: "days", unit_amount_in_cents: Recurly::Money.new(:USD => 1_99), unit_name: "unit" ) raise RecurlyClientError.new(plan.errors) if plan.errors.any? end def get_pending_plan_code(subscription) if subscription && subscription.pending_subscription return subscription.pending_subscription.plan.plan_code else return nil end end def report_meta_capi(current_user, plan_code) # CAPI Hook: Subscribe begin monthly_cost = case plan_code when JamRuby::SubscriptionDefinitions::JAM_SILVER, JamRuby::SubscriptionDefinitions::JAM_SILVER_YEARLY 5.00 when JamRuby::SubscriptionDefinitions::JAM_GOLD, JamRuby::SubscriptionDefinitions::JAM_GOLD_YEARLY 10.00 when JamRuby::SubscriptionDefinitions::JAM_PLATINUM, JamRuby::SubscriptionDefinitions::JAM_PLATINUM_YEARLY 20.00 else 0.00 end ltv = monthly_cost * 12 begin puts "Sending CAPI Subscribe event #{current_user.email}, #{monthly_cost}, #{ltv}" CapiTransmitter.send_event('Subscribe', current_user, { value: monthly_cost.to_s, currency: 'USD', predicted_ltv: ltv.to_s }) rescue => e puts "Error sending CAPI Subscribe event #{current_user.email}, #{e.message}" end rescue => e puts "Error sending CAPI Subscribe event #{current_user.email}, #{e.message}" end end def get_highest_plan(subscription) SubscriptionDefinitions.higher_plan(subscription.plan.plan_code, get_pending_plan_code(subscription)) end def handle_create_subscription(current_user, plan_code, account) begin subscription = create_subscription(current_user, plan_code, account, current_user.subscription_trial_ended? ? nil : current_user.subscription_trial_ends_at) current_user.recurly_subscription_id = subscription.uuid current_user.first_subscribed_at = Time.now if current_user.first_subscribed_at.nil? current_user.first_subscribed_plan_code = plan_code if current_user.first_subscribed_plan_code.nil? if current_user.subscription_trial_ended? current_user.subscription_plan_code = get_highest_plan(subscription) current_user.subscription_plan_code_set_at = DateTime.now else # we could force a platinum plan since the user has put forward payment already, even in trial puts "user #{current_user.email} is in trial" if plan_code == SubscriptionDefinitions::JAM_PLATINUM || plan_code == SubscriptionDefinitions::JAM_PLATINUM_YEARLY puts "user #{current_user.email} is in trial and buying platinum ; upgrade them already" current_user.subscription_plan_code = plan_code current_user.subscription_plan_code_set_at = DateTime.now else current_user.subscription_plan_code = SubscriptionDefinitions::JAM_GOLD current_user.subscription_plan_code_set_at = DateTime.now end end current_user.reset_playtime current_user.save(validate: false) report_meta_capi(current_user, plan_code) rescue => e puts "Could not create subscription for user #{current_user.email}. #{e}" return false, subscription, account end return true, subscription, account end # https://dev.recurly.com/docs/create-subscription def create_subscription(user, plan_code, account, starts_at = nil) old_subscription_id = user.recurly_subscription_id if old_subscription_id # first, let's try to reactivate it old_subscription = Recurly::Subscription.find(old_subscription_id) begin old_subscription.reactivate puts "reactivated plan! Let's check if it needs changing" #if plan_code != old_subscription.plan.plan_code result = old_subscription.update_attributes( :plan_code => plan_code, :timeframe => starts_at.nil? ? 'bill_date' : 'now' ) # end # fetch it again. because it's got staleness after update_attributes operation return Recurly::Subscription.find(old_subscription_id) rescue => e puts "Unable to reactivate/update old plan #{e}" user.update_attribute(:recurly_subscription_id, nil) end end if account.billing_info puts "Creating subscription for #{user.email} with plan_code #{plan_code}" subscription = Recurly::Subscription.create( :plan_code => plan_code, :currency => 'USD', :customer_notes => 'Thank you for your business!', :account => { :account_code => account.account_code }, :starts_at => starts_at, :auto_renew => true ) subscription else puts "User has no billing info; not trying to create a subscription #{user.email}" end subscription end def find_subscription(user, fed_account = nil) subscription = nil account = nil if fed_account.nil? account = get_account(user) else account = fed_account end # first try to find the current subscription. If it's gone, delete our state. If expired, delete our state. if user.recurly_subscription_id begin subscription = Recurly::Subscription.find(user.recurly_subscription_id) rescue Recurly::Resource::NotFound puts "subscription is gone. delete it!" user.update_attribute(:recurly_subscription_id, nil) user.recurly_subscription_id = nil subscription = nil end puts "Subscription state: #{subscription.state}" if subscription.state == 'expired' puts "subscription is expired. stop tracking it!" user.update_attribute(:recurly_subscription_id, nil) user.recurly_subscription_id = nil subscription = nil end end if user.recurly_subscription_id.nil? if account active_subscription = nil account.subscriptions.find_each do |subscription| puts "Subscription: #{subscription.inspect} #{subscription.state}" if subscription.state == "active" || subscription.state == "future" active_subscription = subscription break end end subscription = active_subscription else puts "can't find subscription for account #{account}" end end if subscription && user.recurly_subscription_id.nil? puts "Repairing subscription ID on account" user.update_attribute(:recurly_subscription_id, subscription.uuid) user.update_attribute(:first_subscribed_plan_code, subscription.plan.plan_code) user.recurly_subscription_id = subscription.uuid user.first_subscribed_at = Time.now if user.first_subscribed_at.nil? end return [subscription, account] end def change_subscription_plan(current_user, plan_code) subscription, account = find_subscription(current_user) if subscription.nil? puts "no subscription found for user #{current_user.email}" return false end puts "subscription.plan #{subscription.plan}" if subscription.plan.plan_code == plan_code puts "plan code was the same as requested: #{plan_code}" return false end result = subscription.update_attributes( :plan_code => plan_code, :timeframe => 'bill_date' ) puts "change subscription plan #{result}" return result end def sync_subscription(user) begin # edge case: admin controlled if user.admin_override_plan_code # check if it's expired first... if Time.now > user.admin_override_ends_at.to_time puts "admin control expired. clear override and set Free plan" user.admin_override_plan_code = nil # logic below will catch this #user.subscription_plan_code = nil user.admin_override_ends_at = nil user.subscription_sync_code = 'undo_admin_control' user.subscription_sync_msg = "admin control expired. clear override and set Free plan" user.subscription_last_checked_at = Time.now user.save(validate: false) # don't return; let this fall through to next states else puts "admin controlled plan #{user.email}" user.subscription_plan_code = user.admin_override_plan_code user.subscription_plan_code_set_at = Time.now user.subscription_last_checked_at = Time.now user.subscription_sync_code = 'admin_control' user.subscription_sync_msg = "admin override - plan_code set to #{user.admin_override_plan_code}" user.save(validate: false) return end end # edge case: user is in a licensed school if user.has_active_license? puts "user has school license #{user.email}" user.subscription_plan_code = SubscriptionDefinitions::JAM_PLATINUM user.subscription_plan_code_set_at = DateTime.now user.subscription_last_checked_at = DateTime.now user.subscription_sync_code = 'school_license' user.subscription_sync_msg = "has school license - plan_code set to #{SubscriptionDefinitions::JAM_PLATINUM}" user.save(validate: false) return end # if user is in trial still, not much book-keeping if !user.subscription_trial_ended? puts "user has a trial still #{user.email}" # there is actually nothing to do, because we don't start billing for any plan until trial is over. user.subscription_last_checked_at = DateTime.now user.subscription_sync_code = 'in_trial' user.subscription_sync_msg = "trial still active - plan_code not altered" user.save(validate: false) return end # if there is no recurly action here, then they must be coming off of a trial and we have to mark them down if user.recurly_code.nil? && !user.subscription_plan_code.nil? puts "new user #{user.email} has no payment info and is ending their trial" # TODO: send email user.subscription_plan_code = nil user.subscription_plan_code_set_at = DateTime.now user.subscription_last_checked_at = DateTime.now user.subscription_sync_code = 'trial_ended' user.subscription_sync_msg = "trial ended and no subscription set - plan_code set to Free" user.save(validate: false) return end account = get_account(user) if account.nil? puts "Account is nil? #{user.email}. Strange. Could happen in some weird admin messing around scenarios" user.subscription_last_checked_at = DateTime.now user.save(validate: false) user.subscription_sync_code = 'no_recurly_account' user.subscription_sync_msg = "user has no recurly account - plan_code not altered" user.save(validate: false) return end user.is_past_due = account.has_past_due_invoice subscription, account = find_subscription(user, account) if subscription user.recurly_subscription_state = subscription.state else user.recurly_subscription_state = nil end if subscription.nil? || subscription.state == 'expired' puts "user has expired or no plan" user.subscription_plan_code = nil user.subscription_plan_code_set_at = DateTime.now user.subscription_sync_code = 'no_subscription_or_expired' user.subscription_sync_msg = "user has no or expired subscription - plan_code set to Free" else if user.is_past_due if !user.subscription_plan_code.nil? puts "user #{user.email} has a past due plan. We gotta bring them down" user.subscription_plan_code = nil user.subscription_plan_code_set_at = DateTime.now user.subscription_sync_code = 'is_past_due_changed' user.subscription_sync_msg = "payment has gone past due - plan_code set to Free" else puts "user is past due and #{user.email} had no changes" user.subscription_sync_code = 'is_past_due_unchanged' user.subscription_sync_msg = "payment has gone past due, plan_code not altered because already set to free" end else if user.subscription_plan_code != user.desired_plan_code puts "they are back! get them back into their desired plan #{user.email}" if !SubscriptionDefinitions.is_downgrade(user.desired_plan_code, user.subscription_plan_code) user.reset_playtime user.subscription_plan_code = user.desired_plan_code user.subscription_plan_code_set_at = DateTime.now user.subscription_sync_code = 'good_standing_repaired' user.subscription_sync_msg = "user is in good standing but desired != effective; plan_code set to #{user.desired_plan_code}" else #user.subscription_plan_code = user.desired_plan_code #user.subscription_plan_code_set_at = DateTime.now user.subscription_sync_code = 'good_standing_ignored' user.subscription_sync_msg = "user is in good standing but the desired plan is less than subscription plan; plan_code not touched" end else puts "good standing user #{user.email} had no changes" user.subscription_sync_code = 'good_standing_unchanged' user.subscription_sync_msg = "user is in good standing but already set correctly; plan_code not altered" end end end user.subscription_last_checked_at = DateTime.now user.save(validate: false) rescue => e puts "Unexpected error in sync_subscription for user #{user.email}" puts e.message user.subscription_last_checked_at = DateTime.now user.subscription_sync_code = 'failed_sync' user.subscription_sync_msg = e.message user.save(validate: false) end end def sync_transactions(options = {}) ActiveRecord::Base.transaction do options.merge!({ sort: :updated_at, state: :successful, per_page: 200 }) latest_seen = nil Recurly::Transaction.find_each(options) do |transaction | if AffiliateDistribution.find_by_external_id(transaction.uuid) # this is now a normal path, because we will pick up the last transaction we saw, since we use # the last transaction time to feed `latest_seen`. #begin # Bugsnag.notify("ActiveRecord::RecordNotUnique: duplicate affiliate_distribution for Recurly transaction uuid #{transaction.uuid} was prevented from been added.") #rescue => exception # Rails.logger.error(exception) unless Rails.env.test? #end next end # advance the time last seen in the transactions. if latest_seen.nil? || latest_seen < transaction.created_at.to_time latest_seen = transaction.created_at.to_time end # these next lines try to ascertain that the transaction we've hit describes a true subscription # jamtrack transactions are handled entirely separately, so this should avoid those, and perhaps other 'odd' # transactions in Recurly next if transaction.action != 'purchase' || transaction.status != 'success' || transaction.source != 'subscription' next if transaction.subscriptions.length == 0 subscription = transaction.subscriptions.first next if subscription.plan == nil || subscription.plan.plan_code == nil account = transaction.details["account"] user = User.find(account.account_code) affiliate_partner = user.affiliate_referral if !affiliate_partner.nil? && affiliate_partner.created_within_affiliate_window(user, transaction.created_at.to_time) affiliate_distribution = AffiliateDistribution.new affiliate_distribution.product_type = "Subscription" affiliate_distribution.affiliate_referral = affiliate_partner fee_in_cents = (transaction.amount_in_cents - transaction.tax_in_cents) * affiliate_partner.rate affiliate_distribution.affiliate_referral_fee_in_cents = fee_in_cents affiliate_distribution.created_at = transaction.created_at.to_time affiliate_distribution.product_code = subscription.plan.plan_code affiliate_distribution.external_id = transaction.uuid #external_id is a unique column. should raises error if duplicates affiliate_distribution.save! end end # only grab the latest time as seen in the data; that way, we should never skip really recent entries if if !latest_seen.nil? GenericState.singleton.update_attribute(:recurly_transactions_last_sync_at, latest_seen) end end end def find_or_create_account(current_user, billing_info, recurly_token = nil) account = get_account(current_user) if !account account = create_account(current_user, billing_info) elsif !billing_info.nil? update_billing_info(current_user, billing_info, account) end if !recurly_token.nil? puts "#{current_user.id} skipping double-update of billing of #{recurly_token} due to 2024 Oct Recurly Bug" # update_billing_info_from_token(current_user, account, recurly_token) end account end private def account_hash(current_user, billing_info) options = { account_code: current_user.id, email: current_user.email, first_name: current_user.first_name, last_name: current_user.last_name, address: { city: current_user.city, state: current_user.state, country: current_user.country } } options[:billing_info] = billing_info if billing_info options end end # class class RecurlyClientError < Exception attr_accessor :errors def initialize(data) if data.respond_to?('has_key?') self.errors = data else self.errors = {:message=>data.to_s} end end # initialize def to_s s=super s << ", errors: #{errors.inspect}" if self.errors.any? s end end # RecurlyClientError end # module