diff --git a/app/controllers/spree/api/v2/storefront/checkout_controller_decorator.rb b/app/controllers/spree/api/v2/storefront/checkout_controller_decorator.rb index 6f60d1a1..b09f3a78 100644 --- a/app/controllers/spree/api/v2/storefront/checkout_controller_decorator.rb +++ b/app/controllers/spree/api/v2/storefront/checkout_controller_decorator.rb @@ -17,12 +17,14 @@ def request_update_payment render_serialized_payload { serialize_resource(order) } else payment = find_payment(order, params[:payment_number]) - context = payment.request_update - if context.success? && context.error_message.blank? + processor = Vpago::PaymentProcessor.new(payment: payment) + processor.call + + if processor.success? render_serialized_payload { serialize_resource(order) } else - render_error_payload(context.error_message) + render_error_payload(processor.error) end end end diff --git a/app/controllers/spree/webhook/payways_controller.rb b/app/controllers/spree/webhook/payways_controller.rb index 1b99693a..5f73b44e 100644 --- a/app/controllers/spree/webhook/payways_controller.rb +++ b/app/controllers/spree/webhook/payways_controller.rb @@ -6,15 +6,11 @@ class PaywaysController < BaseController # match via: [:get, :post] # {"response"=>"{\"tran_id\":\"PE13LXT1\",\"status\":0"}"} def v2_return - handler_service = v2_request_updater_service - - return_callback_handler(handler_service) + return_callback_handler end def return - handler_service = request_updater_service - - return_callback_handler(handler_service) + return_callback_handler end # https://vtenh.herokuapp.com/payways/continue?tran_id=P2W2S1LB @@ -28,29 +24,16 @@ def continue private - def v2_request_updater_service - ::Vpago::PaywayV2::PaymentRequestUpdater - end - - def request_updater_service - ::Vpago::Payway::PaymentRequestUpdater - end - - # the callback invoke by PAYWAY in case of success - def return_callback_handler(handler_service) - # pawway send get request with nothing + def return_callback_handler return render plain: :ok if request.method == 'GET' builder = Vpago::PaywayReturnOptionsBuilder.new(params: params) payment = builder.payment - request_updater = handler_service.new(payment) - request_updater.call - - order = payment.order - order = order.reload + processor = Vpago::PaymentProcessor.new(payment: payment) + processor.call - if order.paid? || payment.pending? + if processor.success? render plain: :success else render plain: :failed, status: 400 diff --git a/app/models/spree/gateway/payway.rb b/app/models/spree/gateway/payway.rb index d6b05fd6..4b760e83 100644 --- a/app/models/spree/gateway/payway.rb +++ b/app/models/spree/gateway/payway.rb @@ -55,7 +55,7 @@ def process(_money, _source, gateway_options) ActiveMerchant::Billing::Response.new(true, 'Order created') end - def cancel(_response_code) + def cancel(_response_code, _payment) # we can use this to send request to payment gateway api to cancel the payment ( void ) # currently Payway does not support to cancel the gateway diff --git a/app/models/spree/gateway/payway_v2.rb b/app/models/spree/gateway/payway_v2.rb index 434c487d..1d235ab6 100644 --- a/app/models/spree/gateway/payway_v2.rb +++ b/app/models/spree/gateway/payway_v2.rb @@ -1,8 +1,5 @@ module Spree class Gateway::PaywayV2 < PaymentMethod - # preference :endpoint, :string - # preference :return_url, :string - # preference :continue_success_url, :string preference :host, :string preference :api_key, :string preference :merchant_id, :string @@ -12,25 +9,31 @@ class Gateway::PaywayV2 < PaymentMethod preference :transaction_fee_percentage, :string preference :public_key, :text - # Only enable one-click payments if spree_auth_devise is installed - # def self.allow_one_click_payments? - # Gem.loaded_specs.key?('spree_auth_devise') - # end - - validates :preferred_public_key, presence: true, if: :require_public_key? - - def require_public_key? - enable_pre_auth == true - end + validates :preferred_public_key, presence: true, if: :enable_pre_auth? def payment_source_class Spree::VpagoPaymentSource end + # override def payment_profiles_supported? false end + # override + def support_payout? + return false unless default_payout_profile.present? && default_payout_profile.receivable? + + true + end + + # override + def support_pre_auth? + return false unless enable_pre_auth? + + true + end + def card_type if Vpago::Payway::CARD_TYPES.index(preferences[:payment_option]).nil? Vpago::Payway::CARD_TYPE_ABAPAY @@ -39,43 +42,136 @@ def card_type end end - def payment_option_card? - preferences[:payment_option] == Vpago::Payway::CARD_TYPE_CARDS + # partial to render the gateway. + def method_type + 'payway_v2' end - def payment_option_aba? - preferences[:payment_option] == Vpago::Payway::CARD_TYPE_ABAPAY + # override + # authorize payment if pre-auth is enabled, otherwise purchase / complete immediately. + def auto_capture? + !enable_pre_auth? end - # partial to render the gateway. - def method_type - 'payway_v2' + # override + def capture(_amount, _response_code, gateway_options) + _, payment_number = gateway_options[:order_id].split('-') + payment = Spree::Payment.find_by(number: payment_number) + + success = true + params = {} + + if payment.support_pre_auth? + success, params[:pre_auth] = confirm_pre_auth(payment) + elsif payment.support_payout? + success, params[:payout] = confirm_payouts(payment) + end + + if success + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Success', params) + else + ActiveMerchant::Billing::Response.new(false, 'Payway Gateway: Failed', params) + end end - # Custom PaymentMethod/Gateway can redefine this method to check method - # availability for concrete order. - def available_for_order?(_order) - true + # override + # purchase is used when pre auth disabled + def purchase(_amount, _source, _gateway_options = {}) + _, payment_number = gateway_options[:order_id].split('-') + payment = Spree::Payment.find_by(number: payment_number) + + checker = check_transaction(payment) + payment.update(transaction_response: checker.json_response) + + success = checker.success? + params = {} + + success, params[:payout] = confirm_payouts(payment) if success && payment.support_payout? + + if success + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Purchased', params, { authorization: checker.transaction_id }) + else + ActiveMerchant::Billing::Response.new(false, 'Payway Gateway: Purchasing Failed', params, { authorization: checker.transaction_id, error_code: checker.status }) + end end - # force to purchase instead of authorize - def auto_capture? - true + # override + # authorize is used when pre auth enabled + def authorize(_amount, _source, gateway_options = {}) + _, payment_number = gateway_options[:order_id].split('-') + payment = Spree::Payment.find_by(number: payment_number) + + checker = check_transaction(payment) + payment.update(transaction_response: checker.json_response) + + success = checker.success? + params = {} + + if success + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Authorized', params, { authorization: checker.transaction_id }) + else + ActiveMerchant::Billing::Response.new(false, 'Payway Gateway: Authorization Failed', params, { authorization: checker.transaction_id, error_code: checker.status }) + end + end + + # override + def void(_response_code, gateway_options) + _, payment_number = gateway_options[:order_id].split('-') + payment = Spree::Payment.find_by(number: payment_number) + + return ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Payment has been voided.') unless payment.support_pre_auth? + + canceler = Vpago::PaywayV2::PreAuthCanceler.new(payment) + canceler.call + + if canceler.success? + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Pre-authorization successfully canceled.') + else + ActiveMerchant::Billing::Response.new(false, 'Payway Gateway: Failed to cancel pre-authorization.') + end + end + + # override + def cancel(_response_code, _payment) + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Payment has been canceled.') + end + + private + + def check_transaction(payment) + checker = Vpago::PaywayV2::TransactionStatus.new(payment) + checker.call + checker end - def process(_money, _source, gateway_options) - Rails.logger.debug { "About to create payment for order #{gateway_options[:order_id]}" } - # First of all, invalidate all previous tranx orders to prevent multiple paid orders - # source.save! - ActiveMerchant::Billing::Response.new(true, 'Order created') + def confirm_pre_auth(payment) + completer = Vpago::PaywayV2::PreAuthCompleter.new(payment) + completer.call + + pre_auth_params = { merchant_auth: completer.merchant_auth } + [completer.success?, pre_auth_params] end - def cancel(_response_code) - # we can use this to send request to payment gateway api to cancel the payment ( void ) - # currently Payway does not support to cancel the gateway + def confirm_payouts(payment) + expect_payout_total = payment.payouts.sum(:amount) + confirmed = payout_total(payment) == expect_payout_total + success = false + + if confirmed + payment.payouts.find_each { |payout| payout.update!(state: :confirmed) } + success = true + end + + payout_params = { payout: checker.payout_total, expect_payout: expect_payout_total } + [success, payout_params] + end + + def payout_total(payment) + payouts_response = payment.transaction_response['payout'] + + return nil if payouts_response.nil? || !payouts_response.is_a?(Array) || payouts_response.empty? - # in our case don't do anything - ActiveMerchant::Billing::Response.new(true, 'Payway order has been cancelled.') + payouts_response.map { |payout| payout['amt'].to_f || 0 }.sum end end end diff --git a/app/models/vpago/order_decorator.rb b/app/models/vpago/order_decorator.rb index 371d8f87..772043e0 100644 --- a/app/models/vpago/order_decorator.rb +++ b/app/models/vpago/order_decorator.rb @@ -10,6 +10,24 @@ def self.prepended(base) through: :line_items base.state_machine.before_transition from: :cart, do: :ensure_valid_vendor_payment_methods + base.state_machine.after_transition to: :complete, do: :generate_line_items_total_metadata + base.state_machine.after_transition to: :complete, do: :capture_payments!, if: :should_capture_payments_after_complete? + end + + # override + def process_payments! + possible_payment_total = payment_total + pending_payments.sum(:amount) + return if possible_payment_total >= total + + super + end + + def should_capture_payments_after_complete? + pending_payments.any? + end + + def capture_payments! + pending_payments.each(&:capture!) end def ensure_valid_vendor_payment_methods @@ -25,30 +43,6 @@ def line_items_from_same_vendor? line_items.joins(:variant).pluck('spree_variants.vendor_id').uniq.size == 1 end - # Make sure the order confirmation is delivered when the order has been paid for. - def finalize! - # lock all adjustments (coupon promotions, etc.) - all_adjustments.each(&:close) - - # update payment and shipment(s) states, and save - updater.update_payment_state - - shipments.each do |shipment| - shipment.update!(self) - shipment.finalize! if paid? || authorized? - end - - updater.update_shipment_state - save! - updater.run_hooks - - touch :completed_at - - deliver_order_confirmation_email if !confirmation_delivered? && (paid? || authorized?) - - consider_risk - end - def required_payway_payout? line_items.any?(&:required_payway_payout?) || shipments.any?(&:required_payway_payout?) end @@ -56,7 +50,7 @@ def required_payway_payout? # override def available_payment_methods(store = nil) payment_methods = if vendor_payment_methods.any? - available_vendor_payment_methods + vendor_payment_methods else collect_payment_methods(store) end @@ -68,20 +62,6 @@ def available_payment_methods(store = nil) end end - def available_vendor_payment_methods - if ticket_seller_user? - vendor_payment_methods \ - else - vendor_payment_methods.reject { |pm| pm.type == 'Spree::PaymentMethod::Check' } - end - end - - def ticket_seller_user? - return false if user.nil? - - user.has_spree_role?('ticket_seller') - end - def line_items_count line_items.size end @@ -90,29 +70,10 @@ def generate_line_items_total_metadata line_items.each(&:update_total_metadata) end - def send_confirmation_email! - return unless !confirmation_delivered? && (paid? || authorized?) - - deliver_order_confirmation_email - end - - def successful_payment - paid? || payments.any? { |p| p.after_pay_method? && p.authorized? } - end - - alias paid_or_authorized? successful_payment - - def authorized? - payments.last.authorized? - end - def order_adjustment_total adjustments.eligible.sum(:amount) end end end -if Spree::Order.included_modules.exclude?(Vpago::OrderDecorator) - Spree::Order.register_update_hook(:generate_line_items_total_metadata) - Spree::Order.prepend(Vpago::OrderDecorator) -end +Spree::Order.prepend(Vpago::OrderDecorator) if Spree::Order.included_modules.exclude?(Vpago::OrderDecorator) diff --git a/app/models/vpago/payment/vpago_payment_processing_decorator.rb b/app/models/vpago/payment/vpago_payment_processing_decorator.rb deleted file mode 100644 index 9f4f1415..00000000 --- a/app/models/vpago/payment/vpago_payment_processing_decorator.rb +++ /dev/null @@ -1,48 +0,0 @@ -module Vpago - module Payment - module VpagoPaymentProcessingDecorator - def process! - if vpago_type? - process_with_vpago_gateway - else - super - end - end - - def cancel! - if vpago_type? - cancel_with_vpago_gateway - else - super - end - end - - # private - def vpago_type? - payment_method.is_a?(Spree::Gateway::Payway) || - payment_method.is_a?(Spree::Gateway::WingSdk) || - payment_method.is_a?(Spree::Gateway::Acleda) || - payment_method.is_a?(Spree::Gateway::PaywayV2) - end - - def cancel_with_vpago_gateway - response = payment_method.cancel(transaction_id) - handle_response(response, :void, :failure) - end - - def process_with_vpago_gateway - amount ||= money.money - started_processing! - - response = payment_method.process( - amount, - source, - gateway_options - ) - handle_response(response, :started_processing, :failure) - end - end - end -end - -Spree::Payment.include(Vpago::Payment::VpagoPaymentProcessingDecorator) diff --git a/app/models/vpago/payment_decorator.rb b/app/models/vpago/payment_decorator.rb index a21dd729..2c327ac8 100644 --- a/app/models/vpago/payment_decorator.rb +++ b/app/models/vpago/payment_decorator.rb @@ -3,61 +3,16 @@ module PaymentDecorator def self.prepended(base) base.has_many :payouts, class_name: 'Spree::Payout', inverse_of: :payment base.after_create -> { Vpago::PayoutsGenerator.new(self).call }, if: :should_generate_payouts? - base.after_update :capture_pre_auth, if: :state_changed_to_complete? - base.after_update :cancel_pre_auth, if: :state_changed_to_failed? - end - - def state_changed_to_complete? - saved_change_to_state? && state == 'completed' - end - def state_changed_to_failed? - saved_change_to_state? && state == 'failed' + base.delegate :support_pre_auth?, + :support_payout?, + to: :payment_method end def should_generate_payouts? support_payout? && payouts.empty? end - def support_payout? - payment_method.support_payout? - end - - # On the first call, everything works. The order is transitioned to complete and one Spree::Payment, - # which redirect the payment. But, after making the same call again, - # for instance because the payment wasn't completed or failed, - # another Spree::Payment is created but without a payment_url. So, if a consumer, - # for whatever reason, failed to complete the first payment, it would not be possible try again. - # This also meant that any consecutive Spree::Payment would not have a payment_url. The consumer is stuck - - def build_source - return unless new_record? - - return unless source_attributes.present? && source.blank? && payment_method.try(:payment_source_class) - - self.source = payment_method.payment_source_class.new(source_attributes) - source.payment_method_id = payment_method.id - source.user_id = order.user_id if order - - # Spree will not process payments if order is completed. - # We should call process! for completed orders to create a the gateway payment. - process! if order.completed? - end - - def request_update - updater = payment_method.payment_request_updater.new(self, { ignore_on_failed: true }) - updater.call - updater - end - - def authorized? - if source.is_a? Spree::VpagoPaymentSource - pending? - else - false - end - end - def payment_url return unless payment_method.type_payway_v2? @@ -77,24 +32,14 @@ def pre_auth_cancelled? pre_auth_status == 'CANCELLED' end - def capture_pre_auth - return if !enable_pre_auth? || pre_auth_completed? - - pre_auth_service.capture_pre_auth(self) - end - - def cancel_pre_auth - return if !enable_pre_auth? || pre_auth_cancelled? - - pre_auth_service.cancel_pre_auth(self) - end + private - def pre_auth_service - payment_method.pre_auth_service + def confirm_pre_auth! + payment_method.confirm_pre_auth!(self) end - def enable_pre_auth? - payment_method.enable_pre_auth? + def confirm_payouts! + payment_method.confirm_payouts!(self) end end end diff --git a/app/models/vpago/payment_method_decorator.rb b/app/models/vpago/payment_method_decorator.rb index 6f464723..bc0deb1c 100644 --- a/app/models/vpago/payment_method_decorator.rb +++ b/app/models/vpago/payment_method_decorator.rb @@ -22,14 +22,17 @@ def base.vpago_payments end def support_payout? - return false unless type_payway_v2? - return false unless default_payout_profile.present? && default_payout_profile.receivable? - - true + false end def support_pre_auth? - type_payway_v2? + false + end + + # TODO: we have already implement purchase for payway_v2. + # make sure to implement this on other payment method as well. + def purchase(_amount, _source, _gateway_options = {}) + ActiveMerchant::Billing::Response.new(true, 'Payway Gateway: Success') end def default_payout_profile @@ -85,12 +88,6 @@ def type_payway_v2? def type_wingsdk? type == Spree::PaymentMethod::TYPE_WINGSDK end - - def pre_auth_service - raise NotImplementedError, 'Pre-auth is not supported for this gateway' unless type_payway_v2? - - Vpago::PaywayV2::PreAuthHandler.new - end end end diff --git a/app/services/vpago/payment_processor.rb b/app/services/vpago/payment_processor.rb new file mode 100644 index 00000000..7d082a43 --- /dev/null +++ b/app/services/vpago/payment_processor.rb @@ -0,0 +1,43 @@ +module Vpago + class PaymentProcessor + attr_accessor :payment, :error + + def initialize(payment:) + @payment = payment + @error = nil + @user_informer = Vpago::UserInformer.new(payment.order) + end + + def call + user_informer.payment_is_processing + payment.process! + + if payment.completed? || payment.pending? + user_informer.order_is_processing + completer = Spree::Checkout::Complete.new.call(order: payment.order) + + unless completer.success? + user_informer.order_process_failed + + if payment.pending? + user_informer.payment_is_refunded + payment.void_transaction! + end + + failure(completer.error) + end + end + rescue Spree::Core::GatewayError => e + user_informer.payment_process_failed + failure(e) + end + + def success? + @error.nil? + end + + def failure(error) + @error = error + end + end +end diff --git a/app/services/vpago/payment_redirect_handler.rb b/app/services/vpago/payment_redirect_handler.rb index fd7ec755..5575f5f7 100644 --- a/app/services/vpago/payment_redirect_handler.rb +++ b/app/services/vpago/payment_redirect_handler.rb @@ -33,8 +33,6 @@ def process end def check_and_process_payment - @payment.process! - if payment_method.type_payway_v2? process_aba_v2_gateway elsif payment_method.type_payway? diff --git a/app/services/vpago/payway_v2/pre_auth_handler.rb b/app/services/vpago/payway_v2/pre_auth_handler.rb deleted file mode 100644 index e90ab62d..00000000 --- a/app/services/vpago/payway_v2/pre_auth_handler.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Vpago - module PaywayV2 - class PreAuthHandler - def capture_pre_auth(payment) - Vpago::PaywayV2::PreAuthCompleter.new(payment).call - end - - def cancel_pre_auth(payment) - Vpago::PaywayV2::PreAuthCanceler.new(payment).call - end - end - end -end diff --git a/app/services/vpago/user_informer.rb b/app/services/vpago/user_informer.rb new file mode 100644 index 00000000..9efe44dd --- /dev/null +++ b/app/services/vpago/user_informer.rb @@ -0,0 +1,44 @@ +require 'google/cloud/firestore' + +module Vpago + class UserInformer + attr_accessor :order + + def initialize(order) + @order = order + end + + def payment_is_processing = notify('payment_is_processing') + def order_is_processing = notify('order_is_processing') + def order_process_failed = notify('order_process_failed') + def payment_is_refunded = notify('payment_is_refunded') + def payment_process_failed = notify('payment_process_failed') + + def notify(message) + data = { + order_state: order.state, + payment_state: order.payment_state, + updated: Time.current + }.compact + + firestore_reference.set(data, merge: true) + firestore_reference.col('histories').doc(message).set({ created_at: Time.current }) + end + + def firestore_reference(_status) + current_date = Time.current.strftime('%Y-%m-%d') + firestore.col('statuses') + .doc('cart') + .col(current_date) + .doc(order.number) + end + + def firestore + @firestore ||= Google::Cloud::Firestore.new(project_id: service_account[:project_id], credentials: service_account) + end + + def service_account + @service_account ||= Rails.application.credentials.cloud_firestore_service_account + end + end +end diff --git a/db/migrate/20250121044614_add_transaction_response_to_spree_payments.rb b/db/migrate/20250121044614_add_transaction_response_to_spree_payments.rb new file mode 100644 index 00000000..91a3eee3 --- /dev/null +++ b/db/migrate/20250121044614_add_transaction_response_to_spree_payments.rb @@ -0,0 +1,5 @@ +class AddTransactionResponseToSpreePayments < ActiveRecord::Migration[7.0] + def change + add_column :spree_payments, :transaction_response, :jsonb, default: {}, if_not_exists: true + end +end diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index d06a8847..606e07a4 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -219,47 +219,4 @@ end end end - - describe '#available_vendor_payment_methods' do - let(:vendor1) { create(:vendor) } - let(:vendor2) { create(:vendor) } - let(:order) { create(:order) } - - before do - create(:line_item, order: order, product: create(:product_in_stock, vendor: vendor1)) - create(:line_item, order: order, product: create(:product_in_stock, vendor: vendor2)) - end - - context 'when user is a ticket seller' do - before do - allow(order).to receive(:ticket_seller_user?).and_return(true) - end - - it 'returns all vendor payment methods' do - payment_method1 = create(:payment_method, vendor: vendor1) - payment_method2 = create(:payment_method, vendor: vendor2, type: 'Spree::PaymentMethod::Check') - - order.stub(:vendor_payment_methods) { [payment_method1, payment_method2] } - - expect(order.available_vendor_payment_methods).to match_array([payment_method1, payment_method2]) - end - end - - context 'when user is not a ticket seller' do - before do - allow(order).to receive(:ticket_seller_user?).and_return(false) - end - - it 'returns vendor payment methods excluding the ones of type Check' do - payment_method1 = create(:payment_method, vendor: vendor1) - payment_method2 = create(:payment_method, vendor: vendor2) - payment_method_check = create(:payment_method, vendor: vendor1, type: 'Spree::PaymentMethod::Check') - - order.stub(:vendor_payment_methods) { [payment_method1, payment_method2, payment_method_check] } - - expect(order.available_vendor_payment_methods).to match_array([payment_method1, payment_method2]) - expect(order.available_vendor_payment_methods).not_to include(payment_method_check) - end - end - end end