diff --git a/app/models/spree/payout_profile.rb b/app/models/spree/payout_profile.rb index e2bbd1fb..af1be75d 100644 --- a/app/models/spree/payout_profile.rb +++ b/app/models/spree/payout_profile.rb @@ -4,6 +4,7 @@ class PayoutProfile < Base has_many :payout_profile_payments, class_name: 'Spree::PayoutProfilePayment', inverse_of: :payout_profile has_many :payout_profile_products, class_name: 'Spree::PayoutProfileProduct', inverse_of: :payout_profile + has_many :payout_profile_line_items, class_name: 'Spree::PayoutProfileLineItem', inverse_of: :payout_profile has_many :products, class_name: "Spree::Product", through: :payout_profile_products belongs_to :vendor, class_name: 'Spree::Vendor', optional: true, inverse_of: :payout_profiles diff --git a/app/models/spree/payout_profile_line_item.rb b/app/models/spree/payout_profile_line_item.rb new file mode 100644 index 00000000..2bd1b6b0 --- /dev/null +++ b/app/models/spree/payout_profile_line_item.rb @@ -0,0 +1,41 @@ +# This model is used to associate line items with payout profiles, even though we may not know in advance +# if user select payment method that supports payouts. The benefit is that when a user selects and pays with +# a payment method that does support payouts, we can use this association to tell the bank which payout profile to send to. +# It also benefits financial reporting. +module Spree + class PayoutProfileLineItem < Base + belongs_to :payout_profile, class_name: 'Spree::PayoutProfile', required: true, inverse_of: :payout_profile_line_items + belongs_to :line_item, class_name: 'Spree::LineItem', required: true, inverse_of: :payout_profile_line_items + + has_one :order, through: :line_item + + validates :payout_profile_id, uniqueness: { scope: :line_item_id } + + extend DisplayMoney + money_methods :amount, :commission_amount, :pre_commission_amount, :outstanding_amount + + before_save :set_amounts + + def available_amount_can_be_payout_to_vendor + order_adjustment = order.line_item_count.zero? ? 0 : order.adjustments.eligible.credit.total / order.line_item_count + available_amount = line_item.subtotal + line_item.adjustments.eligible.credit.total + order_adjustment + expected_amount_by_vendor = line_item.pre_commission_amount + + # avoid more than expected. + [available_amount, expected_amount_by_vendor].min + end + + private + + def set_amounts + self.amount = available_amount_can_be_payout_to_vendor + + self.commission_rate = line_item.commission_rate + self.commission_amount = line_item.commission_amount + self.pre_commission_amount = line_item.pre_commission_amount + + # amount that should be later sent to vendor + self.outstanding_amount = self.pre_commission_amount - self.amount + end + end +end diff --git a/app/models/spree/payout_profile_payment.rb b/app/models/spree/payout_profile_payment.rb index dc55c752..d9cb61db 100644 --- a/app/models/spree/payout_profile_payment.rb +++ b/app/models/spree/payout_profile_payment.rb @@ -1,3 +1,4 @@ +# This class only create when payment is completed. So we can trust that if this exist, money has already been sent to payout profile. module Spree class PayoutProfilePayment < Base belongs_to :payout_profile, class_name: 'Spree::PayoutProfile', required: true, inverse_of: :payout_profile_payments diff --git a/app/models/vpago/adjustment_decorator.rb b/app/models/vpago/adjustment_decorator.rb new file mode 100644 index 00000000..4b42e717 --- /dev/null +++ b/app/models/vpago/adjustment_decorator.rb @@ -0,0 +1,26 @@ +module Vpago + module AdjustmentDecorator + def self.prepended(base) + base.enum run_by: { unspecified: 0, store: 1, vendor: 2 }, _prefix: true + + base.scope :promotion_run_by_vendor, -> { promotion.eligible.where(run_by: :vendor) } + base.scope :promotion_run_by_store, -> { promotion.eligible.where(run_by: :store) } + + base.before_save :set_run_by + + def base.total + sum(:amount) + end + end + + private + + def set_run_by + self.run_by = source.try(:run_by) if source.is_a?(::Spree::PromotionAction) + end + end +end + +unless Spree::Adjustment.included_modules.include?(Vpago::AdjustmentDecorator) + Spree::Adjustment.prepend(Vpago::AdjustmentDecorator) +end diff --git a/app/models/vpago/line_item_decorator.rb b/app/models/vpago/line_item_decorator.rb index be98c66f..832fda0f 100644 --- a/app/models/vpago/line_item_decorator.rb +++ b/app/models/vpago/line_item_decorator.rb @@ -1,6 +1,8 @@ module Vpago module LineItemDecorator def self.prepended(base) + base.has_many :payout_profile_line_items, class_name: 'Spree::PayoutProfileLineItem', inverse_of: :line_item + base.has_many :payout_profiles, class_name: 'Spree::PayoutProfile', through: :product base.has_many :active_payout_profiles, class_name: 'Spree::PayoutProfile', through: :product base.has_many :active_payway_payout_profiles, class_name: 'Spree::PayoutProfile', through: :product @@ -19,11 +21,24 @@ def commission_rate end def commission_amount - pre_tax_amount * commission_rate / 100.0 + subtotal_with_vendor_promotion_total * commission_rate / 100.0 end def pre_commission_amount - pre_tax_amount - commission_amount + subtotal_with_vendor_promotion_total - commission_amount + end + + # using subtotal instead of pre_tax_amount since pre_tax_amount already include adjustments amount in it. + # we want raw amount to add only vendor adjustment amount. + def subtotal_with_vendor_promotion_total + subtotal + vendor_promotion_total + end + + def vendor_promotion_total + @vendor_promotion_total ||= begin + order_adjustment = order.line_item_count.zero? ? 0 : order.adjustments.promotion_run_by_vendor.total / order.line_item_count + adjustments.promotion_run_by_vendor.total + order_adjustment + end end end end diff --git a/app/models/vpago/order_decorator.rb b/app/models/vpago/order_decorator.rb index fa238b6a..477cced0 100644 --- a/app/models/vpago/order_decorator.rb +++ b/app/models/vpago/order_decorator.rb @@ -3,6 +3,11 @@ module OrderDecorator extend Spree::DisplayMoney money_methods :order_adjustment_total, :shipping_discount + def self.prepended(base) + base.has_many :payout_profile_payments, class_name: 'Spree::PayoutProfilePayment', through: :payments + base.has_many :payout_profile_line_items, class_name: 'Spree::PayoutProfileLineItem', through: :line_items + end + # Make sure the order confirmation is delivered when the order has been paid for. def finalize! # lock all adjustments (coupon promotions, etc.) @@ -29,18 +34,12 @@ def finalize! consider_risk end - # currently does not support payout when having adjustment/promotion or having tax. - def allowed_payout? - return false if adjustment_total > 0 - return false if included_tax_total > 0 - return false if additional_tax_total > 0 - return false if promo_total > 0 - - true + def required_payway_payout? + line_items.any?(&:required_payway_payout?) end - def required_payway_payout? - allowed_payout? && line_items.any?(&:required_payway_payout?) + def line_item_count + line_items.size end # override diff --git a/app/models/vpago/payment_decorator.rb b/app/models/vpago/payment_decorator.rb index 1dc71123..ad4197d7 100644 --- a/app/models/vpago/payment_decorator.rb +++ b/app/models/vpago/payment_decorator.rb @@ -2,6 +2,8 @@ module Vpago module PaymentDecorator def self.prepended(base) base.has_many :payout_profile_payments, class_name: 'Spree::PayoutProfilePayment', inverse_of: :payment + + base.after_create -> { Vpago::PayoutProfileLineItemGenerator.new(order).call } end # On the first call, everything works. The order is transitioned to complete and one Spree::Payment, diff --git a/app/models/vpago/payout_profile_line_item_generator.rb b/app/models/vpago/payout_profile_line_item_generator.rb new file mode 100644 index 00000000..72ab5ed9 --- /dev/null +++ b/app/models/vpago/payout_profile_line_item_generator.rb @@ -0,0 +1,29 @@ +module Vpago + class PayoutProfileLineItemGenerator + attr_reader :order + + def initialize(order) + @order = order + end + + def call + return if order.payout_profile_payments.any? + + prepare_payout_profile_line_items + end + + # Associate line items with payout profiles, even though we may not know in advance + # if user select payment method that supports payouts. The benefit is that when a user selects and pays with + # a payment method that does support payouts, we can use this association to tell the bank which payout profile to send to. + # It also benefits financial reporting. + def prepare_payout_profile_line_items + line_items = order.line_items.includes(:active_payway_payout_profiles) + Spree::PayoutProfileLineItem.where(line_item_id: line_items.pluck(:id)).delete_all + + line_items.each do |line_item| + payout_profile = line_item.active_payway_payout_profiles.first || Spree::PayoutProfiles::PaywayV2.default + line_item.payout_profile_line_items.create(payout_profile: payout_profile) + end + end + end +end diff --git a/app/models/vpago/promotion_action_decorator.rb b/app/models/vpago/promotion_action_decorator.rb new file mode 100644 index 00000000..2c60ac9a --- /dev/null +++ b/app/models/vpago/promotion_action_decorator.rb @@ -0,0 +1,11 @@ +module Vpago + module PromotionActionDecorator + def self.prepended(base) + base.enum run_by: { unspecified: 0, store: 1, vendor: 2 }, _prefix: true + end + end +end + +unless Spree::PromotionAction.included_modules.include?(Vpago::PromotionActionDecorator) + Spree::PromotionAction.prepend(Vpago::PromotionActionDecorator) +end diff --git a/app/services/vpago/payout_profiles/payway/payout_profile_request_creator.rb b/app/services/vpago/payout_profiles/payway/payout_profile_request_creator.rb index a4f49f55..a071f5a2 100644 --- a/app/services/vpago/payout_profiles/payway/payout_profile_request_creator.rb +++ b/app/services/vpago/payout_profiles/payway/payout_profile_request_creator.rb @@ -4,7 +4,7 @@ module Payway class PayoutProfileRequestCreator < BasePayoutProfileRequest # override def request_path - "api/merchant-portal/merchant-access/whitelist-account/add-whitelist-payout" + "/api/merchant-portal/merchant-access/whitelist-account/add-whitelist-payout" end # code: diff --git a/app/services/vpago/payout_profiles/payway/payout_profile_request_updater.rb b/app/services/vpago/payout_profiles/payway/payout_profile_request_updater.rb index ad699781..b6736e67 100644 --- a/app/services/vpago/payout_profiles/payway/payout_profile_request_updater.rb +++ b/app/services/vpago/payout_profiles/payway/payout_profile_request_updater.rb @@ -4,7 +4,7 @@ module Payway class PayoutProfileRequestUpdater < BasePayoutProfileRequest # override def request_path - "api/merchant-portal/merchant-access/whitelist-account/update-whitelist-status" + "/api/merchant-portal/merchant-access/whitelist-account/update-whitelist-status" end def call diff --git a/config/routes.rb b/config/routes.rb index c6a6b066..efc284d6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -70,5 +70,9 @@ post :verify_with_bank end end + + resources :orders do + resources :payout_profile_line_items, only: [:index] + end end end diff --git a/db/migrate/20240610103708_create_spree_payout_profile_line_items.rb b/db/migrate/20240610103708_create_spree_payout_profile_line_items.rb new file mode 100644 index 00000000..a84fd99d --- /dev/null +++ b/db/migrate/20240610103708_create_spree_payout_profile_line_items.rb @@ -0,0 +1,16 @@ +class CreateSpreePayoutProfileLineItems < ActiveRecord::Migration[7.0] + def change + create_table :spree_payout_profile_line_items, if_not_exists: true do |t| + t.references :payout_profile, foreign_key: { to_table: :spree_payout_profiles } + t.references :line_item, foreign_key: { to_table: :spree_line_items } + + t.decimal :amount, precision: 10, scale: 2, default: "0.0", null: false + t.decimal :commission_rate, null: false + t.decimal :commission_amount, precision: 10, scale: 2, default: "0.0", null: false + t.decimal :pre_commission_amount, precision: 10, scale: 2, default: "0.0", null: false + t.decimal :outstanding_amount, precision: 10, scale: 2, default: "0.0", null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20240611085734_add_run_by_to_spree_adjustments.rb b/db/migrate/20240611085734_add_run_by_to_spree_adjustments.rb new file mode 100644 index 00000000..1214fbe5 --- /dev/null +++ b/db/migrate/20240611085734_add_run_by_to_spree_adjustments.rb @@ -0,0 +1,5 @@ +class AddRunByToSpreeAdjustments < ActiveRecord::Migration[7.0] + def change + add_column :spree_adjustments, :run_by, :integer, null: false, default: 0, if_not_exists: true + end +end diff --git a/db/migrate/20240611093824_add_run_by_to_spree_promotion_actions.rb b/db/migrate/20240611093824_add_run_by_to_spree_promotion_actions.rb new file mode 100644 index 00000000..dea31b79 --- /dev/null +++ b/db/migrate/20240611093824_add_run_by_to_spree_promotion_actions.rb @@ -0,0 +1,5 @@ +class AddRunByToSpreePromotionActions < ActiveRecord::Migration[7.0] + def change + add_column :spree_promotion_actions, :run_by, :integer, null: false, default: 0, if_not_exists: true + end +end diff --git a/lib/vpago/payway_v2/payouts_constructor.rb b/lib/vpago/payway_v2/payouts_constructor.rb index ddfcaaf7..e95599e3 100644 --- a/lib/vpago/payway_v2/payouts_constructor.rb +++ b/lib/vpago/payway_v2/payouts_constructor.rb @@ -11,26 +11,29 @@ class PayoutsConstructor def initialize(payment) @payment = payment - @line_items = @payment.order.line_items.includes(:active_payway_payout_profiles) + @line_items = @payment.order.line_items.includes(payout_profile_line_items: :payout_profile) end def call - return [] unless @payment.order.allowed_payout? - payouts = build_payouts_for_line_items payouts = include_default_payout_for_remaining_amounts(payouts) payouts = group_payouts(payouts) - payouts + # when invalid, we still allow user to pay. we can deal with those issues in report later. + return [] unless valid_payout_total?(payouts) + + format_payouts(payouts) + end + + def valid_payout_total?(payouts) + payment.amount == payouts.sum { |payout| payout[:amt] } end # construct payouts for line item product that has payout profile. def build_payouts_for_line_items line_items.each_with_object([]) do |line_item, payouts| - payout_profile = line_item.active_payway_payout_profiles.first - - if payout_profile.present? - payouts << { acc: payout_profile.bank_account_number, amt: line_item.pre_commission_amount } + line_item.payout_profile_line_items.each do |payout_profile_line_item| + payouts << { acc: payout_profile_line_item.payout_profile.bank_account_number, amt: payout_profile_line_item.amount } end end end @@ -53,13 +56,18 @@ def group_payouts(payouts) payouts.group_by { |item| item[:acc] }.map do |acc, items| { acc: acc, - amt: format_amount(items.sum { |item| item[:amt] }) + amt: items.sum { |item| item[:amt] } } end end - def format_amount(amount) - "%.2f" % amount + def format_payouts(payouts) + payouts.map do |payout| + { + acc: payout[:acc], + amt: "%.2f" % payout[:amt] + } + end end end end diff --git a/spec/factories/payout_profile_line_item_factory.rb b/spec/factories/payout_profile_line_item_factory.rb new file mode 100644 index 00000000..7f8e162d --- /dev/null +++ b/spec/factories/payout_profile_line_item_factory.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :payout_profile_line_item, class: Spree::PayoutProfileLineItem do + payout_profile {|p| p.association(:payway_payout_profile) } + line_item {|p| p.association(:line_item) } + end +end diff --git a/spec/factories/payout_profile_payment_factory.rb b/spec/factories/payout_profile_payment_factory.rb new file mode 100644 index 00000000..0877fb1c --- /dev/null +++ b/spec/factories/payout_profile_payment_factory.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :payout_profile_payment, class: Spree::PayoutProfilePayment do + payout_profile {|p| p.association(:payway_payout_profile) } + payment {|p| p.association(:payment) } + end +end diff --git a/spec/lib/vpago/payway_v2/payouts_constructor_spec.rb b/spec/lib/vpago/payway_v2/payouts_constructor_spec.rb index 42ef758e..314bc6a7 100644 --- a/spec/lib/vpago/payway_v2/payouts_constructor_spec.rb +++ b/spec/lib/vpago/payway_v2/payouts_constructor_spec.rb @@ -7,84 +7,136 @@ let(:payout_profile1) { create(:payway_payout_profile, active: true, bank_account_number: '111', verified_at: DateTime.current)} let(:payout_profile2) { create(:payway_payout_profile, active: true, bank_account_number: '222', verified_at: DateTime.current)} - let(:product0) { create(:product, payout_profiles: [payout_profile1]) } - let(:product1) { create(:product, payout_profiles: [payout_profile1]) } - let(:product2) { create(:product, payout_profiles: [payout_profile2]) } + let(:line_item0) { create(:line_item, price: 5.0) } + let(:line_item1) { create(:line_item, price: 5.0) } + let(:line_item2) { create(:line_item, price: 11.0) } - let(:payout_line_item0) { create(:line_item, product: product0, price: 5.0) } - let(:payout_line_item1) { create(:line_item, product: product1, price: 5.0) } - let(:payout_line_item2) { create(:line_item, product: product2, price: 11.0) } - let(:no_payout_line_item) { create(:line_item, price: 12.0) } + let(:order) { create(:order, line_items: [line_item0, line_item1, line_item2]) } + let!(:payment) do + payment = create(:payment, payment_method: payment_method, order: order, amount: 5.0 + 5.0 + 11.0) - let(:order) { create(:order, line_items: [payout_line_item0, payout_line_item1, payout_line_item2, no_payout_line_item]) } + # after create, payment generate this, have to remove it since we want to manually create them here. + Spree::PayoutProfileLineItem.delete_all - let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 5.0 + 5.0 + 11 + 12)} + payment + end subject { described_class.new(payment) } describe '#call' do - context 'when allowed_payout? is false' do - it 'return empty payouts' do - allow(order).to receive(:allowed_payout?).and_return(false) + let!(:payout_profile_line_item_0) { create(:payout_profile_line_item, line_item: line_item0, payout_profile: payout_profile1) } + let!(:payout_profile_line_item_1) { create(:payout_profile_line_item, line_item: line_item1, payout_profile: payout_profile1) } + let!(:payout_profile_line_item_2) { create(:payout_profile_line_item, line_item: line_item2, payout_profile: payout_profile2) } + + it 'build_payouts_for_line_items, include_default_payout_for_remaining_amounts, group_payouts, check if payout valid, and format payout' do + expect(subject).to receive(:build_payouts_for_line_items).and_call_original + expect(subject).to receive(:include_default_payout_for_remaining_amounts).and_call_original + expect(subject).to receive(:group_payouts).and_call_original + expect(subject).to receive(:valid_payout_total?).and_call_original + expect(subject).to receive(:format_payouts).and_call_original + + subject.call + end + + it 'return constructed payouts' do + expect(subject.call).to eq([ + {:acc => "111", :amt => "10.00"}, + {:acc => "222", :amt => "11.00"} + ]) + end - expect(subject.call).to eq([]) + context 'when valid_payout_total = true' do + it 'return constructed payout' do + allow(subject).to receive(:valid_payout_total?).and_return(true) + + expect(subject.call).to_not be_empty end end - context 'when allowed_payout? is true' do - it 'constuct payouts for all 4 line items, add default payout for remaining amount, and group them' do - expect(order.allowed_payout?).to be true - expect(subject.call).to eq([ - { acc: payout_profile1.bank_account_number, amt: '10.00' }, # 5$ + 5$ | line_item_0 + line_item_1 - { acc: payout_profile2.bank_account_number, amt: '11.00' }, - { acc: default_payout_profile.bank_account_number, amt: '12.00' }, - ]) + context 'when valid_payout_total = false' do + it 'return empty payouts, meaning that we still accept payment & deal with this issue later' do + allow(subject).to receive(:valid_payout_total?).and_return(false) + + expect(subject.call).to be_empty end end end describe '#build_payouts_for_line_items' do - it 'return payout for all 4 line items' do + let!(:payout_profile_line_item_0) { create(:payout_profile_line_item, line_item: line_item0, payout_profile: payout_profile1) } + let!(:payout_profile_line_item_1) { create(:payout_profile_line_item, line_item: line_item1, payout_profile: payout_profile1) } + let!(:payout_profile_line_item_2) { create(:payout_profile_line_item, line_item: line_item2, payout_profile: payout_profile2) } + + it 'build payout profile base on payout_profile_line_item associated with each line item' do expect(subject.build_payouts_for_line_items).to eq([ - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile2.bank_account_number, amt: 11.0 } + {:acc => "111", :amt => 5.0}, + {:acc => "111", :amt => 5.0}, + {:acc => "222", :amt => 11.0} ]) end end describe '#include_default_payout_for_remaining_amounts' do - let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 10.0 + 11.0 + 12.0)} - - context 'when has remaing amount' do - it 'return construct default payout for remaining amount 12.0' do - payouts = subject.build_payouts_for_line_items - - expect(subject.include_default_payout_for_remaining_amounts(payouts)).to eq([ - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile2.bank_account_number, amt: 11.0 }, - { acc: default_payout_profile.bank_account_number, amt: 12.0 }, + let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 5.0 + 5.0 + 11.0 + remaining_amount)} + + context 'when there are remaining amount' do + let(:remaining_amount) { 30 } + + it 'add remaining amount with default account to existing payout' do + result = subject.include_default_payout_for_remaining_amounts([ + {:acc => "111", :amt => 5.0}, + {:acc => "111", :amt => 5.0}, + {:acc => "222", :amt => 11.0} + ]) + + expect(result).to eq([ + {:acc => "111", :amt => 5.0}, + {:acc => "111", :amt => 5.0}, + {:acc => "222", :amt => 11.0}, + {:acc => default_payout_profile.bank_account_number, :amt => remaining_amount}, ]) end end - context 'when has NO remaing amount' do - # override - let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 10.0 + 11.0)} - - it 'return does not return with default payout profile' do - payouts = subject.build_payouts_for_line_items - - expect(subject.include_default_payout_for_remaining_amounts(payouts)).to eq([ - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile1.bank_account_number, amt: 5.0 }, - { acc: payout_profile2.bank_account_number, amt: 11.0 }, + context 'when there are no remaining amount' do + let(:remaining_amount) { 0 } + + it 'does not change anything' do + result = subject.include_default_payout_for_remaining_amounts([ + {:acc => "111", :amt => 5.0}, + {:acc => "111", :amt => 5.0}, + {:acc => "222", :amt => 11.0} + ]) + + expect(result).to eq([ + {:acc => "111", :amt => 5.0}, + {:acc => "111", :amt => 5.0}, + {:acc => "222", :amt => 11.0} ]) end end end + describe '#valid_payout_total?' do + context 'when sum of payout != payment amount' do + let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 10) } + let(:payouts) { [ { acc: "111", amt: 12 } ]} + + it 'return false' do + expect(subject.valid_payout_total?(payouts)).to be false + end + end + + context 'when sum of payout == payment amount' do + let(:payment) { create(:payment, payment_method: payment_method, order: order, amount: 10) } + let(:payouts) { [ { acc: "111", amt: 10 } ]} + + it 'return true' do + expect(subject.valid_payout_total?(payouts)).to be true + end + end + end + describe '#group_payouts' do it 'group by account & sum by amount' do payouts = [ @@ -94,32 +146,26 @@ ] expect(subject.group_payouts(payouts)).to eq([ - { acc: "111", amt: "21.00" }, - { acc: "222", amt: "20.00" }, - { acc: "333", amt: "14.00" } + { acc: "111", amt: 10 + 11 }, + { acc: "222", amt: 12 + 8 }, + { acc: "333", amt: 14 } ]) end end - describe '#format_amount' do - it 'formats the amount with two decimal places' do - formatted_amount = subject.format_amount(123.456) - expect(formatted_amount).to eq '123.46' - end - - it 'formats the amount correctly when it is an integer' do - formatted_amount = subject.format_amount(100) - expect(formatted_amount).to eq '100.00' - end - - it 'formats the amount correctly when it has one decimal place' do - formatted_amount = subject.format_amount(55.5) - expect(formatted_amount).to eq '55.50' - end + describe '#format_payouts' do + it 'format payout amount' do + payouts = [ + { acc: "111", amt: 21 }, + { acc: "222", amt: 20 }, + { acc: "333", amt: 14 } + ] - it 'formats the amount correctly when it is zero' do - formatted_amount = subject.format_amount(0) - expect(formatted_amount).to eq '0.00' + expect(subject.format_payouts(payouts)).to eq([ + { acc: "111", amt: "21.00" }, + { acc: "222", amt: "20.00" }, + { acc: "333", amt: "14.00" } + ]) end end -end \ No newline at end of file +end diff --git a/spec/models/spree/line_item_spec.rb b/spec/models/spree/line_item_spec.rb index b9aea995..232c81cd 100644 --- a/spec/models/spree/line_item_spec.rb +++ b/spec/models/spree/line_item_spec.rb @@ -47,51 +47,101 @@ end describe '#commission_amount' do - let(:vendor) { create(:vendor) } - let(:product) { create(:product_in_stock, vendor: vendor) } - let(:line_item) { create(:line_item, product: product, price: 100.0) } + let(:line_item) { create(:line_item) } - context 'when product has a commission rate' do - it 'caculate 10% of 100 base on product commission rate' do - allow(product).to receive(:commission_rate).and_return(10) + it 'return subtotal_with_vendor_promotion_total * commission / 100' do + allow(line_item).to receive(:subtotal_with_vendor_promotion_total).and_return(70) + allow(line_item).to receive(:commission_rate).and_return(10) - expect(line_item.commission_amount).to eq(10.0) - end + expect(line_item.commission_amount).to eq(70.0 * 10.0 / 100) end + end - context 'when product does not have a commission rate but vendor does' do - it 'caculate 15% of 100 base on vendor commission rate' do - allow(product).to receive(:commission_rate).and_return(nil) - vendor.commission_rate = 15 + describe '#pre_commission_amount' do + let(:line_item) { create(:line_item) } + + it 'return subtotal_with_vendor_promotion_total - commission_amount' do + allow(line_item).to receive(:subtotal_with_vendor_promotion_total).and_return(30) + allow(line_item).to receive(:commission_amount).and_return(20) + + expect(line_item.pre_commission_amount).to eq(30 - 20) + end + end + + describe '#subtotal_with_vendor_promotion_total' do + let(:line_item) { create(:line_item) } + + it 'return subtotal + vendor_promotion_total' do + allow(line_item).to receive(:subtotal).and_return(50) + allow(line_item).to receive(:vendor_promotion_total).and_return(30) + + expect(line_item.pre_commission_amount).to eq(50 + 30) + end + end + + describe '#vendor_promotion_total' do + let(:line_item1) { create(:line_item, price: 50) } + let(:line_item2) { create(:line_item, price: 50) } - expect(line_item.commission_amount).to eq(15.0) + let!(:order) { create(:order, line_items: [line_item1, line_item2], state: :cart) } + + let(:line_item_promotion_by_vendor_26) { create(:promotion_with_item_adjustment, adjustment_rate: 26) } + let(:line_item_promotion_by_store_27) { create(:promotion_with_item_adjustment, adjustment_rate: 27) } + + let(:order_promotion_by_vendor_13) { create(:promotion_with_order_adjustment, weighted_order_adjustment_amount: 13) } + let(:order_promotion_by_store_14) { create(:promotion_with_order_adjustment, weighted_order_adjustment_amount: 14) } + + before do + order.update_with_updater! + + promotions_run_by_vendor.each { |promotion| promotion.actions.update_all(run_by: :vendor) && promotion.activate(order: order) } + promotions_run_by_store.each { |promotion| promotion.actions.update_all(run_by: :store) && promotion.activate(order: order) } + + # make it eligible + line_item1.adjustments.update_all(eligible: true) + order.adjustments.update_all(eligible: true) + end + + context 'when there is promotion that run by vendor' do + let(:promotions_run_by_vendor) { [line_item_promotion_by_vendor_26, order_promotion_by_vendor_13] } + let(:promotions_run_by_store) { [] } + + it 'return line item adjustments + (order adjustment / line items count)' do + expect(line_item1.vendor_promotion_total).to eq(-(26 + 13.0 / 2)) end end - context 'when neither product nor vendor has a commission rate' do - it 'returns 0 as the commission amount' do - allow(product).to receive(:commission_rate).and_return(nil) - vendor.commission_rate = nil + context 'when there are promotions that run by both vendor & store' do + let(:promotions_run_by_vendor) { [line_item_promotion_by_vendor_26, order_promotion_by_vendor_13] } + let(:promotions_run_by_store) { [line_item_promotion_by_store_27, order_promotion_by_store_14] } - expect(line_item.commission_amount).to eq(0.0) + it 'return line item adjustments + (order adjustment / line items count) that run by vendor and exclude promotion that run by store' do + expect(line_item1.vendor_promotion_total).to eq(-(26 + 13.0 / 2)) end end - end + + context 'when there are only promotions that run by store' do + let(:promotions_run_by_vendor) { [] } + let(:promotions_run_by_store) { [line_item_promotion_by_store_27, order_promotion_by_store_14] } - describe '#pre_commission_amount' do - let(:vendor) { create(:vendor, commission_rate: 10) } - let(:product) { create(:product_in_stock, vendor: vendor) } - let(:line_item) { create(:line_item, product: product, price: 100.0) } + it 'return 0 since no promotions that run by vendor' do + expect(line_item1.vendor_promotion_total).to eq 0 + end + end - it 'return amount before commission cut' do - expect(line_item.commission_amount).to eq 10 - expect(line_item.pre_commission_amount).to eq 90 + context 'when there are no promotions' do + let(:promotions_run_by_vendor) { [] } + let(:promotions_run_by_store) { [] } + + it 'return 0' do + expect(line_item1.vendor_promotion_total).to eq 0 + end end end describe '#required_payway_payout?' do context 'when there are required_active_payout_profiles in product' do - let(:payout_product) { build(:payway_payout_profile, active: true, verified_at: DateTime.current)} + let(:payout_product) { build(:payway_payout_profile, active: true, verified_at: DateTime.current) } let(:product) { create(:product, required_active_payout_profiles: [payout_product]) } let(:line_item) { create(:line_item, product: product) } diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index b831c15a..eb38f0d5 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -21,81 +21,23 @@ end end - describe '#allowed_payout?' do - let(:order) { build(:order) } - - context 'when adjustment_total > 0' do - it 'returns false' do - order.adjustment_total = 1 - expect(order.allowed_payout?).to be false - end - end - - context 'when included_tax_total > 0' do - it 'returns false' do - order.included_tax_total = 1 - expect(order.allowed_payout?).to be false - end - end - - context 'when additional_tax_total > 0' do - it 'returns false' do - order.additional_tax_total = 1 - expect(order.allowed_payout?).to be false - end - end - - context 'when promo_total > 0' do - it 'returns false' do - order.promo_total = 1 - expect(order.allowed_payout?).to be false - end - end - - context 'when all totals are 0' do - it 'returns true' do - order.adjustment_total = 0 - order.included_tax_total = 0 - order.additional_tax_total = 0 - order.promo_total = 0 - - expect(order.allowed_payout?).to be true - end - end - end - describe '#required_payway_payout?' do - context 'when allowed_payout is false' do - before do - order.promo_total = 1 - expect(order.allowed_payout?).to be false - end - - it 'return false' do - expect(order.required_payway_payout?).to be false - end + let(:line_item_a) { create(:line_item) } + let(:line_item_b) { create(:line_item) } + let(:order) { create(:order, line_items: [line_item_a, line_item_b])} + + before do + order.adjustment_total = 0 + order.included_tax_total = 0 + order.additional_tax_total = 0 + order.promo_total = 0 end - context 'when allowed_payout is true' do - let(:line_item_a) { create(:line_item) } - let(:line_item_b) { create(:line_item) } - let(:order) { create(:order, line_items: [line_item_a, line_item_b])} + it 'return true when any of line item is required payway payout' do + allow(line_item_a).to receive(:required_payway_payout?).and_return(true) + allow(line_item_b).to receive(:required_payway_payout?).and_return(false) - before do - order.adjustment_total = 0 - order.included_tax_total = 0 - order.additional_tax_total = 0 - order.promo_total = 0 - - expect(order.allowed_payout?).to be true - end - - it 'return true when any of line item is required payway payout' do - allow(line_item_a).to receive(:required_payway_payout?).and_return(true) - allow(line_item_b).to receive(:required_payway_payout?).and_return(false) - - expect(order.required_payway_payout?).to be true - end + expect(order.required_payway_payout?).to be true end end diff --git a/spec/models/spree/payment_spec.rb b/spec/models/spree/payment_spec.rb index e5c7c8e4..e1ebf095 100644 --- a/spec/models/spree/payment_spec.rb +++ b/spec/models/spree/payment_spec.rb @@ -4,4 +4,18 @@ describe "associations" do it { should have_many(:payout_profile_payments).class_name('Spree::PayoutProfilePayment').inverse_of(:payment) } end + + describe 'callback: after_create' do + let(:order) { create(:order) } + let(:generator) { Vpago::PayoutProfileLineItemGenerator.new(order) } + + subject { build(:payment, order: order) } + + it 'calls Vpago::PayoutProfileLineItemGenerator' do + expect(Vpago::PayoutProfileLineItemGenerator).to receive(:new).and_return(generator) + expect(generator).to receive(:call).and_call_original + + subject.save! + end + end end diff --git a/spec/models/spree/payout_profile_line_item_generator_spec.rb b/spec/models/spree/payout_profile_line_item_generator_spec.rb new file mode 100644 index 00000000..4cb337a5 --- /dev/null +++ b/spec/models/spree/payout_profile_line_item_generator_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +RSpec.describe Vpago::PayoutProfileLineItemGenerator do + describe "#call" do + let!(:default_payout_profile) { create(:payway_payout_profile, default: true, bank_account_number: '87654321') } + let!(:payout_profile) { create(:payway_payout_profile, default: true, bank_account_number: '47654322') } + + let(:order) { create(:order, payments: [payment]) } + + subject { described_class.new(order) } + + context "when the order has not sent any payouts" do + let(:payment) { create(:payment, payout_profile_payments: []) } + + it "calls prepare_payout_profile_line_items" do + expect(subject.order.payout_profile_payments.any?).to be false + + expect(subject).to receive(:prepare_payout_profile_line_items).and_call_original + + subject.call + end + end + + context "when the order has already sent payouts" do + let(:payout_profile_payment) { create(:payout_profile_payment) } + let(:payment) { create(:payment, payout_profile_payments: [payout_profile_payment]) } + + it "does not call prepare_payout_profile_line_items" do + expect(subject.order.payout_profile_payments.any?).to be true + + expect(subject).to_not receive(:prepare_payout_profile_line_items) + + subject.call + end + end + end + + describe '#prepare_payout_profile_line_items' do + let!(:default_payout_profile) { create(:payway_payout_profile, default: true, active: true, verified_at: Date.current, bank_account_number: '87654321') } + let!(:payout_profile1) { create(:payway_payout_profile, active: true, verified_at: Date.current, bank_account_number: '37654332') } + + let(:vendor) { create(:vendor, commission_rate: 10) } + + let(:product1) { create(:product_in_stock, vendor: vendor, payout_profiles: [payout_profile1]) } + let(:product2) { create(:product_in_stock, vendor: vendor, payout_profiles: []) } + + let(:payout_line_item1) { create(:line_item, product: product1, quantity: 1, price: 100.0) } + let(:payout_line_item2) { create(:line_item, product: product2, quantity: 1, price: 100.0) } + + let(:order) { create(:order, line_items: [payout_line_item1, payout_line_item2]) } + + subject { described_class.new(order.reload) } + + it 'generate payout profile & line item association with pre_commission_amount & commission_amount' do + subject.prepare_payout_profile_line_items + + expect(payout_line_item1.payout_profile_line_items[0].payout_profile).to eq payout_profile1 + expect(payout_line_item1.payout_profile_line_items[0].pre_commission_amount).to eq 90.0 + expect(payout_line_item1.payout_profile_line_items[0].commission_amount).to eq 10.0 + + expect(payout_line_item2.payout_profile_line_items[0].payout_profile).to eq default_payout_profile + expect(payout_line_item2.payout_profile_line_items[0].pre_commission_amount).to eq 90.0 + expect(payout_line_item2.payout_profile_line_items[0].commission_amount).to eq 10.0 + end + end +end diff --git a/spec/models/spree/payout_profile_line_item_spec.rb b/spec/models/spree/payout_profile_line_item_spec.rb new file mode 100644 index 00000000..f34bd9e9 --- /dev/null +++ b/spec/models/spree/payout_profile_line_item_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +RSpec.describe Spree::PayoutProfileLineItem, type: :model do + describe "associations" do + it { is_expected.to belong_to(:payout_profile).class_name('Spree::PayoutProfile').inverse_of(:payout_profile_line_items) } + it { is_expected.to belong_to(:line_item).class_name('Spree::LineItem').inverse_of(:payout_profile_line_items) } + end + + describe 'callback' do + describe 'before_save :set_amounts' do + let(:vendor) { create(:vendor, commission_rate: 10) } + let(:product) { create(:product_in_stock, vendor: vendor) } + let(:line_item) { create(:line_item, product: product, price: 50) } + let(:order) { create(:order, line_items: [line_item], state: :cart) } + let(:line_item_promotion) { create(:promotion_with_item_adjustment, adjustment_rate: -4) } + let(:order_promotion) { create(:promotion_with_order_adjustment, weighted_order_adjustment_amount: -16) } + subject { create(:payout_profile_line_item, line_item: line_item) } + + before do + order.update_with_updater! + [line_item_promotion, order_promotion].each {|p| p.activate(order: order) } + + # make it eligible + line_item.adjustments.update_all(eligible: true) + order.adjustments.update_all(eligible: true) + end + + it 'amounts 0 when not saved' do + subject = described_class.new(line_item: line_item, payout_profile: create(:payway_payout_profile)) + + expect(subject.amount).to eq 0.0 + expect(subject.commission_amount).to eq 0.0 + expect(subject.pre_commission_amount).to eq 0.0 + expect(subject.outstanding_amount).to eq 0.0 + end + + it 'save value from line item before save' do + subject = described_class.new(line_item: line_item, payout_profile: create(:payway_payout_profile)) + subject.save + + expect("%.2f" % subject.amount).to eq "45.00" + expect("%.2f" % subject.commission_amount).to eq "5.00" + expect("%.2f" % subject.pre_commission_amount).to eq "45.00" + expect("%.2f" % subject.outstanding_amount).to eq "0.00" + + # also have commission rate for reference when it get changed. + expect("%.2f" % subject.commission_rate).to eq "10.00" + end + end + end + + describe "#available_amount_can_be_payout_to_vendor" do + let(:line_item) { create(:line_item, price: 50) } + let(:line_item_a) { create(:line_item, price: 50) } + let!(:order) { create(:order, line_items: [line_item, line_item_a], state: :cart) } + + let(:credit_line_item_promotion_3) { create(:promotion_with_item_adjustment, adjustment_rate: 3) } + let(:charge_line_item_promotion_4) { create(:promotion_with_item_adjustment, adjustment_rate: -4) } + + let(:credit_order_promotion_15) { create(:promotion_with_order_adjustment, weighted_order_adjustment_amount: 15) } + let(:charge_order_promotion_16) { create(:promotion_with_order_adjustment, weighted_order_adjustment_amount: -16) } + + subject { create(:payout_profile_line_item, line_item: line_item) } + + before do + order.update_with_updater! + promotions.each {|p| p.activate(order: order) } + + # make it eligible + line_item.adjustments.update_all(eligible: true) + order.adjustments.update_all(eligible: true) + end + + context 'when have credit adjustments (promotion adjustment)' do + let(:promotions) { [credit_line_item_promotion_3, credit_order_promotion_15] } + + it 'return subtotal + credit adjustments of line items & order' do + subtotal = line_item.subtotal + adjustment = -(3 + 15.0/order.line_item_count) + + expect(subject.available_amount_can_be_payout_to_vendor).to eq(subtotal + adjustment) + end + end + + context 'when have charge adjustments (additional price to item)' do + let(:promotions) { [charge_line_item_promotion_4, charge_order_promotion_16] } + + it 'return line_item.subtotal + 0$ adjustment, balance that user paid can afford payout' do + subtotal = line_item.subtotal + adjustment = 0 + + expect(subject.available_amount_can_be_payout_to_vendor).to eq(subtotal + adjustment) + end + end + + context 'when have both credit & charge adjustments' do + let(:promotions) { [credit_line_item_promotion_3, charge_line_item_promotion_4, credit_order_promotion_15, charge_order_promotion_16] } + + it 'return subtotal + credit adjustment (exclude non-credit to ensure that money not exceed amount that user paid)' do + subtotal = line_item.subtotal + adjustment = -(3 + 15.0/order.line_item_count) + + expect(subject.available_amount_can_be_payout_to_vendor).to eq(subtotal + adjustment) + end + end + end +end