3scale/porta

View on GitHub
app/models/finance/billing_strategy.rb

Summary

Maintainability
D
1 day
Test Coverage
# frozen_string_literal: true

class Finance::BillingStrategy < ApplicationRecord
  module NonAuditedColumns
    def non_audited_columns
      super - [inheritance_column]
    end
  end

  class << self
    prepend NonAuditedColumns
  end

  audited :allow_mass_assignment => true

  attr_reader :failed_buyers

  CURRENCIES = CurrenciesLoader.load_config.freeze

  belongs_to :account
  alias_attribute :provider, :account

  attr_protected :account_id, :tenant_id, :audit_ids

  accepts_nested_attributes_for :account
  validates :currency, inclusion: { in: CURRENCIES.values, message: :invalid }

  # TODO: uncomment when factories are fixed
  # validates_presence_of :account
  validates :numbering_period, presence: true
  validates :numbering_period, inclusion: { :in => %w(monthly yearly).freeze }, length: { maximum: 255 }
  validates :currency, :type, length: { maximum: 255 }

  def self.daily_canaries
    return if canaries.blank?
    daily_async(where("account_id in (?)", canaries))
  end

  def self.daily_rest
    if canaries.blank?
      daily_async(all)
    else
      daily_async(where("account_id not in (?)", canaries))
    end
  end

  def self.daily_async(scope, options = {})
    now = options[:now] || Time.now.utc

    scope.select([:id, :account_id]).includes(:account).find_each do |billing_strategy|
      Finance::BillingService.async_call(billing_strategy.account, now)
    end
  end

  def self.canaries
    ThreeScale.config.payments.billing_canaries || []
  end

  # Supported options
  #
  # :now - time when the billing is happening
  # :only - run billing only for providers with those IDs
  # :exclude - run billing for all providers, but exclude those IDs
  #
  # This is usually called by BillingService#call with one provider in `only` and a single buyer in `buyer_ids`
  def self.daily(options = {})
    raise 'Options must be a hash' unless options.is_a?(Hash)

    Rails.logger.info("Finance::BillingStrategy.daily started for options #{options}")

    now = options[:now] || Time.now.utc
    skip_notifications = options[:skip_notifications]

    scope = if options.has_key?(:only)
              where("account_id in (?)", options[:only])
            elsif options.has_key?(:exclude)
              where("account_id not in (?)", options[:exclude])
            else
              all
            end

    results = Finance::BillingStrategy::Results.new(now)
    scope.find_each(:batch_size => 5) do |billing_strategy|
      begin
        unless billing_strategy.active?
          next results.skip(billing_strategy)
        end

        results.start(billing_strategy)
        ignoring_find_each_scope { billing_strategy.daily(now: now, buyer_ids: options[:buyer_ids], skip_notifications: skip_notifications) }
        results.success(billing_strategy)
      rescue => e
        results.failure(billing_strategy)
        name = billing_strategy.provider.try!(:name)
        id = billing_strategy.id
        message = "BillingStrategy #{id}(#{name}) failed utterly"

        Rails.logger.error(message)

        System::ErrorReporting.report_error(e, :error_message => message, :error_class => 'BillingError')

        raise e
      end
    end

    Rails.logger.info("Finance::BillingStrategy.daily finished for options #{options}, with results: #{results.inspect_all_things}")

    notify_billing_results(results) unless skip_notifications

    results
  end

  def self.notify_billing_results(results)
    BillingMailer.billing_finished(results).deliver_now unless results.successful?
  rescue => error
    System::ErrorReporting.report_error(error)
    env = Rails.env
    raise if env.test? || env.development?
  end

  delegate :notify_billing_results, to: :class

  def self.account_currency(account_id)
    Rails.cache.fetch("account:#{account_id}:billing_strategy:currency") do
      where(:account_id => account_id).pluck(:currency).first || false
    end || nil
    # the false trick is there for store it in memcache, because nil is not stored
  end

  def notify_billing_finished(now)
    only_on_days(now, 1) { notify_about_finalized_invoices }
    notify_about_expired_credit_cards(now)
  end

  def active?
    provider.approved?
  end

  def build_invoice(opts = {})
    options = opts.reverse_merge(period: Month.new(Time.now.utc.to_date))
    creation_type = options.delete(:creation_type)
    create_invoice_counter(options[:period])

    provider.buyer_invoices.new(options) do |new_invoice|
      new_invoice.creation_type = creation_type if creation_type.present?
    end
  end

  def create_invoice!(opts = {})
    invoice = build_invoice(opts)
    Invoice.transaction { invoice.save! }
    invoice
  end

  def create_invoice(opts = {})
    invoice = build_invoice(opts)
    Invoice.transaction { invoice.save }
    invoice
  end

  def create_invoice_counter(period)
    #  The invoice prefix is either the 7 (0..6) or the 4 (0..3) initial characters of the period stringified,
    #  respectively to monthly billing friendly ids (YYYY-MM-########) and to yearly billing friendly ids (YYYY-########)
    invoice_prefix = period.to_param[0..(billing_monthly? ? 6 : 3)]

    InvoiceCounter.transaction(requires_new: true) do
      InvoiceCounter.create(provider_account: account, invoice_prefix: invoice_prefix, invoice_count: 0)
    end
  rescue ActiveRecord::RecordNotUnique
    InvoiceCounter.find_by(provider_account: account, invoice_prefix: invoice_prefix)
  end

  # TODO: Remove. See https://github.com/3scale/system/pull/9360
  def next_available_friendly_id(month, step = 1)
    return unless month
    id_prefix = billing_monthly? ? month : month.begin.year

    last_of_period = Invoice.by_provider(account)
                            .with_normalized_friendly_id(numbering_period, month)
                            .first
    order = if last_of_period
              last_of_period.friendly_id.split('-').last
            else
              0
            end
    "#{id_prefix.to_param}-#{'%08d' % (order.to_i + step)}"
  end

  # TODO: rename to invoice_numbering_monthly?
  def billing_monthly?
    numbering_period == 'monthly'
  end

  # TODO: rename to invoice_numbering_yearly?
  def billing_yearly?
    numbering_period == 'yearly'
  end

  def change_mode(bs_mode)
    return if bs_mode == self.type
    new_name = (name == 'prepaid') ? 'postpaid' : 'prepaid'
    self.update_attribute(:type, bs_mode.to_s)
    warning("Billing mode changed to #{new_name}")
  end

  def warning(txt, buyer = nil)
    LogEntry.log(:warning, txt, self.account_id, buyer)
  end

  def error(txt, buyer = nil)
    LogEntry.log(:error, txt, self.account_id, buyer)
  end

  def info(txt, buyer = nil)
    LogEntry.log(:info, txt, self.account_id, buyer)
  end

  protected

  delegate :provider_id_for_audits, :to => :account, :allow_nil => true

  def only_on_days(*days, &block)
    now = days.shift
    yield if days.include?(now.day)
  end

  def bill_expired_trials(buyer, now)
    buyer.billable_contracts_with_trial_period_expired(now - 1.day).find_each(batch_size: 50) do |contract|
      plan_type = contract.plan.class.model_name.human.downcase

      info("#{log_prefix(buyer)} for #{plan_type} #{contract.plan.id} (#{contract.plan.name}) - just signed up or trial period expired", buyer)
      contract.bill_for(Month.new(now), invoice_for(buyer, now))
    end
  end

  # TODO: DRY
  # TODO: cover it by unit tests
  #
  def bill_fixed_costs(buyer, now = Time.now.utc)
    info("#{log_prefix(buyer)} billing fixed costs at #{now}", buyer)
    buyer.billable_contracts.find_each(batch_size: 50) do |contract|
      contract.bill_for(Month.new(now), invoice_for(buyer, now))
    end
  end

  # Finalize all invoices that are in open but belong to
  # a period (month) that is already over.
  #
  def finalize_invoices_of(buyer, now = Time.now.utc)
    invoices_to_finalize_of(buyer, now).find_each(:batch_size => 20) do |invoice|
      info("#{log_prefix(buyer)} finalizing invoices for period #{invoice.period}", buyer)
      invoice.finalize!
    end
  end

  def issue_invoices_of(buyer, now = Time.now.utc)
    to_issue = self.provider.buyer_invoices.by_buyer(buyer).finalized_before(now - 1.day - 22.hours)

    to_issue.find_each(batch_size: 100) do |invoice|
      info("#{log_prefix(buyer)} issuing invoice #{invoice.id} for period #{invoice.period}", buyer)
      invoice.issue_and_pay_if_free!

      # TODO: extract to overloaded method?
      # TODO: Decouple the notification to observer
      if charging_enabled? && invoice.cost.nonzero? && invoice.buyer_account.paying_monthly?
        InvoiceMessenger.upcoming_charge_notification(invoice).deliver
      end
    end
  end

  def invoice_for_cinstance(contract)
    buyer = contract.buyer_account
    invoice_for(buyer, Time.now.utc)
  end

  def notify_about_finalized_invoices
    if provider.buyer_line_items.sum_by_invoice_state(:finalized) > 0
      info('Notifying about finalized invoices with non-zero cost')

      # do not send email if provider's using new notification system
      unless provider.provider_can_use?(:new_notification_system)
        AccountMessenger.invoices_to_review(provider).deliver
      end

      event = Invoices::InvoicesToReviewEvent.create(provider)
      Rails.application.config.event_store.publish_event(event)
    elsif provider.buyer_invoices.finalized.count > 0
      info('All finalized invoices have zero cost - notification not sent')
    end
  end

  def notify_about_expired_credit_cards(now)
    expiry_date = now.to_date + 10.days

    provider.buyers.expired_credit_card(expiry_date).find_each(:batch_size => 20) do |buyer|
      ignoring_find_each_scope do
        AccountMessenger.expired_credit_card_notification_for_buyer(buyer).deliver

        # do not send email if provider's using new notification system
        unless provider.provider_can_use?(:new_notification_system)
          AccountMessenger.expired_credit_card_notification_for_provider(buyer).deliver
        end

        event = Accounts::ExpiredCreditCardProviderEvent.create(buyer)
        Rails.application.config.event_store.publish_event(event)

        info('Notifying about expiring credit card', buyer)
      end
    end
  end

  def invoice_for(buyer, now)
    month = Month.new(now)
    Finance::InvoiceProxy.new(buyer, month)
  end

  def charge_invoices(buyer, now = Time.now.utc)
    if charging_enabled?
      if self.currency.nil?
        raise "BillingStrategy(#{self.id}) is trying to charge without currency settings"
      end

      buyer.invoices.chargeable(now).find_each(batch_size: 50) do |invoice|
        Rails.logger.info("#{log_prefix(buyer)} trying to charge invoice #{invoice.id}")
        invoice.charge!

      end
    else
      Rails.logger.info("#{log_prefix(buyer)} charging not enabled for provider #{account.org_name} - bypassing")
    end
  end

  def needs_credit_card?
    charging_enabled?
  end

  def log_prefix(buyer)
    "[billing] provider #{buyer.provider_account_id} buyer #{buyer.id}:"
  end

  public :needs_credit_card?

  private

  # Yields a block for each buyer, passing it as a parameter. If an
  # exception occurs meanwhile, catches and reports it.
  #
  def bill_and_charge_each(options = {})
    buyer_ids = options[:buyer_ids]
    @failed_buyers = []

    if provider.nil?

      message = "WARNING: tried to use billing strategy #{self.id} which has no account"
      exception = ::Finance::BillingError.new message
      System::ErrorReporting.report_error(exception, :error_message => message, :error_class => 'InvalidData')
      return
    end

    buyer_accounts = provider.buyer_accounts
    buyer_accounts = buyer_accounts.where(id: buyer_ids) if buyer_ids.present?
    buyer_accounts.find_each(:batch_size => 20) do |buyer|
      begin
        ignoring_find_each_scope { yield(buyer) }
      rescue => exception
        name = buyer.name
        buyer_id = buyer.id
        provider_id = provider.id

        msg = "Failed to bill or charge #{name}(#{buyer_id}) of provider(#{provider_id}): #{exception.message}\n"
        error(msg, buyer)
        System::ErrorReporting.report_error(exception, :error_message => msg,
                                            :error_class => 'BillingError',
                                            :parameters => { :buyer_id => buyer_id, :provider_id => provider_id }
        )

        @failed_buyers << buyer_id
        raise if Rails.env.test?
      end
    end
  end

  def add_plan_cost(action, contract, plan, period)
    cost = plan.cost_for_period(period)

    if cost.nonzero?
      sign = action == :refund ? -1 : 1
      reason = action == :refund ? 'Refund' : 'Fixed fee'
      add_cost(contract, "#{reason} ('#{plan.name}')", period.to_s, cost * sign, plan)
    end
  end

  def add_cost(contract, name, description, cost, plan = contract.plan)
    invoice = invoice_for_cinstance(contract)
    Finance::BackgroundBilling.new(invoice).create_line_item!(
      {
        contract: contract,
        plan_id: plan.id,
        name: name,
        description: description,
        quantity: 1,
        cost: cost,
        type: LineItem::PlanCost
      }
    )
  end

  delegate :report_error, to: 'System::ErrorReporting'

  module FindEachFix
    # HACK: to overcome find_each scoping, we reset the scope
    def ignoring_find_each_scope(&block)
      Account.unscoped.scoping(&block)
    end
  end

  extend FindEachFix
  include FindEachFix
end