spree-returnly 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +230 -0
  4. data/Rakefile +46 -0
  5. data/app/controllers/spree/api/returnly/api_controller.rb +14 -0
  6. data/app/controllers/spree/api/returnly/refunds_controller.rb +43 -0
  7. data/app/controllers/spree/api/returnly/version_controller.rb +14 -0
  8. data/app/models/reimbursement_shipping.rb +61 -0
  9. data/app/models/reimbursement_type/original_payment_no_items.rb +17 -0
  10. data/app/models/spree/return_item_decorator.rb +14 -0
  11. data/app/services/refund_methods.rb +20 -0
  12. data/app/services/refund_payments.rb +59 -0
  13. data/config/locales/en.yml +5 -0
  14. data/config/routes.rb +8 -0
  15. data/lib/returnly.rb +17 -0
  16. data/lib/returnly/builders/customer_return.rb +28 -0
  17. data/lib/returnly/builders/return_item.rb +42 -0
  18. data/lib/returnly/discount_calculator.rb +51 -0
  19. data/lib/returnly/discounts/line_item.rb +26 -0
  20. data/lib/returnly/discounts/order.rb +33 -0
  21. data/lib/returnly/engine.rb +69 -0
  22. data/lib/returnly/factories.rb +6 -0
  23. data/lib/returnly/gift_card.rb +21 -0
  24. data/lib/returnly/refund/amount_calculator.rb +64 -0
  25. data/lib/returnly/refund/return_item_restock_policy.rb +15 -0
  26. data/lib/returnly/refund_calculator.rb +62 -0
  27. data/lib/returnly/refund_presenter.rb +115 -0
  28. data/lib/returnly/refunder.rb +110 -0
  29. data/lib/returnly/refunds_configuration.rb +36 -0
  30. data/lib/returnly/services/create_reimbursement.rb +74 -0
  31. data/lib/returnly/services/mark_items_as_returned.rb +25 -0
  32. data/lib/returnly/version.rb +7 -0
  33. data/lib/solidus-returnly.rb +12 -0
  34. data/lib/spree-returnly.rb +12 -0
  35. metadata +400 -0
