-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
reimbursement.rb
201 lines (166 loc) · 6.97 KB
/
reimbursement.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# frozen_string_literal: true
module Spree
class Reimbursement < Spree::Base
class IncompleteReimbursementError < StandardError; end
belongs_to :order, inverse_of: :reimbursements
belongs_to :customer_return, inverse_of: :reimbursements, touch: true
has_many :refunds, inverse_of: :reimbursement
has_many :credits, inverse_of: :reimbursement, class_name: 'Spree::Reimbursement::Credit'
has_many :return_items, inverse_of: :reimbursement
validates :order, presence: true
validate :validate_return_items_belong_to_same_order
accepts_nested_attributes_for :return_items, allow_destroy: true
before_create :generate_number
before_create :calculate_total
scope :reimbursed, -> { where(reimbursement_status: 'reimbursed') }
# The reimbursement_tax_calculator property should be set to an object that responds to "call"
# and accepts a reimbursement object. Invoking "call" should update the tax fields on the
# associated ReturnItems.
# This allows a store to easily integrate with third party tax services.
class_attribute :reimbursement_tax_calculator
self.reimbursement_tax_calculator = ReimbursementTaxCalculator
# A separate attribute here allows you to use a more performant calculator for estimates
# and a different one (e.g. one that hits a 3rd party API) for the final caluclations.
class_attribute :reimbursement_simulator_tax_calculator
self.reimbursement_simulator_tax_calculator = ReimbursementTaxCalculator
# The reimbursement_models property should contain an array of all models that provide
# reimbursements.
# This allows a store to incorporate custom reimbursement methods that Spree doesn't know about.
# Each model must implement a "total_amount_reimbursed_for" method.
# Example:
# Refund.total_amount_reimbursed_for(reimbursement)
# See the `reimbursement_generator` property regarding the generation of custom reimbursements.
class_attribute :reimbursement_models
self.reimbursement_models = [Spree::Refund, Spree::Reimbursement::Credit]
# The reimbursement_performer property should be set to an object that responds to the following methods:
# - #perform
# - #simulate
# see ReimbursementPerformer for details.
# This allows a store to customize their reimbursement methods and logic.
class_attribute :reimbursement_performer
self.reimbursement_performer = ReimbursementPerformer
# These are called if the call to "reimburse!" succeeds.
class_attribute :reimbursement_success_hooks
self.reimbursement_success_hooks = []
# These are called if the call to "reimburse!" fails.
class_attribute :reimbursement_failure_hooks
self.reimbursement_failure_hooks = []
state_machine :reimbursement_status, initial: :pending do
event :errored do
transition to: :errored, from: [:pending, :errored]
end
event :reimbursed do
transition to: :reimbursed, from: [:pending, :errored]
end
end
class << self
def build_from_customer_return(customer_return)
order = customer_return.order
order.reimbursements.build({
customer_return: customer_return,
return_items: customer_return.return_items.accepted.not_reimbursed
})
end
end
def display_total
Spree::Money.new(total, { currency: order.currency })
end
def calculated_total
# rounding down to handle edge cases for consecutive partial returns where rounding
# might cause us to try to reimburse more than was originally billed
return_items.to_a.sum(&:total).to_d.round(2, :down)
end
def paid_amount
reimbursement_models.sum do |model|
model.total_amount_reimbursed_for(self)
end
end
def unpaid_amount
total - paid_amount
end
def perform!(created_by: nil)
unless created_by
Spree::Deprecation.warn("Calling #perform on #{self} without created_by is deprecated")
end
reimbursement_tax_calculator.call(self)
reload
update!(total: calculated_total)
reimbursement_performer.perform(self, created_by: created_by)
if unpaid_amount_within_tolerance?
reimbursed!
Spree::Event.fire 'reimbursement_reimbursed', reimbursement: self
reimbursement_success_hooks.each { |h| h.call self }
else
errored!
Spree::Event.fire 'reimbursement_errored', reimbursement: self
reimbursement_failure_hooks.each { |h| h.call self }
end
if errored?
raise IncompleteReimbursementError, I18n.t("spree.validation.unpaid_amount_not_zero", amount: unpaid_amount)
end
end
def simulate(created_by: nil)
unless created_by
Spree::Deprecation.warn("Calling #simulate on #{self} without created_by is deprecated")
end
reimbursement_simulator_tax_calculator.call(self)
reload
update!(total: calculated_total)
reimbursement_performer.simulate(self, created_by: created_by)
end
def return_items_requiring_exchange
return_items.select(&:exchange_required?)
end
def any_exchanges?
return_items.any?(&:exchange_processed?)
end
def all_exchanges?
return_items.all?(&:exchange_processed?)
end
# Accepts all return items, saves the reimbursement, and performs the reimbursement
#
# @api public
# @param [Spree.user_class] created_by the user that is performing this action
# @return [void]
def return_all(created_by: nil)
unless created_by
Spree::Deprecation.warn("Calling #return_all on #{self} without created_by is deprecated")
end
return_items.each(&:accept!)
save!
perform!(created_by: created_by)
end
private
def calculate_total
self.total ||= calculated_total
end
def generate_number
self.number ||= loop do
random = "RI#{Array.new(9){ rand(9) }.join}"
break random unless self.class.exists?(number: random)
end
end
def validate_return_items_belong_to_same_order
if return_items.any? { |ri| ri.inventory_unit.order_id != order_id }
errors.add(:base, :return_items_order_id_does_not_match)
end
end
# If there are multiple different reimbursement types for a single
# reimbursement we open ourselves to a one-cent rounding error for every
# type over the first one. This is due to how we round #unpaid_amount and
# how each reimbursement type will round as well. Since at this point the
# payments and credits have already been processed, we should allow the
# reimbursement to show as 'reimbursed' and not 'errored'.
def unpaid_amount_within_tolerance?
reimbursement_count = reimbursement_models.count do |model|
model.total_amount_reimbursed_for(self) > 0
end
leniency = if reimbursement_count > 0
(reimbursement_count - 1) * 0.01.to_d
else
0
end
unpaid_amount.abs.between?(0, leniency)
end
end
end