spree-returnly 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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