Changesets can be listed by changeset number.
The Git repository is here.
- Revision:
- 464
- Log:
Canvass version 1.0.2.
- Author:
- rool
- Date:
- Sat May 03 09:48:48 +0100 2014
- Size:
- 15950 Bytes
1 | ###################################################################### |
2 | # File:: donation.rb |
3 | # (C):: Hipposoft 2011 |
4 | # |
5 | # Purpose:: Keep track of donation payments. |
6 | # ---------------------------------------------------------------------- |
7 | # 30-Jan-2011 (ADH): Created. |
8 | ######################################################################## |
9 | |
10 | class Donation < Collectable |
11 | |
12 | # Note inheritance from Collectable. |
13 | |
14 | # =========================================================================== |
15 | # CHARACTERISTICS |
16 | # =========================================================================== |
17 | |
18 | acts_as_audited :except => [ :amount_for_sorting, :authorisation_tokens ] |
19 | |
20 | # The user's name and e-mail address along with the poll's title is copied |
21 | # into the donation object. While it's useful to have a link back to the |
22 | # original object in the database by ID, it's also useful to make sure that |
23 | # the donation object stands alone should the user account or original poll |
24 | # object be deleted for any reason. |
25 | |
26 | belongs_to :user |
27 | belongs_to :poll |
28 | belongs_to :currency |
29 | |
30 | serialize :authorisation_tokens # A hash of tokens is stored here |
31 | |
32 | # Limitations and requirements. |
33 | |
34 | MAXLEN_STATE = 16 # See STATE MACHINE below |
35 | |
36 | # NB - *DO NOT* validate user names or e-mail addresses beyond "not blank". |
37 | # In redistribution donations, these are set to "-" and the associated user |
38 | # ID to "0". See "app/models/poll.rb". |
39 | |
40 | validates_presence_of :user_id, |
41 | :user_name, |
42 | :user_email, |
43 | :poll_id, |
44 | :poll_title, |
45 | :currency_id, |
46 | :workflow_state |
47 | |
48 | validates_numericality_of :amount_integer, |
49 | :amount_fraction, |
50 | |
51 | :only_integer => true |
52 | |
53 | validate :credit_donations_must_be_above_zero |
54 | |
55 | def credit_donations_must_be_above_zero |
56 | if ( self.amount_for_sorting <= 0.0 && self.debit == false ) |
57 | self.errors.add :amount_for_sorting, :greater_than, :count => 0 |
58 | end |
59 | end |
60 | |
61 | # Keep a for-sorting cache column up to date. |
62 | # |
63 | def update_sorting_amount |
64 | amount = Currency.simplify( |
65 | self.amount_integer, |
66 | self.amount_fraction |
67 | ).to_f |
68 | |
69 | amount = -amount if ( self.debit ) |
70 | self.amount_for_sorting = amount |
71 | end |
72 | |
73 | before_validation :update_sorting_amount |
74 | |
75 | # See Jason King's "good_sort" plugin: |
76 | # |
77 | # http://github.com/JasonKing/good_sort/tree/master |
78 | # |
79 | # As with Workflow (see later), must use a "table_exists?" check here. |
80 | |
81 | if ( Donation.table_exists? ) |
82 | sort_on( :updated_at, |
83 | :poll_title, |
84 | :user_name, |
85 | :user_email, |
86 | :amount_for_sorting ) |
87 | end |
88 | |
89 | # How many entries to list per index page? See the Will Paginate plugin: |
90 | # |
91 | # http://wiki.github.com/mislav/will_paginate |
92 | |
93 | def self.per_page |
94 | MAXIMUM_LIST_ITEMS_PER_PAGE |
95 | end |
96 | |
97 | # Search columns for views rendering the "shared/_simple_search.html.erb" |
98 | # view partial and using "appctrl_build_search_conditions" to handle queries. |
99 | |
100 | SEARCH_COLUMNS = %w{ poll_title user_name user_email amount_for_sorting } |
101 | |
102 | # =========================================================================== |
103 | # PERMISSIONS |
104 | # =========================================================================== |
105 | |
106 | # N/A |
107 | |
108 | # =========================================================================== |
109 | # STATE MACHINE |
110 | # =========================================================================== |
111 | |
112 | STATE_INITIAL = :initial |
113 | STATE_PAID = :paid |
114 | |
115 | # Must use "table_exists?", as Workflow needs to check the database but this |
116 | # class may be examined by migrations before the table is created. |
117 | |
118 | if ( Donation.table_exists? ) |
119 | include Workflow # http://github.com/geekq/workflow |
120 | workflow do |
121 | |
122 | # *********************************************************************** |
123 | # WARNING! You must always wrap state changes herein with a transaction |
124 | # block as other objects may be updated as a result of the state change; |
125 | # inconsistencies will result if one or more of these alterations fail |
126 | # without rollback. |
127 | # |
128 | # WARNING! Debit donations do not automatically update their associated |
129 | # poll when saved. See comments below for more information. |
130 | # *********************************************************************** |
131 | |
132 | # STATE_INITIAL: Payment is pending and not confirmed; subject to garbage |
133 | # collection / timeout. |
134 | # |
135 | state STATE_INITIAL do |
136 | event :paid, :transitions_to => STATE_PAID |
137 | end |
138 | |
139 | # STATE_PAID: Payment has been made. We may find that the poll has gone |
140 | # into a "non-donatable" state by now, though and have to give the user |
141 | # their money back straight away - an edge case, but a genuine risk. |
142 | # Must use pessimistic locking here to make sure that we get an exclusive |
143 | # hold of the item and don't end up incrementing vote or total counts for |
144 | # an object that's concurrently being updated in another request thread, |
145 | # leading to a race condition with one or the other thread's changes |
146 | # getting overwritten. |
147 | # |
148 | # DEBIT DONATIONS RESULTING FROM POLL EXPIRY AND FUNDS REDISTRIBUTION DO |
149 | # NOT RESULT IN AUTOMATIC DECREMENTING OF THE ASSOCIATED POLL'S TOTALS OR |
150 | # ANY CHANGE TO ITS VOTE COUNT. The creator of the debit donation is |
151 | # responsible for this. |
152 | # |
153 | state STATE_PAID do |
154 | on_entry do |
155 | |
156 | unless ( self.debit ) |
157 | Poll.transaction do # Transaction required for pessimistic lock |
158 | |
159 | # That ":lock => true" is enough to eventually mean that the |
160 | # database layer works its magic and no matter how many threads |
161 | # run this code concurrently, they'll end up seralised and each |
162 | # work on an at-that-instant valid, up to date Poll object. |
163 | |
164 | poll = Poll.find_by_id( self.poll_id, :lock => true ) |
165 | |
166 | if ( poll.nil? ) |
167 | raise I18n.t( :'activerecord.errors.models.donation.poll_has_vanished' ) |
168 | elsif ( poll.workflow_state.to_sym != Poll::STATE_OPEN ) |
169 | raise I18n.t( :'activerecord.errors.models.donation.poll_is_not_open' ) |
170 | end |
171 | |
172 | poll.votes = poll.votes + 1 |
173 | |
174 | poll.total_integer, poll.total_fraction = Currency.add( |
175 | poll.total_integer, |
176 | poll.total_fraction, |
177 | self.amount_integer, |
178 | self.amount_fraction |
179 | ) |
180 | |
181 | poll.save! |
182 | end # Transaction required for pessimistic lock |
183 | end # 'unless ( self.debit )' |
184 | end # 'on_entry do' |
185 | end # State machine block for STATE_PAID |
186 | |
187 | end |
188 | end |
189 | |
190 | # =========================================================================== |
191 | # GENERAL |
192 | # =========================================================================== |
193 | |
194 | # Apply a default sort to the given array of model instances. The array is |
195 | # modified in place. See also "default_sort_hash". |
196 | # |
197 | def self.apply_default_sort_order( array ) |
198 | array.sort! { | x, y | y.updated_at <=> y.updated_at } |
199 | end |
200 | |
201 | # Return the default sort hash for Donations objects to avoid duplication |
202 | # in the DonationsController and PollsController, both of which can generate |
203 | # sortable lists of donations. The hash is suitable for passing as the |
204 | # ":default_sorting" option to "appctrl_search_sort_and_paginate". See also |
205 | # "apply_default_sort_order". |
206 | # |
207 | def self.default_sort_hash |
208 | { 'down' => 'true', 'field' => 'updated_at' } |
209 | end |
210 | |
211 | # See the Collectable superclass for details. |
212 | # |
213 | def self.garbage_collect( session ) |
214 | super( |
215 | session, |
216 | Donation, |
217 | [ |
218 | '( updated_at < ? ) AND ( workflow_state = ? )', |
219 | TIMEOUT.seconds.ago, |
220 | STATE_INITIAL.to_s |
221 | ] |
222 | ) |
223 | end |
224 | |
225 | # Safely destroy all donations in STATE_INITIAL for the given user. There |
226 | # should only be one of these, but just in case there are many, a transaction |
227 | # is used so that destruction is done as an atomic operation in the database. |
228 | # |
229 | def self.safely_destroy_initial_state_donations_for( user ) |
230 | Donation.transaction do |
231 | Donation.destroy_all( |
232 | :user_id => user.id, |
233 | :workflow_state => Donation::STATE_INITIAL.to_s |
234 | ) |
235 | end |
236 | end |
237 | |
238 | # Return a conditions array, suitable for passing as a value to the |
239 | # :conditions key in a 'find'-style operation, which returns only donations |
240 | # that can be viewed by the user given in the last parameter. |
241 | # |
242 | # The first parameter can be e.g. a request's "params" hash - any has which |
243 | # contains things like a 'user_id' key (or not), influencing the generated |
244 | # constraints. |
245 | # |
246 | # The returned constraints ensure that initial state items are not found. |
247 | # |
248 | # Note that sensible results are only returned in the presence of a current |
249 | # user value, so a user must be logged in if this method is called. Do NOT |
250 | # try to pass 'nil' in the last parameter. |
251 | # |
252 | def self.conditions_for( params, current_user ) |
253 | user_id = params[ :user_id ] |
254 | poll_id = params[ :poll_id ] |
255 | |
256 | # Only administrators can do anything other than list their own donations. |
257 | # To keep life simple, only administrators can get per-poll donations. |
258 | |
259 | unless ( current_user.admin? ) |
260 | user_id = current_user.id |
261 | poll_id = nil |
262 | end |
263 | |
264 | # Generate the constraints array. |
265 | |
266 | constraints = 'workflow_state <> (?)' |
267 | arguments = [ Donation::STATE_INITIAL.to_s ] |
268 | |
269 | unless ( user_id.nil? ) |
270 | constraints << ' AND user_id = (?)' |
271 | arguments << user_id |
272 | end |
273 | |
274 | unless ( poll_id.nil? ) |
275 | constraints << ' AND poll_id = (?)' |
276 | arguments << poll_id |
277 | end |
278 | |
279 | return [ "(#{ constraints })" ] + arguments |
280 | end |
281 | |
282 | # Build a Donation object for the given poll, donor, donation amount as |
283 | # the integer part string and donation amount as the fraction part string. |
284 | # |
285 | # Throws an exception on failure, else has succeeded. Failure is unexpected |
286 | # and no recovery is possible, beyond apologising to the user and maybe |
287 | # asking them to try again later (while some sysadmin looks at the web server |
288 | # and database trying to figure out what's wrong!). Returns a reference to |
289 | # the new Donation instance upon success. |
290 | # |
291 | # Any initial state items belonging to the user on entry are deleted with a |
292 | # call to "safely_destroy_initial_state_donations_for" since the new item |
293 | # will supercede them. The new donation is in an initial state on exit. No |
294 | # financial contributions are made to the associated poll until the payment |
295 | # completes; see the state machine for details. |
296 | # |
297 | # THE OBJECT IS NOT SAVED to allow for validations to be checked at a higher |
298 | # level. THE CALLER MUST SAVE THE OBJECT unless it is temporary. |
299 | # |
300 | # As a special case for administrators registering external donations, you |
301 | # can pass override options in the last parameter for the recorded user name |
302 | # and e-mail address. The donation is still recorded against the given User |
303 | # in the second parameter - this should be the admin - but the name and |
304 | # e-mail can be different (e.g. the admin fills this into a form). Set keys |
305 | # ":external" to "true", ":name" to the human readable person's full name and |
306 | # ":email" to the e-mail address. If nil, an empty string is stored. If you |
307 | # fail to pass ":external => true" in the options, then the administrator's |
308 | # in-progress initial state donations, if any, may be accidentally wiped. |
309 | # |
310 | def self.generate_for( poll, donor, donation_integer, donation_fraction, options = {} ) |
311 | |
312 | self.safely_destroy_initial_state_donations_for( donor ) unless options[ :external ] == true |
313 | |
314 | return Donation.transaction do |
315 | |
316 | # In English as this is a debug-only exception. |
317 | |
318 | raise( "Associated poll cannot receive donations" ) if ( poll.workflow_state.to_sym != Poll::STATE_OPEN ) |
319 | |
320 | # Create the new donation object. |
321 | |
322 | options[ :name ] = donor.name unless options.has_key?( :name ) |
323 | options[ :email ] = donor.email unless options.has_key?( :email ) |
324 | |
325 | donation = Donation.new( |
326 | :user => donor, |
327 | :user_name => options[ :name ] || "", |
328 | :user_email => options[ :email ] || "", |
329 | |
330 | :poll => poll, |
331 | :poll_title => poll.title, |
332 | |
333 | :currency => poll.currency, |
334 | |
335 | :amount_integer => donation_integer, |
336 | :amount_fraction => donation_fraction |
337 | ) |
338 | |
339 | donation # Transaction block return value |
340 | end # "return Donation.transaction do" |
341 | end |
342 | |
343 | # Send a notification e-mail message to the seller confirming their |
344 | # purchase. For checks and balances, send one to 'admin' too. Why is this |
345 | # done here, rather than inside the Purchase and Order model state change |
346 | # transactions? Well: |
347 | # |
348 | # (1) We could allow sending failures to unwind the transactions and |
349 | # return the user to the cart with the purchase cancelled - but |
350 | # any messages sent before the one which failed would have gone |
351 | # out already, with very confusing consequences for users. |
352 | # |
353 | # (2) We could implement some kind of queueing mechanism. For example |
354 | # Retrospectiva extends Action Mailer to provide a queue which is |
355 | # persisted in the database and uses a cron-style scheduled task |
356 | # to periodically flush the mail queue. But this doesn't really |
357 | # gain us anything - either all messages send, or some fail but |
358 | # some have already been sent to people, whether we process the queue |
359 | # inside the purchase/order state change transaction blocks or not. |
360 | # |
361 | # Bottom line is we consider e-mail to be unreliable and NOT critical to |
362 | # operations. If sending fails, we ignore it. But we must make sure that all |
363 | # purchase and order state changes succeeded before starting to (try to) send |
364 | # related messages. As a result, callers should only call this method when |
365 | # they are sure that all relevant database updates to confirm a purchase and |
366 | # its orders have been successfully committed. |
367 | # |
368 | # All errors inside this method are suppressed; exceptions are not thrown. |
369 | # |
370 | def send_new_item_notifications |
371 | begin |
372 | CanvassMailer.deliver_new_donation_made( self ) |
373 | rescue |
374 | # Ignore everything. |
375 | end |
376 | |
377 | begin |
378 | CanvassMailer.deliver_new_donation_made_admin( self ) |
379 | rescue |
380 | # Ignore everything. |
381 | end |
382 | end |
383 | |
384 | # Convert the internal integer and fraction amount strings into a quantity |
385 | # expressed as a BigDecimal which is rounded and (if necessary) multiplied to |
386 | # meet the requirements of the payment gateway, according to PaymentGateway's |
387 | # 'get_amount_for_gateway' class method. |
388 | # |
389 | def amount_for_gateway |
390 | return PaymentGateway.get_amount_for_gateway( |
391 | BigDecimal.new( |
392 | self.currency.round( |
393 | Currency.simplify( |
394 | self.amount_integer, |
395 | self.amount_fraction |
396 | ) |
397 | ) |
398 | ) |
399 | ) |
400 | end |
401 | |
402 | private |
403 | |
404 | # May need to void a transaction if destroyed mid-way through the on-site |
405 | # payment process. Do this once this and all associated objects have been |
406 | # destroyed and this instance has been frozen - call via "after_destroy". |
407 | # |
408 | # The method tries to be as robust as possible, ignoring errors from any |
409 | # particular token and trying its best to void all of the transactions. |
410 | # |
411 | def void_transaction_if_necessary |
412 | unless ( authorisation_tokens.blank? ) |
413 | authorisation_tokens.values.each do | token | |
414 | begin |
415 | PaymentGateway.instance.gateway.void( token ) |
416 | rescue |
417 | # Ignore failures; there's simply nothing we can do about them. The |
418 | # reservation of money on the buyer's card will expire eventually. In |
419 | # the mean time, keep trying for all of the tokens - others may work. |
420 | end |
421 | end |
422 | end |
423 | end |
424 | |
425 | end |