module TbCheckout class Transaction < ActiveRecord::Base self.table_name = 'tb_checkout_transactions' belongs_to :cart, :inverse_of => :transactions attr_accessor :card_number attr_accessor :card_ccv attr_reader :card_expiration attr_reader :response validates_presence_of :cart, :billing_first_name, :billing_last_name, :billing_address_1, :billing_city, :billing_state, :billing_postal, :card_type validates_numericality_of :amount_charged, :greater_than => 0 validate :card_expiration_is_valid, :on => :create validate :credit_card_is_valid, :on => :create before_validation :set_amount_charged before_create :set_invoice_num, :set_spud_user_and_session_id include TbCheckout::BelongsToUserSession # Transaction status # # Status describes the current state of a transaction # # * Pending: The transaction is newly created # * Authorized: The transaction has been submitted to the payment gateway and successfully authorized # * Captured: The transaction has been submitted to the payment gateway and the purchase is finalized # * Fail: A failure code was received from the payment gateway # * Voided: The transaction is voided in the gateway # * Refunded: The transaction is refunded in the gateway module Status PENDING = 'pending' AUTHORIZED = 'authorized' CAPTURED = 'captured' FAIL = 'fail' VOIDED = 'voided' REFUNDED = 'refunded' end # Scopes default_scope { order('created_at desc') } scope :pending, ->{ where(:status => Status::PENDING) } scope :authorized, ->{ where(:status => Status::AUTHORIZED) } scope :captured, ->{ where(:status => Status::CAPTURED) } scope :failed, ->{ where(:status => Status::FAIL) } # Authorize the transaction against the payment gateway # # * The transaction must be in PENDING state in order ot take this action def authorize! self.with_lock(true) do if self.status != Status::PENDING raise StandardError, 'Payment must be in Pending state before authorization' end @response = TbCheckout.gateway.authorize(amount_in_cents, build_credit_card(), build_purchase_options()) if @response.success? self.update_columns(:status => Status::AUTHORIZED, :gateway_transaction_id => @response.params['transaction_id'], :response_text => @response.to_json) return true else self.update_columns(:status => Status::FAIL, :response_text => @response.to_json) logger.fatal @response.inspect return false end end end # Capture the funds from an authorized transaction through the payment gateway # # * The transaction must be in AUTHORIZED state in order ot take this action def capture! self.with_lock(true) do if self.status != Status::AUTHORIZED raise StandardError, 'Payment must be in Authorized state before capture' end @response = TbCheckout.gateway.capture(amount_in_cents, self.gateway_transaction_id.to_s) if @response.success? || @response.params['action'] == 'PRIOR_AUTH_CAPTURE' self.update_columns(:status => Status::CAPTURED, :response_text => @response.to_json) else self.update_columns(:status => Status::FAIL, :response_text => @response.to_json) logger.fatal @response.inspect end end if self.status == Status::CAPTURED self.cart.update_attributes(:is_completed => true) self.cart.cart_items.each do |cart_item| cart_item.item.run_callbacks(:capture) end return true else return false end end # Voids or Refunds the transaction # # * An unsettled trasanction cannot be refunded, and a settled transaction cannot be voided # * Transactions are settled nightly # * If the transaction is less than 24 hours old, attempt to void it and if that fails attempt a refund def void_or_refund! if (1.days.ago < self.created_at && void!) || refund! return true else return false end end def amount_in_cents return amount_charged * 100 end def billing_full_name return "#{billing_first_name} #{billing_last_name}" end def billing_full_address street = [billing_address_1, billing_address_2].reject(&:blank?).join(', ') return "#{street}, #{billing_city} #{billing_state}, #{billing_postal}" end # Check for card_expiration value in String format and convert to Date format # def card_expiration=(input) if input.is_a? Date @card_expiration = input elsif input.is_a?(Hash) && input.has_key?(1) && input.has_key?(2) @card_expiration = Date.new(input[1], input[2]) elsif input.is_a? String begin @card_expiration = Date.parse(input) rescue ArgumentError => e logger.debug "Failed to parse card_expiration '#{input}' with error: #{e.message}" @card_expiration = nil end else @card_expiration = nil end end def response_json begin return JSON.parse(response_text) rescue JSON::ParseError return {} end end private def void! self.with_lock(true) do if self.status != Status::CAPTURED raise StandardError, 'Payment must be in captured state before it can be voided' end @response = TbCheckout.gateway.void(gateway_transaction_id.to_s) if @response.success? self.update_columns(:status => Status::VOIDED, :response_text => @response.to_json) return true else self.update_columns(:response_text => @response.to_json) logger.fatal @response.inspect return false end end end def refund! self.with_lock(true) do if self.status != Status::CAPTURED raise StandardError, 'Payment must be in captured state before it can be voided' end cc_last_four_digits = self.card_display.chars.last(4).join() @response = TbCheckout.gateway.refund(amount_in_cents, self.gateway_transaction_id.to_s, { :order_id => invoice_num, :description => "REFUND: #{self.cart.description}", :card_number => cc_last_four_digits, :first_name => billing_first_name, :last_name => billing_last_name, :zip => billing_postal }) if @response.success? || @response.params['response_reason_code'] == '55' # already refunded self.update_columns(:status => Status::REFUNDED, :response_text => @response.to_json, :refund_gateway_transaction_id => @response.params['gateway_transaction_id']) return true else self.update_columns(:response_text => @response.to_json) logger.fatal @response.inspect return false end end end def card_expiration_is_valid if self.card_expiration.blank? errors.add(:card_expiration, 'cannot be blank') elsif !card_expiration.is_a? Date errors.add(:card_expiration, 'must be a date') end end def credit_card_is_valid if self.card_number.blank? errors.add(:card_number, 'cannot be blank') end if self.card_ccv.blank? errors.add(:card_ccv, 'cannot be blank') end if errors.size == 0 credit_card = build_credit_card() if credit_card.valid?() self.card_display = credit_card.display_number() else credit_card.errors.full_messages.each do |msg| errors.add(:base, msg) end end end end def build_credit_card return ActiveMerchant::Billing::CreditCard.new({ :brand => card_type, :number => card_number, :verification_value => card_ccv, :month => card_expiration.month, :year => card_expiration.year, :first_name => billing_first_name, :last_name => billing_last_name }) end def build_purchase_options return { :email => self.cart.spud_user.try(:email) || nil, :order_id => self.invoice_num, :description => self.cart.description, :billing_address => { :name => billing_full_name, :address1 => billing_address_1, :address2 => billing_address_2, :city => billing_city, :state => billing_state, :country => country_for_billing_state(), :zip => billing_postal, :phone => self.cart.spud_user.try(:phone) || nil } } end def set_amount_charged if self.cart self.amount_charged = self.cart.total_price end end def set_invoice_num user_id = self.cart.spud_user_id || 0 self.invoice_num = [user_id, Time.now.to_i].join('-') end def set_spud_user_and_session_id self.spud_user_id = self.cart.spud_user_id self.session_id = self.cart.session_id end def country_for_billing_state if ['AB', 'BC', 'MB', 'NB', 'NL', 'NS', 'NT', 'NU', 'ON', 'PE', 'QC', 'SK', 'YT'].include?(billing_state) return 'Canada' else return 'USA' end end end end