@@ -0,0 +1,15 @@
1
+ module Returnly
2
+ module Refund
3
+ class ReturnItemRestockPolicy
4
+ attr_accessor :refund
5
+
6
+ def initialize(refund)
7
+ self.refund = refund
8
+ end
9
+
10
+ def should_return_item?(return_item)
11
+ return_item.resellable?
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,62 @@
1
+ module Returnly
2
+ class RefundCalculator
3
+ attr_accessor :line_items, :order
4
+ extend ::Returnly::RefundsConfiguration
5
+
6
+ def self.process(order, line_items)
7
+ refund_presenter_class.present_estimate new(order, line_items)
8
+ end
9
+
10
+ def initialize(order, line_items)
11
+ self.line_items = line_items
12
+ self.order = order
13
+ end
14
+
15
+ def discount
16
+ Returnly::DiscountCalculator.new(order).calculate(line_items)
17
+ end
18
+
19
+ def line_item_tax(line_item)
20
+ (line_item.adjustments.tax.sum(&:amount).to_d / line_item.quantity).round(2, :down)
21
+ end
22
+
23
+ def line_items_return_items
24
+ available_return_items.each_with_object({}) do |return_item, return_items|
25
+ line_item_id = return_item.inventory_unit.line_item_id
26
+ return_items[line_item_id] ||= []
27
+ return_items[line_item_id] << set_tax(return_item)
28
+ return_items
29
+ end
30
+ end
31
+
32
+ def shipping_tax
33
+ order.all_adjustments.where(source_type: 'Spree::TaxRate', adjustable_type: 'Spree::Shipment').sum(&:amount).to_d.round(2, :down)
34
+ end
35
+
36
+ def gift_card
37
+ @gift_card ||= self.class.gift_card_estimate_class.new(order)
38
+ end
39
+
40
+ protected
41
+
42
+ def available_return_items
43
+ self.class.return_item_builder_class.new(order).build_by_requested_line_items(line_items)
44
+ end
45
+
46
+ def set_tax(return_item)
47
+ percent_of_tax = if return_item.amount <= 0
48
+ 0
49
+ else
50
+ return_item.amount / Spree::ReturnItem.refund_amount_calculator.new.compute(return_item)
51
+ end
52
+
53
+ additional_tax_total = percent_of_tax * return_item.inventory_unit.additional_tax_total
54
+ included_tax_total = percent_of_tax * return_item.inventory_unit.included_tax_total
55
+
56
+ return_item.additional_tax_total = additional_tax_total
57
+ return_item.included_tax_total = included_tax_total
58
+
59
+ return_item
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,115 @@
1
+ module Returnly
2
+ class RefundPresenter
3
+ class << self
4
+ def present_refund(refunder)
5
+ customer_return = refunder.customer_return
6
+ refund = refunder.order.reload.refunds.last
7
+
8
+ {
9
+ refund_id: refund.try(:id).try(:to_s),
10
+ user_id: refunder.order.user_id,
11
+ line_items: customer_return.return_items.group_by { |ri| ri.inventory_unit.line_item.id }.map do |_, return_items|
12
+ first_return_item = return_items.first
13
+ {
14
+ refund_line_item_id: first_return_item.id.to_s,
15
+ order_line_item_id: first_return_item.inventory_unit.line_item.id.to_s,
16
+ units: return_items.size,
17
+ restock: first_return_item.resellable
18
+ }
19
+ end,
20
+ transactions: refunder.reimbursement.refunds.map do |refund|
21
+ {
22
+ id: refund.transaction_id,
23
+ amount: refund.amount.to_s,
24
+ status: transaction_status(refund.payment),
25
+ type: 'REFUND',
26
+ gateway: refund.payment.payment_method.type.demodulize,
27
+ is_online: true,
28
+ is_test: !Rails.env.match?(/production/),
29
+ payment_details: transaction_payment_details(refund.payment, refunder.gift_card_class.new(refund)),
30
+ created_at: refund.created_at.iso8601(3),
31
+ updated_at: refund.updated_at.iso8601(3)
32
+ }
33
+ end,
34
+ created_at: refund.try(:created_at).try(:iso8601, 3),
35
+ updated_at: refund.try(:updated_at).try(:iso8601, 3)
36
+ }
37
+ end
38
+
39
+ def present_estimate(calculator)
40
+ sub_total = Money.new 0
41
+ tax = Money.new 0
42
+
43
+ calculator.line_items_return_items.values.flatten.each do |return_item|
44
+ sub_total += Money.from_amount return_item.inventory_unit.line_item.price
45
+ tax += Money.from_amount calculator.line_item_tax(return_item.inventory_unit.line_item)
46
+ end
47
+
48
+ discount = Money.from_amount(calculator.discount)
49
+ total_refund_amount = sub_total + tax - (discount * -1) # discount is negative, so we multiply it by -1 to make it positive for subtraction
50
+ shipping_tax = calculator.shipping_tax
51
+ {
52
+ product_amount: sub_total.to_s,
53
+ tax_amount: tax.to_s,
54
+ discount_amount: (discount * -1).to_s,
55
+ total_refund_amount: total_refund_amount.to_s,
56
+ refundable_shipping_amount: Money.from_amount(calculator.order.shipment_total).to_s,
57
+ refundable_shipping_tax_amount: Money.from_amount(shipping_tax).to_s,
58
+ max_refundable_amount: Money.from_amount(calculator.order.total - calculator.order.refunds.reload.sum(&:amount)).to_s,
59
+ transactions: transactions_for_estimate(
60
+ calculator.order,
61
+ calculator.gift_card,
62
+ total_refund_amount.to_d + calculator.order.shipment_total + shipping_tax
63
+ )
64
+ }
65
+ end
66
+
67
+ def present_refund_with_zero_amount(refunder)
68
+ {
69
+ refund_id: '1',
70
+ line_items: refunder.line_items.map do |line_item|
71
+ {
72
+ refund_line_item_id: '1',
73
+ order_line_item_id: line_item[:order_line_item_id].to_s,
74
+ units: line_item[:units].to_i,
75
+ restock: line_item[:restock].match?(/true/)
76
+ }
77
+ end,
78
+ transactions: [],
79
+ created_at: Time.zone.now.iso8601(3)
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def transaction_status(payment)
86
+ return 'SUCCESS' if payment.completed?
87
+ return 'FAILURE' if payment.failed? || payment.invalid?
88
+ 'PENDING'
89
+ end
90
+
91
+ def transaction_payment_details(payment, gift_card)
92
+ details = {}
93
+
94
+ details[:avs_result_code] = payment.avs_response if payment.avs_response.present?
95
+ details[:cvv_result_code] = payment.cvv_response_code if payment.cvv_response_code.present?
96
+
97
+ source = payment.source
98
+
99
+ if source.respond_to?(:number) || source.respond_to?(:last_digits)
100
+ details[:credit_card_number] = source.number || source.last_digits if source.number.present? || source.last_digits.present?
101
+ details[:credit_card_company] = source.brand if source.brand.present?
102
+ end
103
+
104
+ details[:gift_card_id] = gift_card.id if gift_card.id.present?
105
+ details[:gift_card_code] = gift_card.code if gift_card.code.present?
106
+
107
+ details
108
+ end
109
+
110
+ def transactions_for_estimate(order, gift_card, payment_amount)
111
+ ::RefundPayments.new(order, gift_card).perform!(payment_amount)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,110 @@
1
+ module Returnly
2
+ class Refunder
3
+ include Returnly::RefundsConfiguration
4
+
5
+ attr_accessor :order,
6
+ :line_items,
7
+ :product_refund_amount,
8
+ :shipping_refund_amount,
9
+ :customer_return,
10
+ :return_item_amount_calculator,
11
+ :return_item_restock_policy,
12
+ :refund_calculator
13
+
14
+ def initialize(order:, line_items:, product_refund_amount:, shipping_refund_amount:)
15
+ self.order = order
16
+ self.line_items = line_items
17
+ self.product_refund_amount = product_refund_amount
18
+ self.shipping_refund_amount = shipping_refund_amount
19
+
20
+ configure
21
+ end
22
+
23
+ def process_return_items
24
+ each_return_item do |return_item|
25
+ return_item.amount = return_item_amount_calculator.return_item_refund_amount return_item
26
+ return_item.additional_tax_total = 0
27
+ return_item.resellable = return_item_restock_policy.should_return_item? return_item
28
+ end
29
+ end
30
+
31
+ def proceed!
32
+ return proceed_without_line_items if line_items.empty?
33
+ return proceed_with_product_zero_amount if product_refund_amount.to_d.zero?
34
+ process_return_items
35
+
36
+ if customer_return.save!
37
+ perform_reimbursement
38
+ RefundPresenter.present_refund(self)
39
+ end
40
+ end
41
+
42
+ def refund_available_amount
43
+ @available_amount ||= Money.from_amount(product_available_amount)
44
+ end
45
+
46
+ def reimbursement
47
+ @_reimbursement ||= Spree::Reimbursement.build_from_customer_return(customer_return)
48
+ end
49
+
50
+ def product_available_amount
51
+ total = order.total - (order.shipment_total + refund_calculator.shipping_tax + refunds)
52
+ [total, product_refund_amount.to_d].min
53
+ end
54
+
55
+ private
56
+
57
+ def add_shipping_amount!
58
+ ::ReimbursementShipping.new(reimbursement).update!(shipping_refund_amount.to_d)
59
+ end
60
+
61
+ def refunds
62
+ order.refunds.sum(&:amount).to_d.round(2, :down)
63
+ end
64
+
65
+ def configure
66
+ self.return_item_amount_calculator = return_item_amount_calculator_class.new(self)
67
+ self.return_item_restock_policy = return_item_restock_policy_class.new(self)
68
+ self.refund_calculator = refund_calculator_class.new(order, line_items)
69
+ end
70
+
71
+ def proceed_without_line_items
72
+ self.customer_return = Returnly::Builders::CustomerReturn.build_by_stock_location(stock_location)
73
+ reimburse_without_items!
74
+ RefundPresenter.present_refund(self)
75
+ end
76
+
77
+ def proceed_with_product_zero_amount
78
+ Returnly::Services::MarkItemsAsReturned.new(order, line_items).perform!
79
+ RefundPresenter.present_refund_with_zero_amount(self)
80
+ end
81
+
82
+ def perform_reimbursement
83
+ Returnly::Services::CreateReimbursement.new(reimbursement).perform!
84
+ add_shipping_amount!
85
+ end
86
+
87
+ def reimburse_without_items!
88
+ reimbursement_total = product_refund_amount.to_d + shipping_refund_amount.to_d
89
+ @_reimbursement = Spree::Reimbursement.new(
90
+ order: order,
91
+ total: reimbursement_total.round(2, :down)
92
+ )
93
+ reimbursement.save!
94
+ ::ReimbursementType::OriginalPaymentNoItems.reimburse(reimbursement, nil, false)
95
+ end
96
+
97
+ def each_return_item
98
+ return_items = refund_calculator.line_items_return_items.values.flatten
99
+ self.customer_return = Returnly::Builders::CustomerReturn.build_by_return_items(return_items)
100
+
101
+ return_items.each do |return_item|
102
+ yield return_item
103
+ end
104
+ end
105
+
106
+ def stock_location
107
+ order.shipments.last.stock_location
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,36 @@
1
+ module Returnly
2
+ module RefundsConfiguration
3
+ def return_item_amount_calculator_class
4
+ Returnly.return_item_amount_calculator || Returnly::Refund::AmountCalculator
5
+ end
6
+
7
+ def return_item_builder_class
8
+ Returnly.return_item_builder || Returnly::Builders::ReturnItem
9
+ end
10
+
11
+ # return item qualifies to be restocked (after refund)
12
+ def return_item_restock_policy_class
13
+ Returnly.return_item_restock_policy || Returnly::Refund::ReturnItemRestockPolicy
14
+ end
15
+
16
+ def refunder_class
17
+ Returnly.refunder || Returnly::Refunder
18
+ end
19
+
20
+ def refund_calculator_class
21
+ Returnly.refund_calculator || Returnly::RefundCalculator
22
+ end
23
+
24
+ def refund_presenter_class
25
+ Returnly.refund_presenter || Returnly::RefundPresenter
26
+ end
27
+
28
+ def gift_card_class
29
+ Returnly.gift_card || Returnly::NilGiftCard
30
+ end
31
+
32
+ def gift_card_estimate_class
33
+ Returnly.git_card_estimate || Returnly::NilGiftCardEstimate
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ module Returnly
2
+ module Services
3
+ class CreateReimbursement
4
+ attr_accessor :reimbursement
5
+
6
+ delegate :return_items, to: :reimbursement
7
+
8
+ def initialize(reimbursement)
9
+ @reimbursement = reimbursement
10
+ end
11
+
12
+ def perform!
13
+ reimbursement.save!
14
+ reimbursement.update!(total: calculated_total)
15
+ performer.perform(reimbursement)
16
+
17
+ if unpaid_amount_within_tolerance?
18
+ reimbursement.reimbursed!
19
+ Spree::Reimbursement.reimbursement_success_hooks.each { |h| h.call reimbursement }
20
+ send_reimbursement_email
21
+ else
22
+ reimbursement.errored!
23
+ Spree::Reimbursement.reimbursement_failure_hooks.each { |h| h.call reimbursement }
24
+ raise incomplete_error_class, Spree.t('validation.unpaid_amount_not_zero', amount: unpaid_amount)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def incomplete_error_class
31
+ Spree::Reimbursement::IncompleteReimbursementError
32
+ end
33
+
34
+ def reimbursement_models
35
+ Spree::Reimbursement.reimbursement_models
36
+ end
37
+
38
+ def paid_amount
39
+ reimbursement_models.sum do |model|
40
+ model.total_amount_reimbursed_for(reimbursement)
41
+ end
42
+ end
43
+
44
+ def unpaid_amount
45
+ reimbursement.total - paid_amount
46
+ end
47
+
48
+ def calculated_total
49
+ return_items.to_a.sum(&:total).to_d.round(2, :down)
50
+ end
51
+
52
+ def performer
53
+ Spree::Reimbursement.reimbursement_performer
54
+ end
55
+
56
+ def send_reimbursement_email
57
+ Spree::ReimbursementMailer.reimbursement_email(reimbursement.id).deliver_later
58
+ end
59
+
60
+ def unpaid_amount_within_tolerance?
61
+ reimbursement_count = reimbursement_models.count do |model|
62
+ model.total_amount_reimbursed_for(reimbursement) > 0
63
+ end
64
+
65
+ leniency = if reimbursement_count > 0
66
+ (reimbursement_count - 1) * 0.01.to_d
67
+ else
68
+ 0
69
+ end
70
+ unpaid_amount.abs.between?(0, leniency)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,25 @@
1
+ module Returnly
2
+ module Services
3
+ class MarkItemsAsReturned
4
+ attr_accessor :order, :line_items
5
+
6
+ def initialize(order, line_items)
7
+ @order = order
8
+ @line_items = line_items
9
+ end
10
+
11
+ def perform!
12
+ line_items.each do |line_item|
13
+ quantity = line_item[:units].to_i
14
+ inventory_units_by(line_item[:order_line_item_id]).take(quantity).each(&:return)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def inventory_units_by(line_item_id)
21
+ order.inventory_units.where(line_item_id: line_item_id)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ module Returnly
2
+ VERSION = '0.13.0'
3
+
4
+ def platform_version
5
+ defined?(Solidus)
6
+ end
7
+ end