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.
- 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
|