spree-returnly 0.13.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/Rakefile +46 -0
- data/app/controllers/spree/api/returnly/api_controller.rb +14 -0
- data/app/controllers/spree/api/returnly/refunds_controller.rb +43 -0
- data/app/controllers/spree/api/returnly/version_controller.rb +14 -0
- data/app/models/reimbursement_shipping.rb +61 -0
- data/app/models/reimbursement_type/original_payment_no_items.rb +17 -0
- data/app/models/spree/return_item_decorator.rb +14 -0
- data/app/services/refund_methods.rb +20 -0
- data/app/services/refund_payments.rb +59 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +8 -0
- data/lib/returnly.rb +17 -0
- data/lib/returnly/builders/customer_return.rb +28 -0
- data/lib/returnly/builders/return_item.rb +42 -0
- data/lib/returnly/discount_calculator.rb +51 -0
- data/lib/returnly/discounts/line_item.rb +26 -0
- data/lib/returnly/discounts/order.rb +33 -0
- data/lib/returnly/engine.rb +69 -0
- data/lib/returnly/factories.rb +6 -0
- data/lib/returnly/gift_card.rb +21 -0
- data/lib/returnly/refund/amount_calculator.rb +64 -0
- data/lib/returnly/refund/return_item_restock_policy.rb +15 -0
- data/lib/returnly/refund_calculator.rb +62 -0
- data/lib/returnly/refund_presenter.rb +115 -0
- data/lib/returnly/refunder.rb +110 -0
- data/lib/returnly/refunds_configuration.rb +36 -0
- data/lib/returnly/services/create_reimbursement.rb +74 -0
- data/lib/returnly/services/mark_items_as_returned.rb +25 -0
- data/lib/returnly/version.rb +7 -0
- data/lib/solidus-returnly.rb +12 -0
- data/lib/spree-returnly.rb +12 -0
- metadata +400 -0
@@ -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
|