google4r-checkout 0.1.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 (39) hide show
  1. data/CHANGES +40 -0
  2. data/LICENSE +22 -0
  3. data/README +76 -0
  4. data/lib/google4r/checkout.rb +33 -0
  5. data/lib/google4r/checkout/commands.rb +260 -0
  6. data/lib/google4r/checkout/frontend.rb +108 -0
  7. data/lib/google4r/checkout/notifications.rb +549 -0
  8. data/lib/google4r/checkout/shared.rb +541 -0
  9. data/lib/google4r/checkout/xml_generation.rb +313 -0
  10. data/test/integration/checkout_command_test.rb +103 -0
  11. data/test/unit/address_test.rb +131 -0
  12. data/test/unit/area_test.rb +44 -0
  13. data/test/unit/checkout_command_test.rb +116 -0
  14. data/test/unit/checkout_command_xml_generator_test.rb +203 -0
  15. data/test/unit/command_test.rb +115 -0
  16. data/test/unit/flat_rate_shipping_test.rb +114 -0
  17. data/test/unit/frontend_test.rb +63 -0
  18. data/test/unit/item_test.rb +159 -0
  19. data/test/unit/marketing_preferences_test.rb +65 -0
  20. data/test/unit/merchant_code_test.rb +122 -0
  21. data/test/unit/new_order_notification_test.rb +115 -0
  22. data/test/unit/notification_acknowledgement_test.rb +43 -0
  23. data/test/unit/notification_handler_test.rb +93 -0
  24. data/test/unit/order_adjustment_test.rb +119 -0
  25. data/test/unit/order_state_change_notification_test.rb +159 -0
  26. data/test/unit/pickup_shipping_test.rb +70 -0
  27. data/test/unit/postal_area_test.rb +71 -0
  28. data/test/unit/private_data_parser_test.rb +68 -0
  29. data/test/unit/shipping_adjustment_test.rb +100 -0
  30. data/test/unit/shipping_method_test.rb +41 -0
  31. data/test/unit/shopping_cart_test.rb +146 -0
  32. data/test/unit/tax_rule_test.rb +70 -0
  33. data/test/unit/tax_table_test.rb +82 -0
  34. data/test/unit/us_country_area_test.rb +76 -0
  35. data/test/unit/us_state_area_test.rb +70 -0
  36. data/test/unit/us_zip_area_test.rb +66 -0
  37. data/test/unit/world_area_test.rb +48 -0
  38. data/var/cacert.pem +7815 -0
  39. metadata +92 -0
