mlins-google-checkout 0.0.2

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 (66) hide show
  1. data/History.txt +14 -0
  2. data/MIT-LICENSE.txt +23 -0
  3. data/README.txt +143 -0
  4. data/Rakefile +34 -0
  5. data/VERSION.yml +4 -0
  6. data/examples/google_notifications_controller.rb +159 -0
  7. data/lib/duck_punches/hpricot.rb +24 -0
  8. data/lib/google-checkout.rb +65 -0
  9. data/lib/google-checkout/cart.rb +302 -0
  10. data/lib/google-checkout/command.rb +191 -0
  11. data/lib/google-checkout/geography.rb +7 -0
  12. data/lib/google-checkout/geography/area.rb +11 -0
  13. data/lib/google-checkout/geography/postal.rb +26 -0
  14. data/lib/google-checkout/geography/us_country.rb +24 -0
  15. data/lib/google-checkout/geography/us_state.rb +22 -0
  16. data/lib/google-checkout/geography/us_zip.rb +22 -0
  17. data/lib/google-checkout/geography/world.rb +12 -0
  18. data/lib/google-checkout/merchant_calculation.rb +30 -0
  19. data/lib/google-checkout/notification.rb +212 -0
  20. data/lib/google-checkout/shipping.rb +8 -0
  21. data/lib/google-checkout/shipping/filters.rb +32 -0
  22. data/lib/google-checkout/shipping/flat_rate.rb +26 -0
  23. data/lib/google-checkout/shipping/merchant_calculated.rb +29 -0
  24. data/lib/google-checkout/shipping/method.rb +11 -0
  25. data/lib/google-checkout/shipping/pickup.rb +22 -0
  26. data/lib/google-checkout/shipping/restrictions.rb +32 -0
  27. data/spec/fixtures/google/checkout-shopping-cart.xml +22 -0
  28. data/spec/fixtures/google/commands/add-merchant-order-number.xml +5 -0
  29. data/spec/fixtures/google/commands/add-tracking-data.xml +8 -0
  30. data/spec/fixtures/google/commands/archive-order.xml +3 -0
  31. data/spec/fixtures/google/commands/authorize-order.xml +2 -0
  32. data/spec/fixtures/google/commands/cancel-order.xml +5 -0
  33. data/spec/fixtures/google/commands/charge-order.xml +4 -0
  34. data/spec/fixtures/google/commands/deliver-order.xml +9 -0
  35. data/spec/fixtures/google/commands/process-order.xml +2 -0
  36. data/spec/fixtures/google/commands/refund-order.xml +6 -0
  37. data/spec/fixtures/google/commands/send-buyer-message.xml +7 -0
  38. data/spec/fixtures/google/commands/unarchive-order.xml +2 -0
  39. data/spec/fixtures/google/merchant_calculations/shipping.xml +40 -0
  40. data/spec/fixtures/google/notifications/authorization-amount-notification.xml +10 -0
  41. data/spec/fixtures/google/notifications/charge-amount-notification.xml +8 -0
  42. data/spec/fixtures/google/notifications/chargeback-amount-notification.xml +8 -0
  43. data/spec/fixtures/google/notifications/new-order-notification.xml +85 -0
  44. data/spec/fixtures/google/notifications/order-state-change-notification.xml +11 -0
  45. data/spec/fixtures/google/notifications/refund-amount-notification.xml +8 -0
  46. data/spec/fixtures/google/notifications/risk-information-notification.xml +23 -0
  47. data/spec/fixtures/google/responses/checkout-redirect.xml +5 -0
  48. data/spec/fixtures/google/responses/error.xml +5 -0
  49. data/spec/fixtures/google/responses/request-received.xml +3 -0
  50. data/spec/google-checkout/cart_spec.rb +110 -0
  51. data/spec/google-checkout/command_spec.rb +131 -0
  52. data/spec/google-checkout/geography/postal_spec.rb +26 -0
  53. data/spec/google-checkout/geography/us_country_spec.rb +26 -0
  54. data/spec/google-checkout/geography/us_state_spec.rb +11 -0
  55. data/spec/google-checkout/geography/us_zip_spec.rb +11 -0
  56. data/spec/google-checkout/geography/world_spec.rb +12 -0
  57. data/spec/google-checkout/merchant_calculation_spec.rb +17 -0
  58. data/spec/google-checkout/notification_spec.rb +175 -0
  59. data/spec/google-checkout/response_spec.rb +49 -0
  60. data/spec/google-checkout/shipping/flat_rate_spec.rb +46 -0
  61. data/spec/google-checkout/shipping/merchant_calculated_spec.rb +70 -0
  62. data/spec/google-checkout/shipping/pickup_spec.rb +22 -0
  63. data/spec/google-checkout_spec.rb +15 -0
  64. data/spec/spec_helper.rb +47 -0
  65. data/support/cacert.pem +7815 -0
  66. metadata +140 -0
