diff --git a/Gemfile b/Gemfile index e97da04..f26c667 100644 --- a/Gemfile +++ b/Gemfile @@ -9,8 +9,7 @@ ## source 'https://rubygems.org' -branch = ENV.fetch('SPREE_BRANCH', 'master') -gem 'spree' #, github: 'spree/spree', branch: branch +gem 'spree' group :development, :test do gem 'pry-rails' @@ -21,7 +20,4 @@ group :test do gem 'webmock' end -gem 'pg' -gem 'mysql2' - gemspec diff --git a/app/controllers/spree/amazon_callback_controller.rb b/app/controllers/spree/amazon_callback_controller.rb index 6c9a5c4..0ed609c 100644 --- a/app/controllers/spree/amazon_callback_controller.rb +++ b/app/controllers/spree/amazon_callback_controller.rb @@ -10,13 +10,24 @@ class Spree::AmazonCallbackController < ApplicationController skip_before_action :verify_authenticity_token + # This is the body that is sent from Amazon's IPN + # + # { + # "merchantId": "Relevant Merchant for the notification", + # "objectType": "one of: Charge, Refund", + # "objectId": "Id of relevant object", + # "notificationType": "STATE_CHANGE", + # "notificationId": "Randomly generated Id, used for tracking only", + # "notificationVersion": "V1" + # } + def new - response = JSON.parse(request.body.read) - if JSON.parse(response["Message"])["NotificationType"] == "PaymentRefund" - refund_id = Hash.from_xml(JSON.parse(response["Message"])[ "NotificationData"])["RefundNotification"]["RefundDetails"]["AmazonRefundId"] + response = JSON.parse(response.body, symbolize_names: true) + if response[:objectType] == 'Refund' + refund_id = response[:objectId] payment = Spree::LogEntry.where('details LIKE ?', "%#{refund_id}%").last.try(:source) if payment - l = payment.log_entries.build(details: response.to_yaml) + l = payment.log_entries.build(details: response) l.save end end diff --git a/app/controllers/spree/amazon_controller.rb b/app/controllers/spree/amazon_controller.rb deleted file mode 100644 index e7072df..0000000 --- a/app/controllers/spree/amazon_controller.rb +++ /dev/null @@ -1,272 +0,0 @@ -## -# Amazon Payments - Login and Pay for Spree Commerce -# -# @category Amazon -# @package Amazon_Payments -# @copyright Copyright (c) 2014 Amazon.com -# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 -# -## -class Spree::AmazonController < Spree::StoreController - helper 'spree/orders' - before_action :check_current_order - before_action :gateway, only: [:address, :payment, :delivery] - before_action :check_amazon_reference_id, only: [:delivery, :complete] - before_action :normalize_addresses, only: [:address, :delivery] - skip_before_action :verify_authenticity_token, only: %i[payment confirm complete] - - respond_to :json - - def address - set_user_information! - - if current_order.cart? - current_order.next! - else - current_order.state = 'address' - current_order.save! - end - end - - def payment - payment = amazon_payment || current_order.payments.create - payment.payment_method = gateway - payment.source ||= Spree::AmazonTransaction.create( - order_reference: params[:order_reference], - order_id: current_order.id, - retry: current_order.amazon_transactions.unsuccessful.any? - ) - - payment.save! - - render json: {} - end - - def delivery - address = SpreeAmazon::Address.find( - current_order.amazon_order_reference_id, - gateway: gateway, - address_consent_token: access_token - ) - - current_order.state = 'address' - - if address - current_order.email = current_order.email || spree_current_user.try(:email) || "pending@amazon.com" - update_current_order_address!(address, spree_current_user.try(:ship_address)) - - current_order.save! - current_order.next - - current_order.reload - - if current_order.shipments.empty? - render plain: 'Not shippable to this address' - else - render layout: false - end - else - head :ok - end - end - - def confirm - if amazon_payment.present? && current_order.update_from_params(params, permitted_checkout_attributes, request.headers.env) - while !current_order.confirm? && current_order.next - end - - update_payment_amount! - current_order.next! unless current_order.confirm? - complete - else - redirect_to :back - end - end - - def complete - @order = current_order - authorize!(:edit, @order, cookies.signed[:guest_token]) - - unless @order.amazon_transaction.retry - amazon_response = set_order_reference_details! - unless amazon_response.constraints.blank? - redirect_to address_amazon_order_path, notice: amazon_response.constraints and return - end - end - - complete_amazon_order! - - if @order.confirm? && @order.next - @order.update_column(:bill_address, @order.ship_address_id) - @current_order = nil - flash.notice = Spree.t(:order_processed_successfully) - flash[:order_completed] = true - redirect_to spree.order_path(@order) - else - amazon_transaction = @order.amazon_transaction - @order.state = 'cart' - amazon_transaction.reload - if amazon_transaction.soft_decline - @order.save! - redirect_to address_amazon_order_path, notice: amazon_transaction.message - else - @order.amazon_transactions.destroy_all - @order.save! - redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) - end - end - end - - def gateway - @gateway ||= Spree::Gateway::Amazon.for_currency(current_order.currency) - end - - private - - def access_token - params[:access_token] - end - - def amazon_order - @amazon_order ||= SpreeAmazon::Order.new( - reference_id: current_order.amazon_order_reference_id, - gateway: gateway, - ) - end - - def amazon_payment - current_order.payments.valid.amazon.first - end - - def update_payment_amount! - payment = amazon_payment - payment.amount = current_order.order_total_after_store_credit - payment.save! - end - - def set_order_reference_details! - amazon_order.set_order_reference_details( - current_order.order_total_after_store_credit, - seller_order_id: current_order.number, - store_name: current_order.store.name, - ) - end - - def set_user_information! - return unless Gem::Specification::find_all_by_name('spree_social').any? && access_token - - auth_hash = SpreeAmazon::User.find(gateway: gateway, - access_token: access_token) - - return unless auth_hash - - authentication = Spree::UserAuthentication.find_by_provider_and_uid(auth_hash['provider'], auth_hash['uid']) - - if authentication.present? && authentication.try(:user).present? - user = authentication.user - sign_in(user, scope: :spree_user) - elsif spree_current_user - spree_current_user.apply_omniauth(auth_hash) - spree_current_user.save! - user = spree_current_user - else - email = auth_hash['info']['email'] - user = Spree::User.find_by_email(email) || Spree::User.new - user.apply_omniauth(auth_hash) - user.save! - sign_in(user, scope: :spree_user) - end - - # make sure to merge the current order with signed in user previous cart - set_current_order - - current_order.associate_user!(user) - session[:guest_token] = nil - end - - def complete_amazon_order! - amazon_order.confirm - end - - def checkout_params - params.require(:order).permit(permitted_checkout_attributes) - end - - def update_current_order_address!(amazon_address, spree_user_address = nil) - bill_address = current_order.bill_address - ship_address = current_order.ship_address - - new_address = Spree::Address.new address_attributes(amazon_address, spree_user_address) - if spree_address_book_available? - user_address = spree_current_user.addresses.find do |address| - address.same_as?(new_address) - end - - if user_address - current_order.update_column(:ship_address_id, user_address.id) - else - new_address.save! - current_order.update_column(:ship_address_id, new_address.id) - end - else - if ship_address.nil? || ship_address.empty? - new_address.save! - current_order.update_column(:ship_address_id, new_address.id) - else - ship_address.update_attributes(address_attributes(amazon_address, spree_user_address)) - end - end - end - - def address_attributes(amazon_address, spree_user_address = nil) - address_params = { - firstname: amazon_address.first_name || spree_user_address.try(:first_name) || "Amazon", - lastname: amazon_address.last_name || spree_user_address.try(:last_name) || "User", - address1: amazon_address.address1 || spree_user_address.try(:address1) || "N/A", - address2: amazon_address.address2, - phone: amazon_address.phone || spree_user_address.try(:phone) || "000-000-0000", - city: amazon_address.city || spree_user_address.try(:city), - zipcode: amazon_address.zipcode || spree_user_address.try(:zipcode), - state: amazon_address.state || spree_user_address.try(:state), - country: amazon_address.country || spree_user_address.try(:country) - } - - if spree_address_book_available? - address_params = address_params.merge(user: spree_current_user) - end - - address_params - end - - def check_current_order - unless current_order - head :ok - end - end - - def check_amazon_reference_id - unless current_order.amazon_order_reference_id - head :ok - end - end - - def spree_address_book_available? - Gem::Specification::find_all_by_name('spree_address_book').any? - end - - def normalize_addresses - # ensure that there is no validation errors and addresses were saved - return unless current_order.bill_address && current_order.ship_address && spree_address_book_available? - - bill_address = current_order.bill_address - ship_address = current_order.ship_address - if current_order.bill_address_id != current_order.ship_address_id && bill_address.same_as?(ship_address) - current_order.update_column(:bill_address_id, ship_address.id) - bill_address.destroy - else - bill_address.update_attribute(:user_id, spree_current_user.try(:id)) - end - - ship_address.update_attribute(:user_id, spree_current_user.try(:id)) - end -end diff --git a/app/controllers/spree/amazonpay_controller.rb b/app/controllers/spree/amazonpay_controller.rb new file mode 100644 index 0000000..b38896d --- /dev/null +++ b/app/controllers/spree/amazonpay_controller.rb @@ -0,0 +1,289 @@ +## +# Amazon Payments - Login and Pay for Spree Commerce +# +# @category Amazon +# @package Amazon_Payments +# @copyright Copyright (c) 2014 Amazon.com +# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 +# +## +class Spree::AmazonpayController < Spree::CheckoutController + before_action :gateway + + skip_before_action :verify_authenticity_token, only: %i[create complete] + + rescue_from ActiveRecord::RecordInvalid, with: :rescue_from_active_record_error + + respond_to :json + + def create + params = { webCheckoutDetail: + { checkoutReviewReturnUrl: confirm_amazonpay_url }, + storeId: gateway.preferred_client_id } + + response = AmazonPay::CheckoutSession.create(params) + + if response.success? + render json: response.body + else + render json: {} + end + end + + def confirm + response = AmazonPay::CheckoutSession.get(amazon_checkout_session_id) + + unless response.success? + redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) + return + end + + body = response.body + status_detail = body[:statusDetail] + no_buyer = response.find_constraint('BuyerNotAssociated') + + # if the order was already completed then they shouldn't be at this step + if status_detail[:state] == 'Completed' || no_buyer + redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) + return + end + + amazon_user = SpreeAmazon::User.from_response(body) + + unless amazon_user.valid? + redirect_to cart_path, notice: Spree.t(:uid_not_set) + return + end + + set_user_information(amazon_user.auth_hash) + + if spree_current_user.nil? + @order.update_attributes!(email: amazon_user.email) + end + + amazon_address = SpreeAmazon::Address.from_response(body) + address_attributes = amazon_address.attributes + + if spree_address_book_available? + address_attributes = address_attributes.merge(user: spree_current_user) + end + + if address_restrictions(amazon_address) + redirect_to cart_path, notice: Spree.t(:cannot_ship_to_address) + return + end + + update_order_address!(address_attributes) + update_order_state('address') + + @order.unprocessed_payments.map(&:invalidate!) + + if !order_next || @order.shipments.empty? + redirect_to cart_path, notice: Spree.t(:cannot_ship_to_address) + else + order_next + end + end + + def payment + update_order_state('payment') + + unless order_next + flash[:error] = @order.errors.full_messages.join("\n") + redirect_to cart_path + return + end + + params = { + webCheckoutDetail: { + checkoutResultReturnUrl: complete_amazonpay_url + }, + paymentDetail: { + paymentIntent: 'Authorize', + canHandlePendingAuthorization: false, + chargeAmount: { + amount: @order.order_total_after_store_credit, + currencyCode: current_currency + } + }, + merchantMetadata: { + merchantReferenceId: @order.number, + merchantStoreName: current_store.name, + noteToBuyer: '', + customInformation: '' + } + } + + response = AmazonPay::CheckoutSession.update(amazon_checkout_session_id, params) + + if response.success? + web_checkout_detail = response.body[:webCheckoutDetail] + redirect_to web_checkout_detail[:amazonPayRedirectUrl] + else + redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) + end + end + + def complete + response = AmazonPay::CheckoutSession.get(amazon_checkout_session_id) + + unless response.success? + redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) + return + end + + body = response.body + status_detail = body[:statusDetail] + + # Make sure the order status from Amazon is completed otherwise + # Redirect to cart for the consumer to start over + unless status_detail[:state] == 'Completed' + redirect_to cart_path, notice: status_detail[:reasonDescription] + return + end + + payments = @order.payments + payment = payments.create + payment.payment_method = gateway + payment.source ||= Spree::AmazonTransaction.create( + order_reference: body[:chargePermissionId], + order_id: @order.id, + capture_id: body[:chargeId], + retry: false + ) + payment.amount = @order.order_total_after_store_credit + payment.response_code = body[:chargeId] + payment.save! + + @order.reload + + while order_next; end + + if @order.complete? + @current_order = nil + flash.notice = Spree.t(:order_processed_successfully) + flash[:order_completed] = true + redirect_to completion_route + else + update_order_state('cart') + amazon_transaction = @order.amazon_transaction + amazon_transaction.reload + unless amazon_transaction.soft_decline + @order.amazon_transactions.destroy_all + @order.temporary_address = true + @order.save! + end + redirect_to cart_path, notice: Spree.t(:order_processed_unsuccessfully) + end + end + + def gateway + @gateway ||= Spree::Gateway::Amazon.for_currency(@order.currency) + @gateway.load_amazon_pay + @gateway + end + + private + + def update_order_state(state) + if @order.state != state + @order.temporary_address = true + @order.state = state + @order.save! + end + end + + def amazon_checkout_session_id + params[:amazonCheckoutSessionId] + end + + # Override this function if you need to restrict shipping locations + def address_restrictions(amazon_address) + amazon_address.nil? + end + + def set_user_information(auth_hash) + return unless Gem::Specification.find_all_by_name('spree_social').any? && auth_hash + + authentication = Spree::UserAuthentication.find_by_provider_and_uid(auth_hash['provider'], auth_hash['uid']) + + if authentication.present? && authentication.try(:user).present? + user = authentication.user + sign_in(user, scope: :spree_user) + elsif spree_current_user + spree_current_user.apply_omniauth(auth_hash) + spree_current_user.save! + user = spree_current_user + else + email = auth_hash['info']['email'] + user = Spree::User.find_by_email(email) || Spree::User.new + user.apply_omniauth(auth_hash) + user.save! + sign_in(user, scope: :spree_user) + end + + # make sure to merge the current order with signed in user previous cart + set_current_order + + @order.associate_user!(user) + session[:guest_token] = nil + end + + def update_order_address!(address_attributes) + ship_address = @order.ship_address + bill_address = @order.bill_address + + new_address = Spree::Address.new address_attributes + if spree_current_user.respond_to?(:addresses) + user_address = spree_current_user.addresses.find do |address| + address.same_as?(new_address) + end + + if user_address + @order.update_column(:ship_address_id, user_address.id) + @order.update_column(:bill_address_id, user_address.id) + else + new_address.save! + @order.update_column(:ship_address_id, new_address.id) + @order.update_column(:bill_address_id, new_address.id) + end + elsif ship_address.nil? || ship_address.empty? + new_address.save! + @order.update_column(:ship_address_id, new_address.id) + @order.update_column(:bill_address_id, new_address.id) + else + ship_address.update_attributes(address_attributes) + bill_address.update_attributes(address_attributes) + end + end + + def rescue_from_spree_gateway_error(exception) + flash.now[:error] = Spree.t(:spree_gateway_error_flash_for_checkout) + @order.errors.add(:base, exception.message) + render :confirm + end + + def rescue_from_active_record_error(exception) + flash.now[:error] = Spree.t(:spree_active_record_error_flash_for_checkout) + @order.errors.add(:base, exception.message) + render :confirm + end + + def skip_state_validation? + true + end + + # We are logging the user in so there is no need to check registration + def check_registration + true + end + + def order_next + @order.temporary_address = true + @order.next + end + + def spree_address_book_available? + Gem::Specification.find_all_by_name('spree_address_book').any? + end +end diff --git a/app/jobs/alexa_delivery_notification_job.rb b/app/jobs/alexa_delivery_notification_job.rb new file mode 100644 index 0000000..3bd4d7d --- /dev/null +++ b/app/jobs/alexa_delivery_notification_job.rb @@ -0,0 +1,23 @@ +class AlexaDeliveryNotificationJob < ActiveJob::Base + queue_as :default + + def perform(shipment_id) + shipment = Spree::Shipment.find_by(id: shipment_id) + order = shipment.order + + params = { + amazonOrderReferenceId: order.amazon_order_reference_id, + deliveryDetails: [{ + trackingNumber: shipment.tracking, + carrierCode: shipment.amazon_carrier_code + }] + } + + # need to do this to make sure everything loads + Spree::Gateway::Amazon.for_currency(order.currency).load_amazon_pay + + response = AmazonPay::DeliveryTrackers.create(params) + + raise 'Could not update tracking info for Alexa DN' unless response.success? + end +end diff --git a/app/models/spree/amazon_transaction.rb b/app/models/spree/amazon_transaction.rb index 404efda..e48ab23 100644 --- a/app/models/spree/amazon_transaction.rb +++ b/app/models/spree/amazon_transaction.rb @@ -9,7 +9,7 @@ ## module Spree class AmazonTransaction < ActiveRecord::Base - has_many :payments, :as => :source + has_many :payments, as: :source scope :unsuccessful, -> { where(success: false) } @@ -42,36 +42,37 @@ def can_close?(payment) end def actions - %w{capture credit void close} + %w[capture credit void close] end def close!(payment) return true unless can_close?(payment) - amazon_order = SpreeAmazon::Order.new( - gateway: payment.payment_method, - reference_id: order_reference - ) + params = { + closureReason: 'No more charges required', + cancelPendingCharges: true + } - response = amazon_order.close_order_reference! + payment.payment_method.load_amazon_pay + + response = AmazonPay::ChargePermission.close(order_reference, params) if response.success? - update_attributes(closed_at: DateTime.now) + update_attributes(closed_at: Time.current) else - gateway_error(response) + gateway_error(response.body) end end private def gateway_error(error) - text = error.params['message'] || error.params['response_reason_text'] || error.message + text = error[:message][0...255] || error[:reasonCode] logger.error(Spree.t(:gateway_error)) - logger.error(" #{error.to_yaml}") + logger.error(" #{error}") - raise Spree::Core::GatewayError.new(text) + raise Spree::Core::GatewayError, text end - end end diff --git a/app/models/spree/gateway/amazon.rb b/app/models/spree/gateway/amazon.rb index 09a77fe..539a1a6 100644 --- a/app/models/spree/gateway/amazon.rb +++ b/app/models/spree/gateway/amazon.rb @@ -9,7 +9,7 @@ ## module Spree class Gateway::Amazon < Gateway - REGIONS = %w(us uk de jp).freeze + REGIONS = %w[us uk de jp].freeze preference :currency, :string, default: -> { Spree::Config.currency } preference :client_id, :string @@ -18,6 +18,8 @@ class Gateway::Amazon < Gateway preference :aws_secret_access_key, :string preference :region, :string, default: 'us' preference :site_domain, :string + preference :public_key_id, :string + preference :private_key_file_location, :string has_one :provider @@ -27,23 +29,12 @@ def self.for_currency(currency) where(active: true).detect { |gateway| gateway.preferred_currency == currency } end - def api_url - sandbox = preferred_test_mode ? '_Sandbox' : '' - { - 'us' => "https://mws.amazonservices.com/OffAmazonPayments#{sandbox}/2013-01-01", - 'uk' => "https://mws-eu.amazonservices.com/OffAmazonPayments#{sandbox}/2013-01-01", - 'de' => "https://mws-eu.amazonservices.com/OffAmazonPayments#{sandbox}/2013-01-01", - 'jp' => "https://mws.amazonservices.jp/OffAmazonPayments#{sandbox}/2013-01-01", - }.fetch(preferred_region) - end - def widgets_url - sandbox = preferred_test_mode ? '/sandbox' : '' { - 'us' => "https://static-na.payments-amazon.com/OffAmazonPayments/us#{sandbox}/js/Widgets.js", - 'uk' => "https://static-eu.payments-amazon.com/OffAmazonPayments/uk#{sandbox}/lpa/js/Widgets.js", - 'de' => "https://static-eu.payments-amazon.com/OffAmazonPayments/de#{sandbox}/lpa/js/Widgets.js", - 'jp' => "https://origin-na.ssl-images-amazon.com/images/G/09/EP/offAmazonPayments#{sandbox}/prod/lpa/js/Widgets.js", + 'us' => 'https://static-na.payments-amazon.com/checkout.js', + 'uk' => 'https://static-eu.payments-amazon.com/checkout.js', + 'de' => 'https://static-eu.payments-amazon.com/checkout.js', + 'jp' => 'https://static-fe.payments-amazon.com/checkout.js' }.fetch(preferred_region) end @@ -67,210 +58,145 @@ def source_required? true end - def authorize(amount, amazon_checkout, gateway_options={}) - if amount < 0 - return ActiveMerchant::Billing::Response.new(true, "Success", {}) - end + def authorize(amount, amazon_transaction, gateway_options={}) + return ActiveMerchant::Billing::Response.new(true, 'Success', {}) if amount < 0 - order_number, payment_number = extract_order_and_payment_number(gateway_options) - order = Spree::Order.find_by!(number: order_number) - payment = Spree::Payment.find_by!(number: payment_number) - authorization_reference_id = operation_unique_id(payment) - - load_amazon_mws(order.amazon_order_reference_id) - - mws_res = begin - @mws.authorize( - authorization_reference_id, - amount / 100.0, - order.currency, - seller_authorization_note: sandbox_authorize_simulation_string(order), - ) - rescue RuntimeError => e - raise Spree::Core::GatewayError.new(e.to_s) + # If there already is a capture_id available then we don't need to create + # a charage and can immediately capture + if amazon_transaction.try(:capture_id) + return capture(amount, amazon_transaction.capture_id, gateway_options) end - amazon_response = SpreeAmazon::Response::Authorization.new(mws_res) - parsed_response = amazon_response.parse rescue nil - - if amazon_response.state == 'Declined' - success = false - if amazon_response.reason_code == 'InvalidPaymentMethod' - soft_decline = true - message = amazon_response.error_message - else - soft_decline = false - message = "Authorization failure: #{amazon_response.reason_code}" - end - else - success = true - order.amazon_transaction.update!( - authorization_id: amazon_response.response_id - ) - message = 'Success' - soft_decline = nil - end + load_amazon_pay + + params = { + chargePermissionId: amazon_transaction.order_reference, + chargeAmount: { + amount: (amount / 100.0).to_s, + currencyCode: gateway_options[:currency] + }, + captureNow: false, + canHandlePendingAuthorization: false + } + + response = AmazonPay::Charge.create(params) + + success = response.success? + message = response.message[0..255] # Saving information in last amazon transaction for error flow in amazon controller - order.amazon_transaction.update!( + amazon_transaction.update!( success: success, message: message, - authorization_reference_id: authorization_reference_id, - soft_decline: soft_decline, + capture_id: response.body[:chargeId], + soft_decline: response.soft_decline?, retry: !success ) - ActiveMerchant::Billing::Response.new( - success, - message, - { - 'response' => mws_res, - 'parsed_response' => parsed_response, - }, - ) + + ActiveMerchant::Billing::Response.new(success, message, response.body) end - def capture(amount, amazon_checkout, gateway_options={}) - if amount < 0 - return credit(amount.abs, nil, nil, gateway_options) - end - order_number, payment_number = extract_order_and_payment_number(gateway_options) - order = Spree::Order.find_by!(number: order_number) - payment = Spree::Payment.find_by!(number: payment_number) - authorization_id = order.amazon_transaction.authorization_id - capture_reference_id = operation_unique_id(payment) - load_amazon_mws(order.amazon_order_reference_id) + def capture(amount, response_code, gateway_options={}) + return credit(amount.abs, response_code, gateway_options) if amount < 0 - mws_res = @mws.capture(authorization_id, capture_reference_id, amount / 100.00, order.currency) + _payment, amazon_transaction = find_payment_and_transaction(response_code) + + load_amazon_pay + + params = { + captureAmount: { + amount: (amount / 100.0).to_s, + currencyCode: gateway_options[:currency] + }, + softDescriptor: Spree::Store.current.try(:name) + } - response = SpreeAmazon::Response::Capture.new(mws_res) + capture_id = update_for_backwards_compatibility(amazon_transaction.capture_id) - payment.response_code = response.response_id - payment.save! + response = AmazonPay::Charge.capture(capture_id, params) - t = order.amazon_transaction - t.capture_id = response.response_id - t.save! + success = response.success? + message = response.message[0..255] - ActiveMerchant::Billing::Response.new(response.success_state?, "OK", - { - 'response' => mws_res, - 'parsed_response' => response.parse, - } + # Saving information in last amazon transaction for error flow in amazon controller + amazon_transaction.update!( + success: success, + message: message, + soft_decline: response.soft_decline?, + retry: !success ) + + ActiveMerchant::Billing::Response.new(success, message) end - def purchase(amount, amazon_checkout, gateway_options={}) - auth_result = authorize(amount, amazon_checkout, gateway_options) - if auth_result.success? - capture(amount, amazon_checkout, gateway_options) - else - auth_result - end + def purchase(amount, amazon_transaction, gateway_options = {}) + capture(amount, amazon_transaction.capture_id, gateway_options) end - def credit(amount, _response_code, gateway_options = {}) - payment = gateway_options[:originator].payment - amazon_transaction = payment.source + def credit(amount, response_code, _gateway_options = {}) + payment, amazon_transaction = find_payment_and_transaction(response_code) - load_amazon_mws(amazon_transaction.order_reference) - mws_res = @mws.refund( - amazon_transaction.capture_id, - operation_unique_id(payment), - amount / 100.00, - payment.currency - ) + load_amazon_pay - response = SpreeAmazon::Response::Refund.new(mws_res) - ActiveMerchant::Billing::Response.new(true, "Success", response.parse, authorization: response.response_id) - end + capture_id = update_for_backwards_compatibility(amazon_transaction.capture_id) - def void(response_code, gateway_options) - order = Spree::Order.find_by(:number => gateway_options[:order_id].split("-")[0]) - load_amazon_mws(order.amazon_order_reference_id) - capture_id = order.amazon_transaction.capture_id + params = { + chargeId: capture_id, + refundAmount: { + amount: (amount / 100.0).to_s, + currencyCode: payment.currency + }, + softDescriptor: Spree::Store.current.try(:name) + } - if capture_id.nil? - response = @mws.cancel - else - response = @mws.refund(capture_id, gateway_options[:order_id], order.order_total_after_store_credit, order.currency) - end + response = AmazonPay::Refund.create(params) + + authorization = response.success? ? response.body[:refundId] : nil + + ActiveMerchant::Billing::Response.new(response.success?, + response.message[0...255], + response.body, + authorization: authorization) + end - ActiveMerchant::Billing::Response.new(true, "Success", Hash.from_xml(response.body)) + def void(response_code, _gateway_options = {}) + cancel(response_code) end def cancel(response_code) - payment = Spree::Payment.find_by!(response_code: response_code) - order = payment.order - load_amazon_mws(payment.source.order_reference) - capture_id = order.amazon_transaction.capture_id + payment, amazon_transaction = find_payment_and_transaction(response_code) - if capture_id.nil? - response = @mws.cancel + if amazon_transaction.capture_id.nil? + load_amazon_pay + params = { cancellationReason: 'Cancelled Order' } + response = AmazonPay::Charge.cancel(amazon_transaction.order_reference, + params) + + ActiveMerchant::Billing::Response.new(response.success?, + response.message[0...255]) else - response = @mws.refund(capture_id, order.number, payment.credit_allowed, payment.currency) + credit(payment.credit_allowed * 100, response_code) end - - ActiveMerchant::Billing::Response.new(true, "#{order.number}-cancel", Hash.from_xml(response.body)) - end - - private - - def load_amazon_mws(reference) - @mws ||= AmazonMws.new(reference, gateway: self) end - def extract_order_and_payment_number(gateway_options) - gateway_options[:order_id].split('-', 2) + def load_amazon_pay + AmazonPay.region = preferred_region + AmazonPay.public_key_id = preferred_public_key_id + AmazonPay.sandbox = preferred_test_mode + AmazonPay.private_key = preferred_private_key_file_location end - # Amazon requires unique ids. Calling with the same id multiple times means - # the result of the previous call will be returned again. This can be good - # for things like asynchronous retries, but would break things like multiple - # captures on a single authorization. - def operation_unique_id(payment) - "#{payment.number}-#{random_suffix}" - end + private - # A random string of lowercase alphanumeric characters (i.e. "base 36") - def random_suffix - length = 10 - SecureRandom.random_number(36 ** length).to_s(36).rjust(length, '0') + def find_payment_and_transaction(response_code) + payment = Spree::Payment.find_by(response_code: response_code) + (raise Spree::Core::GatewayError, 'Payment not found') unless payment + amazon_transaction = payment.source + [payment, amazon_transaction] end - # Allows simulating errors in sandbox mode if the *last* name of the - # shipping address is "SandboxSimulation" and the *first* name is one of: - # - # InvalidPaymentMethodHard- (- is optional. between 1-240.) - # InvalidPaymentMethodSoft- (- is optional. between 1-240.) - # AmazonRejected - # TransactionTimedOut - # ExpiredUnused- (- is optional. between 1-60.) - # AmazonClosed - # - # E.g. a full name like: "AmazonRejected SandboxSimulation" - # - # See https://payments.amazon.com/documentation/lpwa/201956480 for more - # details on Amazon Payments Sandbox Simulations. - def sandbox_authorize_simulation_string(order) - return nil if !preferred_test_mode - return nil if order.ship_address.nil? - return nil if order.ship_address.lastname != 'SandboxSimulation' - - reason, minutes = order.ship_address.firstname.to_s.split('-', 2) - # minutes is optional and is only used for some of the reason codes - minutes ||= '1' - - case reason - when 'InvalidPaymentMethodHard' then %({"SandboxSimulation": {"State":"Declined", "ReasonCode":"InvalidPaymentMethod", "PaymentMethodUpdateTimeInMins":#{minutes}}}) - when 'InvalidPaymentMethodSoft' then %({"SandboxSimulation": {"State":"Declined", "ReasonCode":"InvalidPayment Method", "PaymentMethodUpdateTimeInMins":#{minutes}, "SoftDecline":"true"}}) - when 'AmazonRejected' then '{"SandboxSimulation": {"State":"Declined", "ReasonCode":"AmazonRejected"}}' - when 'TransactionTimedOut' then '{"SandboxSimulation": {"State":"Declined", "ReasonCode":"TransactionTimedOut"}}' - when 'ExpiredUnused' then %({"SandboxSimulation": {"State":"Closed", "ReasonCode":"ExpiredUnused", "ExpirationTimeInMins":#{minutes}}}) - when 'AmazonClosed' then '{"SandboxSimulation": {"State":"Closed", "ReasonCode":"AmazonClosed"}}' - else - Rails.logger.error('"SandboxSimulation" was given as the shipping first name but the last name was not a valid reason code: ' + order.ship_address.firstname.inspect) - nil - end + def update_for_backwards_compatibility(capture_id) + capture_id[20] == 'A' ? capture_id[20, 1] = 'C' : capture_id end end end diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb index 153fb10..bb532c7 100644 --- a/app/models/spree/order_decorator.rb +++ b/app/models/spree/order_decorator.rb @@ -21,6 +21,10 @@ def amazon_order_reference_id amazon_transaction.try(:order_reference) end + def amazon_pay_order? + amazon_transaction.try(:success) + end + def confirmation_required? spree_confirmation_required? || payments.valid.map(&:payment_method).compact.any? { |pm| pm.is_a? Spree::Gateway::Amazon } end diff --git a/app/models/spree/shipment_decorator.rb b/app/models/spree/shipment_decorator.rb new file mode 100644 index 0000000..2a91929 --- /dev/null +++ b/app/models/spree/shipment_decorator.rb @@ -0,0 +1,23 @@ +Spree::Shipment.class_eval do + delegate :amazon_pay_order?, to: :order + + self.state_machine.after_transition( + to: :shipped, + do: :alexa_delivery_notification, + if: :send_alexa_delivery_notification? + ) + + def send_alexa_delivery_notification? + amazon_pay_order? && amazon_carrier_code.present? && tracking.present? + end + + def alexa_delivery_notification + AlexaDeliveryNotificationJob.set(wait: 2.seconds).perform_later(id) + end + + # override this if your carrier codes don't match amazon carrier codes + # https://eps-eu-external-file-share.s3.eu-central-1.amazonaws.com/Alexa/Delivery+Notifications/amazon-pay-delivery-tracker-supported-carriers-v2.csv + def amazon_carrier_code + shipping_method.try(:code) + end +end diff --git a/app/models/spree_amazon/address.rb b/app/models/spree_amazon/address.rb index 51563a0..685898e 100644 --- a/app/models/spree_amazon/address.rb +++ b/app/models/spree_amazon/address.rb @@ -1,72 +1,72 @@ module SpreeAmazon class Address class << self - def find(order_reference, gateway:, address_consent_token: nil) - response = mws( - order_reference, - gateway: gateway, - address_consent_token: address_consent_token, - ).fetch_order_data - from_response(response) + def from_response(response) + new attributes_from_response(response[:shippingAddress]) end - def from_response(response) - return nil if response.destination["PhysicalDestination"].blank? - new attributes_from_response(response.destination["PhysicalDestination"]) + def attributes_from_response(address_params) + @attributes = { + address1: address_params[:addressLine1], + address2: address_params[:addressLine2], + first_name: convert_first_name(address_params[:name]) || 'Amazon', + last_name: convert_last_name(address_params[:name]) || 'User', + city: address_params[:city], + zipcode: address_params[:postalCode], + state: convert_state(address_params[:stateOrRegion], + convert_country(address_params[:countryCode])), + country: convert_country(address_params[:countryCode]), + phone: convert_phone(address_params[:phoneNumber]) || '0000000000' + } + end + + def attributes + @attributes end private - def mws(order_reference, gateway:, address_consent_token: nil) - AmazonMws.new( - order_reference, - gateway: gateway, - address_consent_token: address_consent_token, - ) + def convert_first_name(name) + return nil if name.blank? + name.split(' ').first end - def attributes_from_response(response) - { - address1: response["AddressLine1"], - address2: response["AddressLine2"], - name: response["Name"], - city: response["City"], - zipcode: response["PostalCode"], - state_name: response["StateOrRegion"], - country_code: response["CountryCode"], - phone: response["Phone"] - } + def convert_last_name(name) + return nil if name.blank? + names = name.split(' ') + names.shift + names = names.join(' ') + names.blank? ? nil : names end - end - attr_accessor :name, :city, :zipcode, :state_name, :country_code, - :address1, :address2, :phone + def convert_country(country_code) + Spree::Country.find_by(iso: country_code) + end - def initialize(attributes) - attributes.each_pair do |key, value| - send("#{key}=", value) + def convert_state(state_name, country) + Spree::State.find_by(abbr: state_name, country: country) || + Spree::State.find_by(name: state_name, country: country) end - end - def first_name - return nil if name.blank? - name.split(' ').first + def convert_phone(phone_number) + return nil if phone_number.blank? || + phone_number.length < 10 || + phone_number.length > 15 + phone_number + end end - def last_name - return nil if name.blank? - names = name.split(' ') - names.shift - names = names.join(' ') - names.blank? ? nil : names - end + attr_accessor :first_name, :last_name, :city, :zipcode, + :state, :country, :address1, :address2, :phone - def country - @country ||= Spree::Country.find_by(iso: country_code) + def initialize(attributes) + attributes.each_pair do |key, value| + send("#{key}=", value) + end end - def state - @state ||= Spree::State.find_by(abbr: state_name, country: country) + def attributes + self.class.attributes end end end diff --git a/app/models/spree_amazon/order.rb b/app/models/spree_amazon/order.rb deleted file mode 100644 index 077a2f3..0000000 --- a/app/models/spree_amazon/order.rb +++ /dev/null @@ -1,83 +0,0 @@ -class SpreeAmazon::Order - class CloseFailure < StandardError; end - - attr_accessor :state, :total, :email, :address, :reference_id, :currency, - :gateway, :address_consent_token - - def initialize(attributes) - if !attributes.key?(:gateway) - raise ArgumentError, "SpreeAmazon::Order requires a gateway parameter" - end - self.attributes = attributes - end - - def fetch - response = mws.fetch_order_data - self.attributes = attributes_from_response(response) - self - end - - def confirm - mws.confirm_order - end - - def close_order_reference! - response = mws.close_order_reference - parsed_response = Hash.from_xml(response.body) rescue nil - - if response.success - success = true - message = 'Success' - else - success = false - message = if parsed_response && parsed_response['ErrorResponse'] - error = parsed_response.fetch('ErrorResponse').fetch('Error') - "#{response.code} #{error.fetch('Code')}: #{error.fetch('Message')}" - else - "#{response.code} #{response.body}" - end - - end - - ActiveMerchant::Billing::Response.new( - success, - message, - { - 'response' => response, - 'parsed_response' => parsed_response, - }, - ) - end - - # @param total [String] The amount to set on the order - # @param amazon_options [Hash] These options are forwarded to the underlying - # call to PayWithAmazon::Client#set_order_reference_details - def set_order_reference_details(total, amazon_options={}) - SpreeAmazon::Response::SetOrderReferenceDetails.new mws.set_order_reference_details(total, amazon_options) - end - - private - - def attributes=(attributes) - attributes.each_pair do |key, value| - send("#{key}=", value) - end - end - - def mws - @mws ||= AmazonMws.new( - reference_id, - gateway: gateway, - address_consent_token: address_consent_token, - ) - end - - def attributes_from_response(response) - { - state: response.state, - total: response.total, - email: response.email, - address: SpreeAmazon::Address.from_response(response) - } - end -end diff --git a/app/models/spree_amazon/response.rb b/app/models/spree_amazon/response.rb deleted file mode 100644 index 78a45e9..0000000 --- a/app/models/spree_amazon/response.rb +++ /dev/null @@ -1,106 +0,0 @@ -class SpreeAmazon::Response - attr_reader :type, :response - - def initialize(response) - @type = self.class.name.demodulize - @response = response - end - - def fetch(path, element) - response.get_element(path, element) - end - - def response_details - "#{@type}Response/#{@type}Result/#{@type}Details" - end - - def response_id - fetch("#{response_details}", "Amazon#{@type}Id") - end - - def reference_id - fetch("#{response_details}", "#{@type}ReferenceId") - end - - def amount - fetch("#{response_details}/#{@type}Amount", "Amount") - end - - def currency_code - fetch("#{response_details}/#{@type}Amount", "CurrencyCode") - end - - def state - fetch("#{response_details}/#{@type}Status", "State") - end - - def success_state? - %w{Pending Draft Open Completed}.include?(state) - end - - def success? - response.success - end - - def reason_code - return nil if success_state? - - fetch("#{response_details}/#{@type}Status", "ReasonCode") - end - - def response_code - response.code - end - - def error_code - return nil if success? - - fetch("ErrorResponse/Error", "Code") - end - - def error_message - return nil if success? - - fetch("ErrorResponse/Error", "Message") - end - - def error_response_present? - !parse["ErrorResponse"].nil? - end - - def body - response.body - end - - def parse - Hash.from_xml(body) - end - - class Authorization < SpreeAmazon::Response - def response_details - "AuthorizeResponse/AuthorizeResult/AuthorizationDetails" - end - - def soft_decline? - fetch(response_details, 'SoftDecline').to_s != 'false' - end - end - - class Capture < SpreeAmazon::Response - end - - class Refund < SpreeAmazon::Response - end - - class SetOrderReferenceDetails < SpreeAmazon::Response - - def response_details - "SetOrderReferenceDetailsResponse/SetOrderReferenceDetailsResult/OrderReferenceDetails" - end - - def constraints - fetch("#{response_details}/Constraints/Constraint" , 'Description') - end - - end -end diff --git a/app/models/spree_amazon/user.rb b/app/models/spree_amazon/user.rb index 5fa553f..99f68ad 100644 --- a/app/models/spree_amazon/user.rb +++ b/app/models/spree_amazon/user.rb @@ -1,34 +1,46 @@ module SpreeAmazon class User class << self - def find(gateway:, access_token: nil) - response = lwa(gateway).get_login_profile(access_token) - from_response(response) - end - - def from_response(response) - return nil if response['email'].blank? - attributes_from_response(response) - end + def from_response(response) + new attributes_from_response(response[:buyer]) + end private - def lwa(gateway) - AmazonPay::Login.new(gateway.preferred_client_id, - region: :na, # Default: :na - sandbox: gateway.preferred_test_mode) # Default: false - end - - def attributes_from_response(response) + def attributes_from_response(buyer_params) + if buyer_params.present? { - 'provider' => 'amazon', - 'info' => { - 'email' => response['email'], - 'name' => response['name'] - }, - 'uid' => response['user_id'] + name: buyer_params[:name], + email: buyer_params[:email], + uid: buyer_params[:buyerId] } + else + {} end end + end + + attr_accessor :email, :name, :uid + + def initialize(attributes) + attributes.each_pair do |key, value| + send("#{key}=", value) + end + end + + def valid? + uid.present? && email.present? + end + + def auth_hash + { + 'provider' => 'amazon', + 'info' => { + 'email' => email, + 'name' => name + }, + 'uid' => uid + } + end end end diff --git a/app/overrides/spree/checkout/_delivery/add_buttons_to_delivery.html.erb.deface b/app/overrides/spree/checkout/_delivery/add_buttons_to_delivery.html.erb.deface deleted file mode 100644 index a62fbf3..0000000 --- a/app/overrides/spree/checkout/_delivery/add_buttons_to_delivery.html.erb.deface +++ /dev/null @@ -1,4 +0,0 @@ - -
- <%= submit_tag Spree.t(:place_order), :class => 'btn btn-lg btn-success' %> -
diff --git a/app/overrides/spree/orders/edit/add_pay_with_amazon.html.erb.deface b/app/overrides/spree/orders/edit/add_pay_with_amazon.html.erb.deface index b652b29..fcf11cd 100644 --- a/app/overrides/spree/orders/edit/add_pay_with_amazon.html.erb.deface +++ b/app/overrides/spree/orders/edit/add_pay_with_amazon.html.erb.deface @@ -1,4 +1,4 @@ <% if Spree::Gateway::Amazon.try(:first).try(:active?) %> - <%= render :partial => "spree/amazon/login" %> + <%= render :partial => "spree/amazonpay/login" %> <% end %> diff --git a/app/views/spree/amazon/_login.html.erb b/app/views/spree/amazon/_login.html.erb deleted file mode 100644 index ca08ff7..0000000 --- a/app/views/spree/amazon/_login.html.erb +++ /dev/null @@ -1,46 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> - -<% - gateway = Spree::Gateway::Amazon.for_currency(current_order(create_order_if_necessary: true).currency) - button_type = 'Pay' unless local_assigns[:button_type] - button_color = 'LightGray' unless local_assigns[:button_color] - button_size = 'medium' unless local_assigns[:button_size] - return_path = '/amazon_order/address' unless local_assigns[:return_path] -%> - - - -<%= javascript_include_tag gateway.widgets_url %> - -
- - diff --git a/app/views/spree/amazon/_payment.html.erb b/app/views/spree/amazon/_payment.html.erb deleted file mode 100644 index cb91525..0000000 --- a/app/views/spree/amazon/_payment.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> -new OffAmazonPayments.Widgets.Wallet({ - sellerId: '<%= @gateway.preferred_merchant_id %>', - onPaymentSelect: function(orderReference) { - jQuery.post( - '/amazon_order/payment', - {"order_reference": order_reference}, - function() { - $('#continue_to_delivery').click(); - } - ); - }, - design: { - designMode: 'responsive' - }, - onError: function(error) { - // your error handling code - } -}).bind("walletWidgetDiv"); diff --git a/app/views/spree/amazon/address.html.erb b/app/views/spree/amazon/address.html.erb deleted file mode 100644 index f6eabb8..0000000 --- a/app/views/spree/amazon/address.html.erb +++ /dev/null @@ -1,111 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> -<% content_for :head do %> - - - - -<%= javascript_include_tag @gateway.widgets_url %> - - -<% end %> - -
- -
-
-
-
- -
-
-
-
- - - -
- Loading... -
- -
diff --git a/app/views/spree/amazon/complete.html.erb b/app/views/spree/amazon/complete.html.erb deleted file mode 100644 index 3b26b8c..0000000 --- a/app/views/spree/amazon/complete.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> -
- <%= Spree.t(:order_number, :number => @order.number) %> -

<%= accurate_title %>

- <% if order_just_completed?(@order) %> - <%= Spree.t(:thank_you_for_your_order) %> - <% end %> - -
- <%= render :partial => 'spree/shared/order_details', :locals => { :order => @order } %> - -
- -

- <%= link_to Spree.t(:back_to_store), spree.root_path, :class => "button" %> - <% unless order_just_completed?(@order) %> - <% if try_spree_current_user && respond_to?(:spree_account_path) %> - <%= link_to Spree.t(:my_account), spree_account_path, :class => "button" %> - <% end %> - <% end %> -

-
-
diff --git a/app/views/spree/amazon/confirm.html.erb b/app/views/spree/amazon/confirm.html.erb deleted file mode 100644 index 836e48a..0000000 --- a/app/views/spree/amazon/confirm.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> -
-
- <%= Spree.t(:confirm) %> - <%= render :partial => 'spree/shared/order_details', :locals => { :order => current_order } %> -
- -<%= form_tag spree.complete_amazon_order_path, :class => 'edit_order' do %> -
- <%= submit_tag Spree.t(:place_order), :class => 'btn btn-lg btn-success' %> - -
-<% end %> diff --git a/app/views/spree/amazon/delivery.html.erb b/app/views/spree/amazon/delivery.html.erb deleted file mode 100644 index c65b050..0000000 --- a/app/views/spree/amazon/delivery.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%# %> -<%# Amazon Payments - Login and Pay for Spree Commerce %> -<%# %> -<%# @category Amazon %> -<%# @package Amazon_Payments %> -<%# @copyright Copyright (c) 2014 Amazon.com %> -<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> -<%# %> -<%= form_for current_order, :url => confirm_amazon_order_path, :method => :post do |form| %> - <%= render partial: 'spree/checkout/delivery', locals: {form: form} %> -<% end %> \ No newline at end of file diff --git a/app/views/spree/amazonpay/_login.html.erb b/app/views/spree/amazonpay/_login.html.erb new file mode 100644 index 0000000..751183f --- /dev/null +++ b/app/views/spree/amazonpay/_login.html.erb @@ -0,0 +1,31 @@ +<%# %> +<%# Amazon Payments - Login and Pay for Spree Commerce %> +<%# %> +<%# @category Amazon %> +<%# @package Amazon_Payments %> +<%# @copyright Copyright (c) 2014 Amazon.com %> +<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> +<%# %> + +<% + gateway = Spree::Gateway::Amazon.for_currency(current_order(create_order_if_necessary: true).currency) + return_path = '/amazonpay/create' unless local_assigns[:return_path] +%> + +<%= javascript_include_tag gateway.widgets_url %> + +
+ + diff --git a/app/views/spree/amazonpay/_order_details.erb b/app/views/spree/amazonpay/_order_details.erb new file mode 100644 index 0000000..2802456 --- /dev/null +++ b/app/views/spree/amazonpay/_order_details.erb @@ -0,0 +1,131 @@ +
+ <% if order.has_step?("address") %> + <% if order.has_step?("delivery") %> +
+

<%= Spree.t(:shipping_address) %>

+ <%= render 'spree/shared/address', address: order.ship_address %> +
+ +
+

<%= Spree.t(:shipments) %> <%= link_to "(#{Spree.t(:edit)})", checkout_state_path(:delivery) unless order.completed? %>

+
+ <% order.shipments.each do |shipment| %> +
+ + <%= Spree.t(:shipment_details, stock_location: shipment.stock_location.name, shipping_method: shipment.selected_shipping_rate.name) %> +
+ <% end %> +
+ <%= render 'spree/shared/shipment_tracking', order: order if order.shipped? %> +
+ <% end %> + <% end %> + + <% if order.has_step?("payment") %> +
+

<%= Spree.t(:payment_information) %>

+
+ <% order.payments.valid.each do |payment| %> + <%= render payment %>
+ <% end %> +
+
+ <% end %> +
+ +
+ + + + + + + + + + + + + + + + + + + <% order.line_items.each do |item| %> + + + + + + + + <% end %> + + + + + + + + + + + + + + + + <% if order.line_item_adjustments.exists? %> + <% if order.line_item_adjustments.promotion.eligible.exists? %> + + <% order.line_item_adjustments.promotion.eligible.group_by(&:label).each do |label, adjustments| %> + + + + + <% end %> + + <% end %> + <% end %> + + + <% order.shipments.group_by { |s| s.selected_shipping_rate.name }.each do |name, shipments| %> + + + + + <% end %> + + + <% if order.all_adjustments.tax.exists? %> + + <% order.all_adjustments.tax.group_by(&:label).each do |label, adjustments| %> + + + + + <% end %> + + <% end %> + + + <% order.adjustments.eligible.each do |adjustment| %> + <% next if (adjustment.source_type == 'Spree::TaxRate') and (adjustment.amount == 0) %> + + + + + <% end %> + +
<%= Spree.t(:item) %><%= Spree.t(:price) %><%= Spree.t(:qty) %><%= Spree.t(:total) %>
+ <% if item.variant.images.length == 0 %> + <%= link_to small_image(item.variant.product), item.variant.product %> + <% else %> + <%= link_to image_tag(item.variant.images.first.attachment.url(:small)), item.variant.product %> + <% end %> + +

<%= item.variant.product.name %>

+ <%= truncated_product_description(item.variant.product) %> + <%= "(" + item.variant.options_text + ")" unless item.variant.option_values.empty? %> +
<%= item.single_money.to_html %><%= item.quantity %><%= item.display_amount.to_html %>
<%= Spree.t(:order_total) %>:<%= order.display_total.to_html %>
<%= Spree.t(:subtotal) %>:<%= order.display_item_total.to_html %>
<%= Spree.t(:promotion) %>: <%= label %><%= Spree::Money.new(adjustments.sum(&:amount), currency: order.currency) %>
<%= Spree.t(:shipping) %>: <%= name %><%= Spree::Money.new(shipments.sum(&:discounted_cost), currency: order.currency).to_html %>
<%= Spree.t(:tax) %>: <%= label %><%= Spree::Money.new(adjustments.sum(&:amount), currency: order.currency) %>
<%= adjustment.label %><%= adjustment.display_amount.to_html %>
\ No newline at end of file diff --git a/app/views/spree/amazonpay/confirm.html.erb b/app/views/spree/amazonpay/confirm.html.erb new file mode 100644 index 0000000..9624125 --- /dev/null +++ b/app/views/spree/amazonpay/confirm.html.erb @@ -0,0 +1,34 @@ +<%# %> +<%# Amazon Payments - Login and Pay for Spree Commerce %> +<%# %> +<%# @category Amazon %> +<%# @package Amazon_Payments %> +<%# @copyright Copyright (c) 2014 Amazon.com %> +<%# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 %> +<%# %> +<%= javascript_include_tag @gateway.widgets_url %> + +
+
+ <%= Spree.t(:confirm) %> + <%= render :partial => 'spree/amazonpay/order_details', :locals => { :order => current_order } %> +
+ +<%= form_tag spree.payment_amazonpay_path, :class => 'edit_order' do %> +
+ <%= hidden_field_tag :amazonCheckoutSessionId, params[:amazonCheckoutSessionId] %> + <%= submit_tag Spree.t(:place_order), :class => 'btn btn-lg btn-success' %> + +
+<% end %> + + \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index feaa06f..0f8c53c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -17,5 +17,10 @@ en: merchant_id: Merchant ID aws_access_key_id: MWS Access Key ID aws_secret_access_key: MWS Secret Access Key + public_key_id: Public Key ID + private_key_file_location: Private Key File Location order_reference_id: Order Reference ID order_processed_unsuccessfully: Your payment could not be processed. Please try to place the order again using another payment method. + cannot_ship_to_address: Cannot ship to this address. + spree_active_record_error_flash_for_checkout: "We could not complete your order at this time. An item may have become unavailable." + uid_not_set: "We couldn't locate an Amazon User ID. Please try again." diff --git a/config/routes.rb b/config/routes.rb index fc8436f..f94ac8c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,14 +8,14 @@ # ## Spree::Core::Engine.routes.draw do - resource :amazon_order, only: [], controller: "amazon" do + resource :amazonpay, only: [], controller: 'amazonpay' do member do - get 'address' + post 'create' + get 'confirm' + post 'delivery' post 'payment' - get 'delivery' - post 'confirm' post 'complete' - get 'complete' + get 'complete' end end diff --git a/lib/amazon/charge.rb b/lib/amazon/charge.rb new file mode 100644 index 0000000..adfbfc0 --- /dev/null +++ b/lib/amazon/charge.rb @@ -0,0 +1,19 @@ +module AmazonPay + class Charge + def self.create(params) + AmazonPay.request('post', 'charges', params) + end + + def self.get(charge_id) + AmazonPay.request('get', "charges/#{charge_id}") + end + + def self.capture(charge_id, params) + AmazonPay.request('post', "charges/#{charge_id}/capture", params) + end + + def self.cancel(charge_id, params) + AmazonPay.request('delete', "charges/#{charge_id}/cancel", params) + end + end +end diff --git a/lib/amazon/charge_permission.rb b/lib/amazon/charge_permission.rb new file mode 100644 index 0000000..4125368 --- /dev/null +++ b/lib/amazon/charge_permission.rb @@ -0,0 +1,15 @@ +module AmazonPay + class ChargePermission + def self.get(charge_permission_id) + AmazonPay.request('get', "chargePermissions/#{charge_permission_id}") + end + + def self.update(charge_permission_id, params) + AmazonPay.request('patch', "chargePermissions/#{charge_permission_id}", params) + end + + def self.close(charge_permission_id, params) + AmazonPay.request('delete', "chargePermissions/#{charge_permission_id}/close", params) + end + end +end diff --git a/lib/amazon/checkout_session.rb b/lib/amazon/checkout_session.rb new file mode 100644 index 0000000..f472c29 --- /dev/null +++ b/lib/amazon/checkout_session.rb @@ -0,0 +1,15 @@ +module AmazonPay + class CheckoutSession + def self.create(params) + AmazonPay.request('post', 'checkoutSessions', params) + end + + def self.get(checkout_session_id) + AmazonPay.request('get', "checkoutSessions/#{checkout_session_id}") + end + + def self.update(checkout_session_id, params) + AmazonPay.request('patch', "checkoutSessions/#{checkout_session_id}", params) + end + end +end diff --git a/lib/amazon/delivery_trackers.rb b/lib/amazon/delivery_trackers.rb new file mode 100644 index 0000000..455fb81 --- /dev/null +++ b/lib/amazon/delivery_trackers.rb @@ -0,0 +1,7 @@ +module AmazonPay + class DeliveryTrackers + def self.create(params) + AmazonPay.request('post', 'deliveryTrackers', params) + end + end +end diff --git a/lib/amazon/refund.rb b/lib/amazon/refund.rb new file mode 100644 index 0000000..94be018 --- /dev/null +++ b/lib/amazon/refund.rb @@ -0,0 +1,11 @@ +module AmazonPay + class Refund + def self.create(params) + AmazonPay.request('post', 'refunds', params) + end + + def self.get(refund_id) + AmazonPay.request('get', "refunds/#{refund_id}") + end + end +end diff --git a/lib/amazon/response.rb b/lib/amazon/response.rb new file mode 100644 index 0000000..3f10488 --- /dev/null +++ b/lib/amazon/response.rb @@ -0,0 +1,43 @@ +module AmazonPay + class Response + attr_reader :type, :response, :body + + def initialize(response) + @type = self.class.name.demodulize + @response = response + @body = JSON.parse(response.body, symbolize_names: true) unless failure? + end + + def success? + response_code == 200 || response_code == 201 + end + + def failure? + response_code >= 500 + end + + def response_code + response.code.to_i + end + + def reason_code + return nil if success? + body[:reasonCode] + end + + def soft_decline? + return nil if success? + reason_code == 'SoftDeclined' + end + + def find_constraint(constraint) + return nil unless body[:constraints] + body[:constraints].find { |c| c[:constraintId] == constraint } + end + + def message + return 'Success' if success? + body[:message] + end + end +end diff --git a/lib/amazon_mws.rb b/lib/amazon_mws.rb deleted file mode 100644 index 6a42818..0000000 --- a/lib/amazon_mws.rb +++ /dev/null @@ -1,153 +0,0 @@ -## -# Amazon Payments - Login and Pay for Spree Commerce -# -# @category Amazon -# @package Amazon_Payments -# @copyright Copyright (c) 2014 Amazon.com -# @license http://opensource.org/licenses/Apache-2.0 Apache License, Version 2.0 -# -## - -require 'pay_with_amazon' - -class AmazonMwsOrderResponse - def initialize(response) - @response = Hash.from_xml(response.body).fetch("GetOrderReferenceDetailsResponse", {}) - end - - def destination - @response.fetch("GetOrderReferenceDetailsResult", {}).fetch("OrderReferenceDetails", {}).fetch("Destination", {}) - end - - def constraints - @response.fetch("GetOrderReferenceDetailsResult", {}).fetch("OrderReferenceDetails", {}).fetch("Constraints", {}).fetch("Constraint", {}) - end - - def state - @response.fetch("GetOrderReferenceDetailsResult", {}).fetch("OrderReferenceDetails", {}).fetch("OrderReferenceStatus", {}).fetch("State", {}) - end - - def total - total_block = @response.fetch("GetOrderReferenceDetailsResult", {}).fetch("OrderReferenceDetails", {}).fetch("OrderTotal", {}) - Spree::Money.new(total_block.fetch("Amount", 0), :with_currency => total_block.fetch("CurrencyCode", "USD")) - end - - def email - @response.fetch("GetOrderReferenceDetailsResult", {}).fetch("OrderReferenceDetails", {}).fetch("Buyer", {}).fetch("Email", {}) - end -end - -class AmazonMws - delegate :get_order_reference_details, to: :client - - def initialize(amazon_order_reference_id, gateway:, address_consent_token: nil) - @amazon_order_reference_id = amazon_order_reference_id - @gateway = gateway - @address_consent_token = address_consent_token - end - - - def fetch_order_data - AmazonMwsOrderResponse.new( - get_order_reference_details(@amazon_order_reference_id, address_consent_token: @address_consent_token) - ) - end - - # @param total [String] The amount to set on the order - # @param amazon_options [Hash] These options are forwarded to the underlying - # call to PayWithAmazon::Client#set_order_reference_details - def set_order_reference_details(total, amazon_options={}) - client.set_order_reference_details( - @amazon_order_reference_id, - total, - amazon_options - ) - end - - def set_order_data(total, currency) - client.set_order_reference_details( - @amazon_order_reference_id, - total, - currency_code: currency - ) - end - - def confirm_order - client.confirm_order_reference(@amazon_order_reference_id) - end - - def authorize(authorization_reference_id, total, currency, seller_authorization_note: nil) - client.authorize( - @amazon_order_reference_id, - authorization_reference_id, - total, - currency_code: currency, - transaction_timeout: 0, # 0 is synchronous mode - capture_now: false, - seller_authorization_note: seller_authorization_note, - ) - end - - def get_authorization_details(auth_id) - client.get_authorization_details(auth_id) - end - - def capture(auth_number, ref_number, total, currency, seller_capture_note: nil) - client.capture( - auth_number, - ref_number, - total, - currency_code: currency, - seller_capture_note: seller_capture_note - ) - end - - def get_capture_details(capture_id) - client.get_capture_details(capture_id) - end - - def refund(capture_id, ref_number, total, currency, seller_refund_note: nil) - client.refund( - capture_id, - ref_number, - total, - currency_code: currency, - seller_refund_note: seller_refund_note - ) - end - - def get_refund_details(refund_id) - client.get_refund_details(refund_id) - end - - def cancel - client.cancel_order_reference(@amazon_order_reference_id) - end - - # Amazon's description: - # > Call the CloseOrderReference operation to indicate that a previously - # > confirmed order reference has been fulfilled (fully or partially) and that - # > you do not expect to create any new authorizations on this order - # > reference. You can still capture funds against open authorizations on the - # > order reference. - # > After you call this operation, the order reference is moved into the - # > Closed state. - # https://payments.amazon.com/documentation/apireference/201752000 - def close_order_reference - client.close_order_reference(@amazon_order_reference_id) - end - - private - - def client - @client ||= PayWithAmazon::Client.new( - @gateway.preferred_merchant_id, - @gateway.preferred_aws_access_key_id, - @gateway.preferred_aws_secret_access_key, - region: @gateway.preferred_region.to_sym, - currency_code: @gateway.preferred_currency, - sandbox: @gateway.preferred_test_mode, - platform_id: nil, # TODO: Get a platform id for spree_amazon_payments - ) - end -end diff --git a/lib/amazon_pay.rb b/lib/amazon_pay.rb new file mode 100644 index 0000000..3c9ffe3 --- /dev/null +++ b/lib/amazon_pay.rb @@ -0,0 +1,266 @@ +require 'net/http' +require 'securerandom' +require 'openssl' +require 'time' +require 'active_support/core_ext' + +require 'amazon/charge_permission' +require 'amazon/charge' +require 'amazon/checkout_session' +require 'amazon/refund' +require 'amazon/response' +require 'amazon/delivery_trackers' + +module AmazonPay + @@amazon_signature_algorithm = 'AMZN-PAY-RSASSA-PSS'.freeze + @@hash_algorithm = 'SHA256'.freeze + @@public_key_id = nil + @@region = nil + @@sandbox = 'true' + @@private_key = 'private.pem' + + def self.region=(region) + @@region = region + end + + def self.public_key_id=(public_key_id) + @@public_key_id = public_key_id + end + + def self.sandbox=(sandbox) + @@sandbox = sandbox + end + + def self.private_key=(private_key) + @@private_key = private_key + end + + def self.request(method_type, url, body = nil) + method_types = { + 'post' => Net::HTTP::Post, + 'get' => Net::HTTP::Get, + 'put' => Net::HTTP::Put, + 'patch' => Net::HTTP::Patch, + 'delete' => Net::HTTP::Delete + } + + url = base_api_url + url + + uri = URI.parse(url) + request = method_types[method_type.downcase].new(uri) + request['content-type'] = 'application/json' + if method_type.downcase == 'post' + request['x-amz-pay-idempotency-key'] = SecureRandom.hex(10) + end + request['x-amz-pay-date'] = formatted_timestamp + + request_payload = JSON.dump(body) if body.present? + request.body = request_payload + + headers = request + request_parameters = {} + signed_headers = signed_headers(method_type.downcase, url, request_parameters, request_payload, headers) + signed_headers.each { |key, value| request[key] = value } + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(request) + end + + AmazonPay::Response.new response + end + + def self.signed_headers(http_request_method, request_uri, request_parameters, request_payload, other_presigned_headers = nil) + request_payload ||= '' + request_payload = check_for_payment_critical_data_api(request_uri, http_request_method, request_payload) + pre_signed_headers = {} + pre_signed_headers['accept'] = 'application/json' + pre_signed_headers['content-type'] = 'application/json' + pre_signed_headers['x-amz-pay-region'] = @@region + + if other_presigned_headers.present? + other_presigned_headers.each do |key, val| + next unless key.downcase == 'x-amz-pay-idempotency-key' && val.present? + pre_signed_headers['x-amz-pay-idempotency-key'] = val + end + end + + time_stamp = formatted_timestamp + signature = create_signature(http_request_method, request_uri, + request_parameters, pre_signed_headers, + request_payload, time_stamp) + headers = canonical_headers(pre_signed_headers) + headers['x-amz-pay-date'] = time_stamp + headers['x-amz-pay-host'] = amz_pay_host(request_uri) + signed_headers = "SignedHeaders=#{canonical_headers_names(headers)}, Signature=#{signature}" + # Do not add x-amz-pay-idempotency-key header here, as user-supplied headers get added later + header_array = {} + header_array['accept'] = string_from_array(headers['accept']) + header_array['content-type'] = string_from_array(headers['content-type']) + header_array['x-amz-pay-host'] = amz_pay_host(request_uri) + header_array['x-amz-pay-date'] = time_stamp + header_array['x-amz-pay-region'] = @@region + header_array['authorization'] = "#{@@amazon_signature_algorithm} PublicKeyId=#{@@public_key_id}, #{signed_headers}" + header_array.sort_by { |key, _value| key }.to_h + end + + def self.create_signature(http_request_method, request_uri, request_parameters, pre_signed_headers, request_payload, time_stamp) + rsa = OpenSSL::PKey::RSA.new(File.read(@@private_key)) + pre_signed_headers['x-amz-pay-date'] = time_stamp + pre_signed_headers['x-amz-pay-host'] = amz_pay_host(request_uri) + hashed_payload = hex_and_hash(request_payload) + canonical_uri = canonical_uri(request_uri) + canonical_query_string = create_canonical_query(request_parameters) + canonical_header = header_string(pre_signed_headers) + signed_headers = canonical_headers_names(pre_signed_headers) + canonical_request = "#{http_request_method.upcase}\n#{canonical_uri}\n#{canonical_query_string}\n#{canonical_header}\n#{signed_headers}\n#{hashed_payload}" + hashed_canonical_request = "#{@@amazon_signature_algorithm}\n#{hex_and_hash(canonical_request)}" + Base64.strict_encode64(rsa.sign_pss(@@hash_algorithm, hashed_canonical_request, salt_length: 20, mgf1_hash: @@hash_algorithm)) + end + + def self.hex_and_hash(data) + Digest::SHA256.hexdigest(data) + end + + def self.check_for_payment_critical_data_api(request_uri, http_request_method, request_payload) + payment_critical_data_apis = ['/live/account-management/v1/accounts', '/sandbox/account-management/v1/accounts'] + allowed_http_methods = %w[post put patch] + # For APIs handling payment critical data, the payload shouldn't be + # considered in the signature calculation + payment_critical_data_apis.each do |api| + if request_uri.include?(api) && allowed_http_methods.include?(http_request_method.downcase) + return '' + end + end + request_payload + end + + def self.formatted_timestamp + Time.now.utc.iso8601.split(/[-,:]/).join + end + + def self.canonical_headers(headers) + sorted_canonical_array = {} + headers.each do |key, val| + sorted_canonical_array[key.to_s.downcase] = val if val.present? + end + sorted_canonical_array.sort_by { |key, _value| key }.to_h + end + + def self.amz_pay_host(url) + return '/' unless url.present? + parsed_url = URI.parse(url) + + if parsed_url.host.present? + parsed_url.host + else + '/' + end + end + + def self.canonical_headers_names(headers) + sorted_header = canonical_headers(headers) + parameters = [] + sorted_header.each { |key, _value| parameters << key } + parameters.sort! + parameters.join(';') + end + + def self.string_from_array(array_data) + if array_data.is_a?(Array) + collect_sub_val(array_data) + else + array_data + end + end + + def self.collect_sub_val(parameters) + category_index = 0 + collected_values = '' + + parameters.each do |value| + collected_values += ' ' unless category_index.zero? + collected_values += value + category_index += 1 + end + collected_values + end + + def self.canonical_uri(unencoded_uri) + return '/' if unencoded_uri == '' + + url_array = URI.parse(unencoded_uri) + if url_array.path.present? + url_array.path + else + '/' + end + end + + def self.create_canonical_query(request_parameters) + sorted_request_parameters = sort_canonical_array(request_parameters) + parameters_as_string(sorted_request_parameters) + end + + def self.sort_canonical_array(canonical_array) + sorted_canonical_array = {} + canonical_array.each do |key, val| + if val.is_a?(Object) + sub_arrays(val, key.to_s).each do |new_key, sub_val| + sorted_canonical_array[new_key.to_s] = sub_val + end + elsif !val.present + else + sorted_canonical_array[key.to_s] = val + end + end + sorted_canonical_array.sort_by { |key, _value| key }.to_h + end + + def self.sub_arrays(parameters, category) + category_index = 0 + new_parameters = {} + category_string = "#{category}." + parameters.each do |value| + category_index += 1 + new_parameters["#{category_string}#{category_index}"] = value + end + new_parameters + end + + def self.parameters_as_string(parameters) + query_parameters = [] + parameters.each do |key, value| + query_parameters << "#{key}=#{url_encode(value)}" + end + query_parameters.join('&') + end + + def self.url_encode(value) + URI::encode(value).gsub('%7E', '~') + end + + def self.header_string(headers) + query_parameters = [] + sorted_headers = canonical_headers(headers) + + sorted_headers.each do |key, value| + if value.is_a?(Array) + value = collect_sub_val(value) + else + query_parameters << "#{key}:#{value}" + end + end + return_string = query_parameters.join("\n") + "#{return_string}\n" + end + + def self.base_api_url + sandbox = @@sandbox ? 'sandbox' : 'live' + { + 'us' => "https://pay-api.amazon.com/#{sandbox}/v1/", + 'uk' => "https://pay-api.amazon.eu/#{sandbox}/v1/", + 'de' => "https://pay-api.amazon.eu/#{sandbox}/v1/", + 'jp' => "https://pay-api.amazon.jp/#{sandbox}/v1/", + }.fetch(@@region) + end +end diff --git a/lib/spree_amazon_payments.rb b/lib/spree_amazon_payments.rb index 4e40639..a8827a0 100644 --- a/lib/spree_amazon_payments.rb +++ b/lib/spree_amazon_payments.rb @@ -9,5 +9,4 @@ ## require 'spree_core' require 'spree_amazon_payments/engine' -require 'amazon_mws' require 'amazon_pay' diff --git a/spree_amazon_payments.gemspec b/spree_amazon_payments.gemspec index c80f8fe..21866af 100644 --- a/spree_amazon_payments.gemspec +++ b/spree_amazon_payments.gemspec @@ -11,11 +11,11 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = 'spree_amazon_payments' - s.version = '3.1.0' + s.version = '3.2.0' s.summary = 'Spree Amazon Payments' s.description = '' - s.required_ruby_version = '>= 2.1.0' + s.required_ruby_version = '>= 2.3.0' s.required_rubygems_version = '>= 1.8.23' s.author = 'Amazon Payments' @@ -26,6 +26,7 @@ Gem::Specification.new do |s| s.add_dependency 'spree_core' s.add_dependency 'pay_with_amazon' s.add_dependency 'amazon_pay' + s.add_dependency 'openssl', '~> 2.1.0' s.add_development_dependency 'capybara' s.add_development_dependency 'coffee-rails'