@@ -0,0 +1,541 @@
1
+ #--
2
+ # Project: google4r
3
+ # File: lib/google4r/checkout/shared.rb
4
+ # Author: Manuel Holtgrewe <purestorm at ggnore dot net>
5
+ # Copyright: (c) 2007 by Manuel Holtgrewe
6
+ # License: MIT License as follows:
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining
9
+ # a copy of this software and associated documentation files (the
10
+ # "Software"), to deal in the Software without restriction, including
11
+ # without limitation the rights to use, copy, modify, merge, publish,
12
+ # distribute, sublicense, and/or sell copies of the Software, and to permit
13
+ # persons to whom the Software is furnished to do so, subject to the
14
+ # following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be included
17
+ # in all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
20
+ # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
22
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
23
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
24
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
25
+ # OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+ #++
27
+ # This file contains the classes and modules that are shared by the notification
28
+ # handling and parsing as well as the command generating code.
29
+
30
+ #--
31
+ # TODO: Make the optional attributes return defaults that make sense, i.e. Money.new(0)?
32
+ #++
33
+ module Google4R #:nodoc:
34
+ module Checkout #:nodoc:
35
+ # This exception is thrown by Command#send_to_google_checkout when an error occured.
36
+ class GoogleCheckoutError < Exception
37
+ # The serial number of the error returned by Google.
38
+ attr_reader :serial_number
39
+
40
+ # The HTTP response code of Google's response.
41
+ attr_reader :response_code
42
+
43
+ # The parameter is a hash with the entries :serial_number, :message and :response_code.
44
+ # The attributes serial_number, message and response_code are set to the values in the
45
+ # Hash.
46
+ def initialize(hash)
47
+ @response_code = hash[:response_code]
48
+ @message = hash[:message]
49
+ @serial_number = hash[:serial_number]
50
+ end
51
+
52
+ # Returns a human readable representation of the Exception with the message, HTTP
53
+ # response code and serial number as returned by Google checkout.
54
+ def to_s
55
+ "GoogleCheckoutError: message = '#{@message}', response code = '#{@response_code}', serial number = '#{@serial_number}'."
56
+ end
57
+ end
58
+
59
+ # ShoppingCart instances are containers for Item instances. You can add
60
+ # Items to the class using #create_item (see the documentation of this
61
+ # method for an example).
62
+ class ShoppingCart
63
+ # The owner of this cart. At the moment, this always is the CheckoutCartCommand.
64
+ attr_reader :owner
65
+
66
+ # The items in the cart. Do not modify this array directly but use
67
+ # #create_item to add items.
68
+ attr_reader :items
69
+
70
+ # You can set the <cart-expiration> time with this property. If left
71
+ # unset then the tag will not be generated and the cart will never
72
+ # expire.
73
+ attr_accessor :expires_at
74
+
75
+ # You can set almost arbitrary data into the cart using this method.
76
+ #
77
+ # The data will be converted to XML in the following way: The keys are converted
78
+ # to tag names (whitespace becomes "-", all chars not matching /[a-zA-Z0-9\-_])/
79
+ # will be removed.
80
+ #
81
+ # If a value is an array then the key for this value will be used as the tag
82
+ # name for each of the arrays's entries.
83
+ #
84
+ # Arrays will be flattened before it is processed.
85
+ #
86
+ # === Example
87
+ #
88
+ # cart.private_data = { 'foo' => { 'bar' => 'baz' } })
89
+ #
90
+ # # will produce the following XML
91
+ #
92
+ # <foo>
93
+ # <bar>baz</bar>
94
+ # </foo>
95
+ #
96
+ #
97
+ # cart.private_data = { 'foo' => [ { 'bar' => 'baz' }, "d'oh", 2 ] }
98
+ #
99
+ # # will produce the following XML
100
+ #
101
+ # <foo>
102
+ # <bar>baz</bar>
103
+ # </foo>
104
+ # <foo>d&amp;</foo>
105
+ # <foo>2</foo>
106
+ attr_reader :private_data
107
+
108
+ # Sets the value of the private_data attribute.
109
+ def private_data=(value)
110
+ raise "The given value #{value.inspect} is not a Hash!" unless value.kind_of?(Hash)
111
+ @private_data = value
112
+ end
113
+
114
+ # Initialize a new ShoppingCart with an empty Array for the items.
115
+ def initialize(owner)
116
+ @owner = owner
117
+ @items = Array.new
118
+ end
119
+
120
+ # Use this method to add a new item to the cart. If you use a block with
121
+ # this method then the block will be given the new item. The new item
122
+ # will be returned in any case.
123
+ #
124
+ # Passing a block is the preferred way of using this method.
125
+ #
126
+ # === Example
127
+ #
128
+ # # Using a block (preferred).
129
+ # cart = ShoppingCart.new
130
+ #
131
+ # cart.create_item do |item|
132
+ # item.name = "Dry Food Pack"
133
+ # item.description = "A pack of highly nutritious..."
134
+ # item.unit_price = Money.new(3500, "USD") # $35.00
135
+ # item.quantity = 1
136
+ # end
137
+ #
138
+ # # Not using a block.
139
+ # cart = ShoppingCart.new
140
+ #
141
+ # item = cart.create_item
142
+ # item.name = "Dry Food Pack"
143
+ # item.description = "A pack of highly nutritious..."
144
+ # item.unit_price = Money.new(3500, "USD") # $35.00
145
+ # item.quantity = 1
146
+ def create_item(&block)
147
+ item = Item.new(self)
148
+ @items << item
149
+
150
+ # Pass the newly generated item to the given block to set its attributes.
151
+ yield(item) if block_given?
152
+
153
+ return item
154
+ end
155
+
156
+ # Creates a new ShoppingCart object from a REXML::Element object.
157
+ def self.create_from_element(element, owner)
158
+ result = ShoppingCart.new(owner)
159
+
160
+ text = element.elements['cart-expiration/good-until-date'].text rescue nil
161
+ result.expires_at = Time.parse(text) unless text.nil?
162
+
163
+ data_element = element.elements['merchant-private-data']
164
+ value = PrivateDataParser.element_to_value(data_element) unless data_element.nil?
165
+
166
+ result.private_data = value unless value.nil?
167
+
168
+ element.elements.each('items/item') do |item_element|
169
+ result.items << Item.create_from_element(item_element, result)
170
+ end
171
+
172
+ return result
173
+ end
174
+ end
175
+
176
+ # An Item object represents a line of goods in the shopping cart/receipt.
177
+ #
178
+ # You should never initialize them directly but use ShoppingCart#create_item instead.
179
+ #
180
+ # Note that you have to create/set the tax tables for the owner of the cart in which
181
+ # the item is before you can set the tax_table attribute.
182
+ class Item
183
+ # The cart that this item belongs to.
184
+ attr_reader :shopping_cart
185
+
186
+ # The name of the cart item (string, required).
187
+ attr_accessor :name
188
+
189
+ # The description of the cart item (string, required).
190
+ attr_accessor :description
191
+
192
+ # The price for one unit of the given good (Money instance, required).
193
+ attr_reader :unit_price
194
+
195
+ # Sets the price for one unit of goods described by this item. money must respond to
196
+ # :cents and :currency as the Money class does.
197
+ def unit_price=(money)
198
+ if not (money.respond_to?(:cents) and money.respond_to?(:currency)) then
199
+ raise "Invalid price - does not respond to :cents and :currency - #{money.inspect}."
200
+ end
201
+
202
+ @unit_price = money
203
+ end
204
+
205
+ # Number of units that this item represents (integer, required).
206
+ attr_accessor :quantity
207
+
208
+ # Optional string value that is used to store the item's id (defined by the merchant)
209
+ # in the cart. Serialized to <merchant-item-id> in XML. Displayed by Google Checkout.
210
+ attr_accessor :id
211
+
212
+ # Optional hash value that is used to store the item's id (defined by the merchant)
213
+ # in the cart. Serialized to <merchant-private-item-data> in XML. Not displayed by
214
+ # Google Checkout.
215
+ #
216
+ # Must be a Hash. See ShoppingCart#private_data on how the serialization to XML is
217
+ # done.
218
+ attr_reader :private_data
219
+
220
+ # Sets the private data for this item.
221
+ def private_data=(value)
222
+ raise "The given value #{value.inspect} is not a Hash!" unless value.kind_of?(Hash)
223
+ @private_data = value
224
+ end
225
+
226
+ # The tax table to use for this item. Optional.
227
+ attr_reader :tax_table
228
+
229
+ # Sets the tax table to use for this item. When you set this attribute using this
230
+ # method then the used table must already be added to the cart. Otherwise, a
231
+ # RuntimeError will be raised.
232
+ def tax_table=(table)
233
+ raise "The table #{table.inspect} is not in the item's cart yet!" unless shopping_cart.owner.tax_tables.include?(table)
234
+
235
+ @tax_table = table
236
+ end
237
+
238
+ # Create a new Item in the given Cart. You should not instantize this class directly
239
+ # but use Cart#create_item instead.
240
+ def initialize(shopping_cart)
241
+ @shopping_cart = shopping_cart
242
+ end
243
+
244
+ # Creates a new Item object from a REXML::Element object.
245
+ def self.create_from_element(element, shopping_cart)
246
+ result = Item.new(shopping_cart)
247
+
248
+ result.name = element.elements['item-name'].text
249
+ result.description = element.elements['item-description'].text
250
+ result.quantity = element.elements['quantity'].text.to_i
251
+ result.id = element.elements['merchant-item-id'].text rescue nil
252
+
253
+ data_element = element.elements['merchant-private-item-data']
254
+ if not data_element.nil? then
255
+ value = PrivateDataParser.element_to_value(data_element)
256
+ result.private_data = value unless value.nil?
257
+ end
258
+
259
+ table_selector = element.elements['tax-table-selector'].text rescue nil
260
+ if not table_selector.nil? then
261
+ result.tax_table = shopping_cart.owner.tax_tables.find {|table| table.name == table_selector }
262
+ end
263
+
264
+ unit_price = (element.elements['unit-price'].text.to_f * 100).to_i
265
+ unit_price_currency = element.elements['unit-price/@currency'].value
266
+ result.unit_price = Money.new(unit_price, unit_price_currency)
267
+
268
+ return result
269
+ end
270
+ end
271
+
272
+ # A TaxTable is an ordered array of TaxRule objects. You should create the TaxRule
273
+ # instances using #create_rule
274
+ #
275
+ # You must set up a tax table factory and should only create tax tables from within
276
+ # its temporal factory method as described in the class documentation of Frontend.
277
+ #
278
+ # Each tax table must have one or more tax rules.
279
+ #
280
+ # === Example
281
+ #
282
+ # include Google4R::Checkout
283
+ #
284
+ # tax_free_table = TaxTable.new(false)
285
+ # tax_free_table.name = "default table"
286
+ # tax_free_table.create_rule do |rule|
287
+ # rule.area = UsCountryArea.new(UsCountryArea::ALL)
288
+ # rule.rate = 0.0
289
+ # end
290
+ class TaxTable
291
+ # The name of this tax table (string, required).
292
+ attr_accessor :name
293
+
294
+ # An Array of the TaxRule objects that this TaxTable contains. Use #create_rule do
295
+ # add to this Array but do not change it directly.
296
+ attr_reader :rules
297
+
298
+ # Boolean, true iff the table's standalone attribute is to be set to "true".
299
+ attr_reader :standalone
300
+
301
+ def initialize(standalone)
302
+ @rules = Array.new
303
+
304
+ @standalone = standalone
305
+ end
306
+
307
+ # Use this method to add a new TaxRule to the table. If you use a block with
308
+ # this method then the block will called with the newly created rule for the
309
+ # parameter. The method will return the new rule in any case.
310
+ def create_rule(&block)
311
+ rule = TaxRule.new(self)
312
+ @rules << rule
313
+
314
+ # Pass the newly generated rule to the given block to set its attributes.
315
+ yield(rule) if block_given?
316
+
317
+ return rule
318
+ end
319
+ end
320
+
321
+ # A TaxRule specifies which taxes to apply in which area. Have a look at the "Google
322
+ # Checkout documentation" [http://code.google.com/apis/checkout/developer/index.html#specifying_tax_info]
323
+ # for more information.
324
+ class TaxRule
325
+ # The table this rule belongs to.
326
+ attr_reader :table
327
+
328
+ # The tax rate for this rule (double, required).
329
+ attr_accessor :rate
330
+
331
+ # The area where this tax rule applies (Area subclass instance, required). Serialized
332
+ # to <tax-area> in XML.
333
+ attr_accessor :area
334
+
335
+ # If shipping should be taxed with this tax rule (boolean, defaults to false)
336
+ attr_accessor :shipping_taxed
337
+
338
+ # Creates a new TaxRule in the given TaxTable. Do no call this method yourself
339
+ # but use TaxTable#create_rule instead!
340
+ def initialize(table)
341
+ @table = table
342
+ @shipping_taxed = false
343
+ end
344
+ end
345
+
346
+ # Abstract class for areas that are used to specify a tax area. Do not use this class
347
+ # but only its subclasses.
348
+ class Area
349
+ # Mark this class as abstract by throwing a RuntimeError on initialization.
350
+ def initialize #:nodoc:
351
+ raise "Do not use the abstract class Google::Checkout::Area!"
352
+ end
353
+ end
354
+
355
+ # Instances of UsZipArea represent areas specified by US ZIPs and ZIP patterns.
356
+ class UsZipArea < Area
357
+ # The pattern for this ZIP area.
358
+ attr_accessor :pattern
359
+
360
+ # You can optionally initialize the Area with its value.
361
+ def initialize(pattern=nil)
362
+ self.pattern = pattern unless pattern.nil?
363
+ end
364
+ end
365
+
366
+ # Instances of WorldArea represent a tax area that applies globally.
367
+ class WorldArea < Area
368
+ def initialize
369
+ end
370
+ end
371
+
372
+ # Instances of PostalArea represent a geographical region somewhere in the world.
373
+ class PostalArea < Area
374
+
375
+ # String; The two-letter ISO 3166 country code.
376
+ attr_accessor :country_code
377
+
378
+ # String; Postal code or a range of postal codes for a specific country. To specify a
379
+ # range of postal codes, use an asterisk as a wildcard operator. For example,
380
+ # you can provide a postal_code_pattern value of "SW*" to indicate that a shipping
381
+ # option is available or a tax rule applies in any postal code beginning with the
382
+ # characters SW.
383
+ #
384
+ # === Example
385
+ #
386
+ # area = PostalArea.new('DE')
387
+ # area.postal_code_pattern = '10*'
388
+ attr_accessor :postal_code_pattern
389
+
390
+ # === Parameters
391
+ #
392
+ # country_code should be a two-letter ISO 3166 country code
393
+ # postal_code_pattern should be a full or partial postcode string, using * as a wildcard
394
+ def initialize(country_code, postal_code_pattern=nil)
395
+
396
+ @country_code = country_code
397
+ @postal_code_pattern = postal_code_pattern
398
+ end
399
+ end
400
+
401
+ # Instances of UsStateArea represent states in the US.
402
+ class UsStateArea < Area
403
+ # The two-letter code of the US state.
404
+ attr_reader :state
405
+
406
+ # You can optionally initialize the Area with its value.
407
+ def initialize(state=nil)
408
+ self.state = state unless state.nil?
409
+ end
410
+
411
+ # Writer for the state attribute. value must match /^[A-Z]{2,2}$/.
412
+ def state=(value)
413
+ raise "Invalid US state: #{value}" unless value =~ /^[A-Z]{2,2}$/
414
+ @state = value
415
+ end
416
+ end
417
+
418
+ # Instances of UsCountryArea identify a region within the US.
419
+ class UsCountryArea < Area
420
+ CONTINENTAL_48 = "CONTINENTAL_48".freeze
421
+ FULL_50_STATES = "FULL_50_STATES".freeze
422
+ ALL = "ALL".freeze
423
+
424
+ # The area that is specified with this UsCountryArea (required). Can be
425
+ # one of UsCountryArea::CONTINENTAL_48, UsCountryArea::FULL_50_STATES
426
+ # and UsCountryArea::ALL.
427
+ # See the Google Checkout API for information on these values.
428
+ attr_reader :area
429
+
430
+ # You can optionally initialize the Area with its value.
431
+ def initialize(area=nil)
432
+ self.area = area unless area.nil?
433
+ end
434
+
435
+ # Writer for the area attribute. value must be one of CONTINENTAL_48,
436
+ # FULL_50_STATES and ALL
437
+ def area=(value)
438
+ raise "Invalid area :#{value}!" unless [ CONTINENTAL_48, FULL_50_STATES, ALL ].include?(value)
439
+ @area = value
440
+ end
441
+ end
442
+
443
+ # Abstract class for shipping methods. Do not use this class directly but only
444
+ # one of its subclasses.
445
+ class ShippingMethod
446
+ # The name of the shipping method (string, required).
447
+ attr_accessor :name
448
+
449
+ # The price of the shipping method (Money instance, required).
450
+ attr_reader :price
451
+
452
+ # Sets the cost for this shipping method. money must respond to :cents and :currency
453
+ # as Money objects would.
454
+ def price=(money)
455
+ if not (money.respond_to?(:cents) and money.respond_to?(:currency)) then
456
+ raise "Invalid cost - does not respond to :cents and :currency - #{money.inspect}."
457
+ end
458
+
459
+ @price = money
460
+ end
461
+
462
+ # Mark this class as abstract by throwing a RuntimeError on initialization.
463
+ def initialize
464
+ raise "Do not use the abstract class Google::Checkout::ShippingMethod!"
465
+ end
466
+ end
467
+
468
+ # A class that represents the "pickup" shipping method.
469
+ class PickupShipping < ShippingMethod
470
+ def initialize
471
+ end
472
+ end
473
+
474
+ # A class that represents the "flat_rate" shipping method.
475
+ class FlatRateShipping < ShippingMethod
476
+ # An Array of allowed areas for this flat_rate shipping instance. Use
477
+ # #create_allowed_area to add to this area but do not change it directly.
478
+ attr_reader :allowed_areas
479
+
480
+ # An Array of excluded areas for this flat_rate shipping instance. Use
481
+ # #create_excluded_area to add to this area but do not change it directly.
482
+ attr_reader :excluded_areas
483
+
484
+ def initialize
485
+ @allowed_areas = Array.new
486
+ @excluded_areas = Array.new
487
+ end
488
+
489
+ # Creates a new Area, adds it to the internal list of allowed areas for this
490
+ # shipping types. If you passed a block (preferred) then the block is called
491
+ # with the Area as the only parameter.c
492
+ #
493
+ # The area to be created depends on the given parameter clazz. It can be one
494
+ # of { PostalArea, UsCountryArea, UsStateArea, UsZipArea, WorldArea }.
495
+ #
496
+ # Raises a RuntimeError if the parameter clazz is invalid.
497
+ #
498
+ # === Example
499
+ #
500
+ # method = FlatRateShipping.new
501
+ # method.create_allowed_area(UsCountryArea) do |area|
502
+ # area.area = UsCountryArea::ALL
503
+ # end
504
+ def create_allowed_area(clazz, &block)
505
+ raise "Invalid Area class: #{clazz}!" unless [ PostalArea, UsCountryArea, UsStateArea, UsZipArea, WorldArea ].include?(clazz)
506
+ area = clazz.new
507
+ @allowed_areas << area
508
+
509
+ yield(area) if block_given?
510
+
511
+ return area
512
+ end
513
+
514
+ # Creates a new Area, adds it to the internal list of excluded areas for this
515
+ # shipping types. If you passed a block (preferred) then the block is called
516
+ # with the Area as the only parameter. The created area is returned in any case.
517
+ #
518
+ # The area to be created depends on the given parameter clazz. It can be one
519
+ # of { UsCountryArea, UsStateArea, UsZipArea }.
520
+ #
521
+ # Raises a RuntimeError if the parameter clazz is invalid.
522
+ #
523
+ # === Example
524
+ #
525
+ # method = FlatRateShipping.new
526
+ # method.create_excluded_area(UsCountryArea) do |area|
527
+ # area.area = UsCountryArea::ALL
528
+ # end
529
+ def create_excluded_area(clazz, &block)
530
+ raise "Invalid Area class: #{clazz}!" unless [ UsCountryArea, UsStateArea, UsZipArea ].include?(clazz)
531
+
532
+ area = clazz.new
533
+ @excluded_areas << area
534
+
535
+ yield(area) if block_given?
536
+
537
+ return area
538
+ end
539
+ end
540
+ end
541
+ end