@@ -0,0 +1,302 @@
1
+
2
+ module GoogleCheckout
3
+
4
+ # These are the only sizes allowed by Google. These shouldn't be needed
5
+ # by most people; just specify the :size and :buy_or_checkout options to
6
+ # Cart#checkout_button and the sizes are filled in automatically.
7
+ ButtonSizes = {
8
+ :checkout => {
9
+ :small => { :w => 160, :h => 43 },
10
+ :medium => { :w => 168, :h => 44 },
11
+ :large => { :w => 180, :h => 46 },
12
+ },
13
+
14
+ :buy_now => {
15
+ :small => { :w => 117, :h => 48 },
16
+ :medium => { :w => 121, :h => 44 },
17
+ :large => { :w => 121, :h => 44 },
18
+ },
19
+ }
20
+
21
+ ##
22
+ # This class represents a cart for Google Checkout. After initializing it
23
+ # with a +merchant_id+ and +merchant_key+, you add items via add_item,
24
+ # and can then get xml via to_xml, or html code for a form that
25
+ # provides a checkout button via checkout_button.
26
+ #
27
+ # Example:
28
+ #
29
+ # item = {
30
+ # :name => 'A Quarter',
31
+ # :description => 'One shiny quarter.',
32
+ # :price => 0.25
33
+ # }
34
+ # @cart = GoogleCheckout::Cart.new(merchant_id, merchant_key, item)
35
+ # @cart.add_item(:name => "Pancakes",
36
+ # :description => "Flapjacks by mail."
37
+ # :price => 0.50,
38
+ # :quantity => 10,
39
+ # "merchant-item-id" => '2938292839')
40
+ #
41
+ # Then in your view:
42
+ #
43
+ # Checkout here! <%= @cart.checkout_button %>
44
+ #
45
+ # This object is also useful for getting back a url to the image for a Google
46
+ # Checkout button. You can use this image in forms that submit back to your own
47
+ # server for further processing via Google Checkout's level 2 XML API.
48
+
49
+ class Cart < Command
50
+
51
+ include GoogleCheckout
52
+
53
+ SANDBOX_CHECKOUT_URL = "https://sandbox.google.com/checkout/cws/v2/Merchant/%s/checkout"
54
+ PRODUCTION_CHECKOUT_URL = "https://checkout.google.com/cws/v2/Merchant/%s/checkout"
55
+
56
+ ##
57
+ # You can provide extra data that will be sent to Google and returned with
58
+ # the NewOrderNotification.
59
+ #
60
+ # This should be a Hash and will be turned into XML with proper escapes.
61
+ #
62
+ # Beware using symbols as values. They may be set as sub-keys instead of values,
63
+ # so use a String or other datatype.
64
+
65
+ attr_accessor :merchant_private_data
66
+
67
+ attr_accessor :edit_cart_url
68
+ attr_accessor :continue_shopping_url
69
+
70
+ attr_accessor :merchant_calculations_url
71
+
72
+ attr_accessor :shipping_methods
73
+
74
+ # The default options for drawing in the button that are filled in when
75
+ # checkout_button or button_url is called.
76
+ DefaultButtonOpts = {
77
+ :size => :medium,
78
+ :style => 'white',
79
+ :variant => 'text',
80
+ :loc => 'en_US',
81
+ :buy_or_checkout => nil,
82
+ }
83
+
84
+ # You need to supply, as strings, the +merchant_id+ and +merchant_key+
85
+ # used to identify your store to Google. You may optionally supply one
86
+ # or more items to put inside the cart.
87
+ def initialize(merchant_id, merchant_key, *items)
88
+ super(merchant_id, merchant_key)
89
+ @contents = []
90
+ @merchant_private_data = {}
91
+ @shipping_methods = []
92
+ items.each { |i| add_item i }
93
+ end
94
+
95
+ def empty?
96
+ @contents.empty?
97
+ end
98
+
99
+ # Number of items in the cart.
100
+ def size
101
+ @contents.size
102
+ end
103
+
104
+ def submit_domain
105
+ (GoogleCheckout.production? ? 'checkout' : 'sandbox') + ".google.com"
106
+ end
107
+
108
+ ##
109
+ # The Google Checkout form submission url.
110
+
111
+ def submit_url
112
+ GoogleCheckout.sandbox? ? (SANDBOX_CHECKOUT_URL % @merchant_id) : (PRODUCTION_CHECKOUT_URL % @merchant_id)
113
+ end
114
+
115
+ # This method puts items in the cart.
116
+ # +item+ may be a hash, or have a method named +to_google_product+ that
117
+ # returns a hash with the required values.
118
+ # * name
119
+ # * description (a brief description as it will appear on the bill)
120
+ # * price
121
+ # You may fill in some optional values as well:
122
+ # * quantity (defaults to 1)
123
+ # * currency (defaults to 'USD')
124
+ def add_item(item)
125
+ @xml = nil
126
+ if item.respond_to? :to_google_product
127
+ item = item.to_google_product
128
+ end
129
+
130
+ # We need to check that the necessary keys are in the hash,
131
+ # Otherwise the error will happen in the middle of to_xml,
132
+ # and the bug will be harder to track.
133
+ missing_keys = [ :name, :description, :price ].select { |key|
134
+ !item.include? key
135
+ }
136
+
137
+ unless missing_keys.empty?
138
+ raise ArgumentError,
139
+ "Required keys missing: #{missing_keys.inspect}"
140
+ end
141
+
142
+ @contents << { :quantity => 1, :currency => 'USD' }.merge(item)
143
+ item
144
+ end
145
+
146
+ # This is the important method; it generatest the XML call.
147
+ # It's fairly lengthy, but trivial. It follows the docs at
148
+ # http://code.google.com/apis/checkout/developer/index.html#checkout_api
149
+ #
150
+ # It returns the raw XML string, not encoded.
151
+ def to_xml
152
+ raise RuntimeError, "Empty cart" if self.empty?
153
+
154
+ xml = Builder::XmlMarkup.new
155
+ xml.instruct!
156
+ @xml = xml.tag!('checkout-shopping-cart', :xmlns => "http://checkout.google.com/schema/2") {
157
+ xml.tag!("shopping-cart") {
158
+ xml.items {
159
+ @contents.each { |item|
160
+ xml.item {
161
+ if item.key?(:item_id)
162
+ xml.tag!('merchant-item-id', item[:item_id])
163
+ end
164
+ xml.tag!('item-name') {
165
+ xml.text! item[:name].to_s
166
+ }
167
+ xml.tag!('item-description') {
168
+ xml.text! item[:description].to_s
169
+ }
170
+ xml.tag!('unit-price', :currency => (item[:currency] || 'USD')) {
171
+ xml.text! item[:price].to_s
172
+ }
173
+ xml.quantity {
174
+ xml.text! item[:quantity].to_s
175
+ }
176
+ xml.tag!('merchant-private-item-data') {
177
+ xml << item[:merchant_private_item_data]
178
+ } if item.key?(:merchant_private_item_data)
179
+ }
180
+ }
181
+ }
182
+ unless @merchant_private_data.empty?
183
+ xml.tag!("merchant-private-data") {
184
+ @merchant_private_data.each do |key, value|
185
+ xml.tag!(key, value)
186
+ end
187
+ }
188
+ end
189
+ }
190
+ xml.tag!('checkout-flow-support') {
191
+ xml.tag!('merchant-checkout-flow-support') {
192
+ xml.tag!('edit-cart-url', @edit_cart_url) if @edit_cart_url
193
+ xml.tag!('continue-shopping-url', @continue_shopping_url) if @continue_shopping_url
194
+ xml.tag!("request-buyer-phone-number", false)
195
+ xml.tag!('merchant-calculations') {
196
+ xml.tag!('merchant-calculations-url') {
197
+ xml.text! @merchant_calculations_url
198
+ }
199
+ } if @merchant_calculations_url
200
+
201
+ # TODO tax-tables
202
+ xml.tag!("tax-tables") {
203
+ xml.tag!("default-tax-table") {
204
+ xml.tag!("tax-rules") {
205
+ xml.tag!("default-tax-rule") {
206
+ xml.tag!("shipping-taxed", false)
207
+ xml.tag!("rate", "0.00")
208
+ xml.tag!("tax-area") {
209
+ xml.tag!("world-area")
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ xml.tag!('shipping-methods') {
217
+ @shipping_methods.each do |shipping_method|
218
+ xml << shipping_method.to_xml
219
+ end
220
+ }
221
+ }
222
+ }
223
+ }
224
+ @xml.dup
225
+ end
226
+
227
+ # Returns the signature for the cart XML.
228
+ def signature
229
+ @xml or to_xml
230
+ HMAC::SHA1.digest(@merchant_key, @xml)
231
+ end
232
+
233
+ # Returns HTML for a checkout form for buying all the items in the
234
+ # cart.
235
+ def checkout_button(button_opts = {})
236
+ @xml or to_xml
237
+ burl = button_url(button_opts)
238
+ html = Builder::XmlMarkup.new(:indent => 2)
239
+ html.form({
240
+ :action => submit_url,
241
+ :style => 'border: 0;',
242
+ :id => 'BB_BuyButtonForm',
243
+ :method => 'post',
244
+ :name => 'BB_BuyButtonForm'
245
+ }) do
246
+ html.input({
247
+ :name => 'cart',
248
+ :type => 'hidden',
249
+ :value => Base64.encode64(@xml).gsub("\n", '')
250
+ })
251
+ html.input({
252
+ :name => 'signature',
253
+ :type => 'hidden',
254
+ :value => Base64.encode64(signature).gsub("\n", '')
255
+ })
256
+ html.input({
257
+ :alt => 'Google Checkout',
258
+ :style => "width: auto;",
259
+ :src => button_url(button_opts),
260
+ :type => 'image'
261
+ })
262
+ end
263
+ end
264
+
265
+ # Given a set of options for the button, button_url returns the URL
266
+ # for the button image.
267
+ # The options are the same as those specified on
268
+ # http://checkout.google.com/seller/checkout_buttons.html , with a
269
+ # couple of extra options for convenience. Rather than specifying the
270
+ # width and height manually, you may specify :size to be one of :small,
271
+ # :medium, or :large, and that you may set :buy_or_checkout to :buy_now
272
+ # or :checkout to get a 'Buy Now' button versus a 'Checkout' button. If
273
+ # you don't specify :buy_or_checkout, the Cart will try to guess based
274
+ # on if the cart has more than one item in it. Whatever you don't pass
275
+ # will be filled in with the defaults from DefaultButtonOpts.
276
+ #
277
+ # http://checkout.google.com/buttons/checkout.gif
278
+ # http://sandbox.google.com/checkout/buttons/checkout.gif
279
+
280
+ def button_url(opts = {})
281
+ opts = DefaultButtonOpts.merge opts
282
+ opts[:buy_or_checkout] ||= @contents.size > 1 ? :checkout : :buy_now
283
+ opts.merge! ButtonSizes[opts[:buy_or_checkout]][opts[:size]]
284
+ bname = opts[:buy_or_checkout] == :buy_now ? 'buy.gif' : 'checkout.gif'
285
+ opts.delete :size
286
+ opts.delete :buy_or_checkout
287
+ opts[:merchant_id] = @merchant_id
288
+
289
+ path = opts.map { |k,v| "#{k}=#{v}" }.join('&')
290
+
291
+ # HACK Sandbox graphics are in the checkout subdirectory
292
+ subdir = ""
293
+ if GoogleCheckout.sandbox? && bname == "checkout.gif"
294
+ subdir = "checkout/"
295
+ end
296
+
297
+ # TODO Use /checkout/buttons/checkout.gif if in sandbox.
298
+ "http://#{submit_domain}/#{ subdir }buttons/#{bname}?#{path}"
299
+ end
300
+ end
301
+
302
+ end
@@ -0,0 +1,191 @@
1
+
2
+ # TODO
3
+ #
4
+ # * Use standard ssl certs
5
+
6
+ module GoogleCheckout
7
+
8
+ ##
9
+ # Abstract class for commands.
10
+ #
11
+ # https://sandbox.google.com/checkout/cws/v2/Merchant/1234567890/request
12
+ # https://checkout.google.com/cws/v2/Merchant/1234567890/request
13
+
14
+ class Command
15
+
16
+ attr_accessor :merchant_id, :merchant_key, :currency
17
+
18
+ SANDBOX_REQUEST_URL = "https://sandbox.google.com/checkout/cws/v2/Merchant/%s/request"
19
+ PRODUCTION_REQUEST_URL = "https://checkout.google.com/cws/v2/Merchant/%s/request"
20
+
21
+ def initialize(merchant_id, merchant_key)
22
+ @merchant_id = merchant_id
23
+ @merchant_key = merchant_key
24
+
25
+ @currency = "USD"
26
+ end
27
+
28
+ ##
29
+ # Returns the appropriate sandbox or production url for posting API requests.
30
+
31
+ def url
32
+ GoogleCheckout.sandbox? ? (SANDBOX_REQUEST_URL % @merchant_id) : (PRODUCTION_REQUEST_URL % @merchant_id)
33
+ end
34
+
35
+ ##
36
+ # Sends the Command's XML to GoogleCheckout via HTTPS with Basic Auth.
37
+ #
38
+ # Returns a GoogleCheckout::RequestReceived or a GoogleCheckout::Error object.
39
+
40
+ def post
41
+ # Create HTTP(S) POST command and set up Basic Authentication.
42
+ uri = URI.parse(url)
43
+
44
+ request = Net::HTTP::Post.new(uri.path)
45
+ request.basic_auth(@merchant_id, @merchant_key)
46
+
47
+ # Set up the HTTP connection object and the SSL layer.
48
+ https = Net::HTTP.new(uri.host, uri.port)
49
+ https.use_ssl = true
50
+ https.cert_store = self.class.x509_store
51
+ https.verify_mode = OpenSSL::SSL::VERIFY_PEER
52
+ https.verify_depth = 5
53
+
54
+ # Send the request to Google.
55
+ response = https.request(request, self.to_xml)
56
+
57
+ # NOTE Because Notification.parse() is used, the content of objects
58
+ # will be correctly parsed no matter what the HTTP response code
59
+ # is from the server.
60
+ case response
61
+ when Net::HTTPSuccess, Net::HTTPClientError
62
+ notification = Notification.parse(response.body)
63
+ if notification.error?
64
+ raise APIError, "#{notification.message} [in #{GoogleCheckout.production? ? 'production' : 'sandbox' }]"
65
+ end
66
+ return notification
67
+ when Net::HTTPRedirection, Net::HTTPServerError, Net::HTTPInformation
68
+ raise "Unexpected response code (#{response.class}): #{response.code} - #{response.message}"
69
+ else
70
+ raise "Unknown response code: #{response.code} - #{response.message}"
71
+ end
72
+ end
73
+
74
+ ##
75
+ # Class method to return the OpenSSL::X509::Store instance for the
76
+ # CA certificates.
77
+ #
78
+ # NOTE May not be thread-safe.
79
+
80
+ def self.x509_store
81
+ return @@x509_store if defined?(@@x509_store)
82
+
83
+ cacert_path = File.expand_path(File.dirname(__FILE__) + '/../../support/cacert.pem')
84
+
85
+ @@x509_store = OpenSSL::X509::Store.new
86
+ @@x509_store.add_file(cacert_path)
87
+
88
+ return @@x509_store
89
+ end
90
+
91
+ end
92
+
93
+ ##
94
+ # Abstract class for all commands associated with an existing order.
95
+
96
+ class OrderCommand < Command
97
+
98
+ attr_accessor :google_order_number, :amount
99
+
100
+ ##
101
+ # Make a new object. Last argument is the Google's order number as received
102
+ # in the NewOrderNotification.
103
+
104
+ def initialize(merchant_id, merchant_key, google_order_number)
105
+ # TODO raise "Not an order number!" unless google_order_number.is_a? String
106
+ super(merchant_id, merchant_key)
107
+ @google_order_number = google_order_number
108
+ @amount = 0.00
109
+ end
110
+
111
+ end
112
+
113
+ ##
114
+ # Create a new ChargeOrder object, set the +amount+, then
115
+ # +post+ it.
116
+
117
+ class ChargeOrder < OrderCommand
118
+
119
+ def to_xml
120
+ raise "Charge amount must be greater than 0!" unless @amount.to_f > 0.0
121
+
122
+ xml = Builder::XmlMarkup.new
123
+ xml.instruct!
124
+ @xml = xml.tag!('charge-order', {
125
+ :xmlns => "http://checkout.google.com/schema/2",
126
+ "google-order-number" => @google_order_number
127
+ }) do
128
+ xml.tag!("amount", @amount, {:currency => @currency})
129
+ end
130
+ @xml
131
+ end
132
+
133
+ end
134
+
135
+ ##
136
+ # Tells Google that the order has shipped.
137
+
138
+ class DeliverOrder < OrderCommand
139
+
140
+ def to_xml
141
+
142
+ xml = Builder::XmlMarkup.new
143
+ xml.instruct!
144
+ @xml = xml.tag!('deliver-order', {
145
+ :xmlns => "http://checkout.google.com/schema/2",
146
+ "google-order-number" => @google_order_number
147
+ }) do
148
+ xml.tag!("send-email", false)
149
+ end
150
+ @xml
151
+ end
152
+
153
+ end
154
+
155
+ ##
156
+ # Send a message to the buyer associated with an order.
157
+ #
158
+ # Google will actually send the message to their email address.
159
+
160
+ class SendBuyerMessage < OrderCommand
161
+
162
+ ##
163
+ # Make a new message to send.
164
+ #
165
+ # The last argument is the actual message.
166
+ #
167
+ # Call +post+ on the resulting object to submit it to Google for sending.
168
+
169
+ def initialize(merchant_id, merchant_key, google_order_number, message)
170
+ # TODO Raise meaninful error if message is longer than 255 characters
171
+ raise "Google won't send anything longer than 255 characters! Sorry!" if message.length > 255
172
+ @message = message
173
+ super(merchant_id, merchant_key, google_order_number)
174
+ end
175
+
176
+ def to_xml # :nodoc:
177
+ xml = Builder::XmlMarkup.new
178
+ xml.instruct!
179
+ @xml = xml.tag!('send-buyer-message', {
180
+ :xmlns => "http://checkout.google.com/schema/2",
181
+ "google-order-number" => @google_order_number
182
+ }) do
183
+ xml.tag!("message", @message)
184
+ xml.tag!("send-email", true)
185
+ end
186
+ @xml
187
+ end
188
+
189
+ end
190
+
191
+ end