folked-venice 0.5.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +6 -0
- data/LICENSE +19 -0
- data/README.md +99 -0
- data/Rakefile +6 -0
- data/bin/iap +64 -0
- data/daiki44.gemspec +27 -0
- data/lib/venice.rb +5 -0
- data/lib/venice/client.rb +116 -0
- data/lib/venice/in_app_receipt.rb +121 -0
- data/lib/venice/pending_renewal_info.rb +68 -0
- data/lib/venice/receipt.rb +186 -0
- data/lib/venice/version.rb +3 -0
- data/spec/client_spec.rb +169 -0
- data/spec/in_app_receipt_spec.rb +67 -0
- data/spec/pending_renewal_info_spec.rb +39 -0
- data/spec/receipt +1 -0
- data/spec/receipt_spec.rb +191 -0
- data/spec/spec_helper.rb +18 -0
- metadata +165 -0
@@ -0,0 +1,68 @@
|
|
1
|
+
module Venice
|
2
|
+
class PendingRenewalInfo
|
3
|
+
# Original JSON data returned from Apple for a PendingRenewalInfo object.
|
4
|
+
attr_reader :original_json_data
|
5
|
+
|
6
|
+
# For an expired subscription, the reason for the subscription expiration.
|
7
|
+
# This key is only present for a receipt containing an expired auto-renewable subscription.
|
8
|
+
attr_reader :expiration_intent
|
9
|
+
|
10
|
+
# The current renewal status for the auto-renewable subscription.
|
11
|
+
# This key is only present for auto-renewable subscription receipts, for active or expired subscriptions
|
12
|
+
attr_reader :auto_renew_status
|
13
|
+
|
14
|
+
# The current renewal preference for the auto-renewable subscription.
|
15
|
+
# The value for this key corresponds to the productIdentifier property of the product that the customer’s subscription renews.
|
16
|
+
attr_reader :auto_renew_product_id
|
17
|
+
|
18
|
+
# For an expired subscription, whether or not Apple is still attempting to automatically renew the subscription.
|
19
|
+
# If the customer’s subscription failed to renew because the App Store was unable to complete the transaction, this value will reflect whether or not the App Store is still trying to renew the subscription.
|
20
|
+
attr_reader :is_in_billing_retry_period
|
21
|
+
|
22
|
+
# The product identifier of the item that was purchased.
|
23
|
+
# This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property.
|
24
|
+
attr_reader :product_id
|
25
|
+
|
26
|
+
# The current price consent status for a subscription price increase
|
27
|
+
# This key is only present for auto-renewable subscription receipts if the subscription price was increased without keeping the existing price for active subscribers
|
28
|
+
attr_reader :price_consent_status
|
29
|
+
|
30
|
+
# For a transaction that was cancelled, the reason for cancellation.
|
31
|
+
# Use this value along with the cancellation date to identify possible issues in your app that may lead customers to contact Apple customer support.
|
32
|
+
attr_reader :cancellation_reason
|
33
|
+
|
34
|
+
def initialize(attributes)
|
35
|
+
@original_json_data = attributes
|
36
|
+
@expiration_intent = Integer(attributes['expiration_intent']) if attributes['expiration_intent']
|
37
|
+
@auto_renew_status = Integer(attributes['auto_renew_status']) if attributes['auto_renew_status']
|
38
|
+
@auto_renew_product_id = attributes['auto_renew_product_id']
|
39
|
+
|
40
|
+
if attributes['is_in_billing_retry_period']
|
41
|
+
@is_in_billing_retry_period = (attributes['is_in_billing_retry_period'] == '1')
|
42
|
+
end
|
43
|
+
|
44
|
+
@product_id = attributes['product_id']
|
45
|
+
|
46
|
+
@price_consent_status = Integer(attributes['price_consent_status']) if attributes['price_consent_status']
|
47
|
+
@cancellation_reason = Integer(attributes['cancellation_reason']) if attributes['cancellation_reason']
|
48
|
+
end
|
49
|
+
|
50
|
+
def to_hash
|
51
|
+
{
|
52
|
+
expiration_intent: @expiration_intent,
|
53
|
+
auto_renew_status: @auto_renew_status,
|
54
|
+
auto_renew_product_id: @auto_renew_product_id,
|
55
|
+
is_in_billing_retry_period: @is_in_billing_retry_period,
|
56
|
+
product_id: @product_id,
|
57
|
+
price_consent_status: @price_consent_status,
|
58
|
+
cancellation_reason: @cancellation_reason
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
alias_method :to_h, :to_hash
|
63
|
+
|
64
|
+
def to_json
|
65
|
+
to_hash.to_json
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module Venice
|
4
|
+
class Receipt
|
5
|
+
MAX_RE_VERIFY_COUNT = 3
|
6
|
+
|
7
|
+
# For detailed explanations on these keys/values, see
|
8
|
+
# https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1
|
9
|
+
|
10
|
+
# The app’s bundle identifier.
|
11
|
+
attr_reader :bundle_id
|
12
|
+
|
13
|
+
# The app’s version number.
|
14
|
+
attr_reader :application_version
|
15
|
+
|
16
|
+
# The receipt for an in-app purchase.
|
17
|
+
attr_reader :in_app
|
18
|
+
|
19
|
+
# The version of the app that was originally purchased.
|
20
|
+
attr_reader :original_application_version
|
21
|
+
|
22
|
+
# The original purchase date
|
23
|
+
attr_reader :original_purchase_date
|
24
|
+
|
25
|
+
# The date that the app receipt expires.
|
26
|
+
attr_reader :expires_at
|
27
|
+
|
28
|
+
# Non-Documented receipt keys/values
|
29
|
+
attr_reader :receipt_type
|
30
|
+
attr_reader :adam_id
|
31
|
+
attr_reader :download_id
|
32
|
+
attr_reader :requested_at
|
33
|
+
attr_reader :receipt_created_at
|
34
|
+
|
35
|
+
# Original json response from AppStore
|
36
|
+
attr_reader :original_json_response
|
37
|
+
|
38
|
+
attr_accessor :latest_receipt_info
|
39
|
+
|
40
|
+
# Information about the status of the customer's auto-renewable subscriptions
|
41
|
+
attr_reader :pending_renewal_info
|
42
|
+
|
43
|
+
def initialize(attributes = {})
|
44
|
+
@original_json_response = attributes['original_json_response']
|
45
|
+
|
46
|
+
@bundle_id = attributes['bundle_id']
|
47
|
+
@application_version = attributes['application_version']
|
48
|
+
@original_application_version = attributes['original_application_version']
|
49
|
+
if attributes['original_purchase_date']
|
50
|
+
@original_purchase_date = DateTime.parse(attributes['original_purchase_date'])
|
51
|
+
end
|
52
|
+
if attributes['expiration_date']
|
53
|
+
@expires_at = Time.at(attributes['expiration_date'].to_i / 1000).to_datetime
|
54
|
+
end
|
55
|
+
|
56
|
+
@receipt_type = attributes['receipt_type']
|
57
|
+
@adam_id = attributes['adam_id']
|
58
|
+
@download_id = attributes['download_id']
|
59
|
+
@requested_at = DateTime.parse(attributes['request_date']) if attributes['request_date']
|
60
|
+
@receipt_created_at = DateTime.parse(attributes['receipt_creation_date']) if attributes['receipt_creation_date']
|
61
|
+
|
62
|
+
@in_app = []
|
63
|
+
if attributes['in_app']
|
64
|
+
attributes['in_app'].each do |in_app_purchase_attributes|
|
65
|
+
@in_app << InAppReceipt.new(in_app_purchase_attributes)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
@pending_renewal_info = []
|
70
|
+
if original_json_response && original_json_response['pending_renewal_info']
|
71
|
+
original_json_response['pending_renewal_info'].each do |pending_renewal_attributes|
|
72
|
+
@pending_renewal_info << PendingRenewalInfo.new(pending_renewal_attributes)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def to_hash
|
78
|
+
{
|
79
|
+
bundle_id: @bundle_id,
|
80
|
+
application_version: @application_version,
|
81
|
+
original_application_version: @original_application_version,
|
82
|
+
original_purchase_date: (@original_purchase_date.httpdate rescue nil),
|
83
|
+
expires_at: (@expires_at.httpdate rescue nil),
|
84
|
+
receipt_type: @receipt_type,
|
85
|
+
adam_id: @adam_id,
|
86
|
+
download_id: @download_id,
|
87
|
+
requested_at: (@requested_at.httpdate rescue nil),
|
88
|
+
receipt_created_at: (@receipt_created_at.httpdate rescue nil),
|
89
|
+
in_app: @in_app.map(&:to_h),
|
90
|
+
pending_renewal_info: @pending_renewal_info.map(&:to_h),
|
91
|
+
latest_receipt_info: @latest_receipt_info
|
92
|
+
}
|
93
|
+
end
|
94
|
+
alias_method :to_h, :to_hash
|
95
|
+
|
96
|
+
def to_json
|
97
|
+
to_hash.to_json
|
98
|
+
end
|
99
|
+
|
100
|
+
class << self
|
101
|
+
def verify(data, options = {})
|
102
|
+
verify!(data, options)
|
103
|
+
rescue VerificationError, Client::TimeoutError
|
104
|
+
false
|
105
|
+
end
|
106
|
+
|
107
|
+
def verify!(data, options = {})
|
108
|
+
client = Client.production
|
109
|
+
|
110
|
+
retry_count = 0
|
111
|
+
begin
|
112
|
+
client.verify!(data, options)
|
113
|
+
rescue VerificationError => error
|
114
|
+
case error.code
|
115
|
+
when 21007
|
116
|
+
client = Client.development
|
117
|
+
retry
|
118
|
+
when 21008
|
119
|
+
client = Client.production
|
120
|
+
retry
|
121
|
+
else
|
122
|
+
retry_count += 1
|
123
|
+
if error.retryable? && retry_count <= MAX_RE_VERIFY_COUNT
|
124
|
+
retry
|
125
|
+
end
|
126
|
+
|
127
|
+
raise error
|
128
|
+
end
|
129
|
+
rescue Net::ReadTimeout, Timeout::Error, OpenSSL::SSL::SSLError,
|
130
|
+
Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE
|
131
|
+
# verifyReceipt is idempotent so we can retry it.
|
132
|
+
# Net::Http has retry logic for some idempotent http methods but it verifyReceipt is POST.
|
133
|
+
retry_count += 1
|
134
|
+
retry if retry_count <= MAX_RE_VERIFY_COUNT
|
135
|
+
raise
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
alias :validate :verify
|
140
|
+
alias :validate! :verify!
|
141
|
+
end
|
142
|
+
|
143
|
+
class VerificationError < StandardError
|
144
|
+
attr_accessor :json
|
145
|
+
|
146
|
+
def initialize(json)
|
147
|
+
@json = json
|
148
|
+
end
|
149
|
+
|
150
|
+
def code
|
151
|
+
Integer(json['status'])
|
152
|
+
end
|
153
|
+
|
154
|
+
def retryable?
|
155
|
+
json['is_retryable']
|
156
|
+
end
|
157
|
+
|
158
|
+
def message
|
159
|
+
case code
|
160
|
+
when 21000
|
161
|
+
'The App Store could not read the JSON object you provided.'
|
162
|
+
when 21002
|
163
|
+
'The data in the receipt-data property was malformed.'
|
164
|
+
when 21003
|
165
|
+
'The receipt could not be authenticated.'
|
166
|
+
when 21004
|
167
|
+
'The shared secret you provided does not match the shared secret on file for your account.'
|
168
|
+
when 21005
|
169
|
+
'The receipt server is not currently available.'
|
170
|
+
when 21006
|
171
|
+
'This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response.'
|
172
|
+
when 21007
|
173
|
+
'This receipt is a sandbox receipt, but it was sent to the production service for verification.'
|
174
|
+
when 21008
|
175
|
+
'This receipt is a production receipt, but it was sent to the sandbox service for verification.'
|
176
|
+
when 21010
|
177
|
+
'This receipt could not be authorized. Treat this the same as if a purchase was never made.'
|
178
|
+
when 21100..21199
|
179
|
+
'Internal data access error.'
|
180
|
+
else
|
181
|
+
"Unknown Error: #{code}"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Venice::Client do
|
4
|
+
let(:receipt_data) { 'asdfzxcvjklqwer' }
|
5
|
+
let(:client) { subject }
|
6
|
+
|
7
|
+
describe '#verify!' do
|
8
|
+
context 'with a receipt response' do
|
9
|
+
before do
|
10
|
+
client.stub(:json_response_from_verifying_data).and_return(response)
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:response) do
|
14
|
+
{
|
15
|
+
'status' => 0,
|
16
|
+
'receipt' => {}
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'does not generate a self-referencing Hash' do
|
21
|
+
receipt = client.verify! 'asdf'
|
22
|
+
expect(receipt.original_json_response['receipt']).not_to have_key('original_json_response')
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'no shared_secret' do
|
27
|
+
before do
|
28
|
+
client.shared_secret = nil
|
29
|
+
Venice::Receipt.stub :new
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'should only include the receipt_data' do
|
33
|
+
Net::HTTP.any_instance.should_receive(:request) do |post|
|
34
|
+
post.body.should eq({ 'receipt-data' => receipt_data }.to_json)
|
35
|
+
post
|
36
|
+
end
|
37
|
+
client.verify! receipt_data
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context 'with a shared secret' do
|
42
|
+
let(:secret) { 'shhhhhh' }
|
43
|
+
|
44
|
+
before do
|
45
|
+
Venice::Receipt.stub :new
|
46
|
+
end
|
47
|
+
|
48
|
+
context 'set secret manually' do
|
49
|
+
before do
|
50
|
+
client.shared_secret = secret
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'should include the secret in the post' do
|
54
|
+
Net::HTTP.any_instance.should_receive(:request) do |post|
|
55
|
+
post.body.should eq({ 'receipt-data' => receipt_data, 'password' => secret }.to_json)
|
56
|
+
post
|
57
|
+
end
|
58
|
+
client.verify! receipt_data
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'set secret when verification' do
|
63
|
+
let(:options) { { shared_secret: secret } }
|
64
|
+
|
65
|
+
it 'should include the secret in the post' do
|
66
|
+
Net::HTTP.any_instance.should_receive(:request) do |post|
|
67
|
+
post.body.should eq({ 'receipt-data' => receipt_data, 'password' => secret }.to_json)
|
68
|
+
post
|
69
|
+
end
|
70
|
+
client.verify! receipt_data, options
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
context 'with a latest receipt info attribute' do
|
76
|
+
let(:response) do
|
77
|
+
{
|
78
|
+
'status' => 0,
|
79
|
+
'receipt' => {},
|
80
|
+
'latest_receipt' => '<encoded string>',
|
81
|
+
'latest_receipt_info' => [
|
82
|
+
{
|
83
|
+
'original_purchase_date_pst' => '2012-12-30 09:39:24 America/Los_Angeles',
|
84
|
+
'unique_identifier' => '0000b01147b8',
|
85
|
+
'original_transaction_id' => '1000000061051565',
|
86
|
+
'expires_date' => '1365114731000',
|
87
|
+
'transaction_id' => '1000000070104252',
|
88
|
+
'quantity' => '1',
|
89
|
+
'product_id' => 'com.ficklebits.nsscreencast.monthly_sub',
|
90
|
+
'original_purchase_date_ms' => '1356889164000',
|
91
|
+
'bid' => 'com.ficklebits.nsscreencast',
|
92
|
+
'web_order_line_item_id' => '1000000026812043',
|
93
|
+
'bvrs' => '0.1',
|
94
|
+
'expires_date_formatted' => '2013-04-04 22:32:11 Etc/GMT',
|
95
|
+
'purchase_date' => '2013-04-04 22:27:11 Etc/GMT',
|
96
|
+
'purchase_date_ms' => '1365114431000',
|
97
|
+
'expires_date_formatted_pst' => '2013-04-04 15:32:11 America/Los_Angeles',
|
98
|
+
'purchase_date_pst' => '2013-04-04 15:27:11 America/Los_Angeles',
|
99
|
+
'original_purchase_date' => '2012-12-30 17:39:24 Etc/GMT',
|
100
|
+
'item_id' => '590265423'
|
101
|
+
}
|
102
|
+
]
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
it 'should create a latest receipt' do
|
107
|
+
client.stub(:json_response_from_verifying_data).and_return(response)
|
108
|
+
receipt = client.verify! 'asdf'
|
109
|
+
receipt.latest_receipt_info.should_not be_nil
|
110
|
+
receipt.latest_receipt_info.first.product_id.should eq 'com.ficklebits.nsscreencast.monthly_sub'
|
111
|
+
end
|
112
|
+
|
113
|
+
context 'when latest_receipt_info is a hash instead of an array' do
|
114
|
+
it 'should still create a latest receipt' do
|
115
|
+
response['latest_receipt_info'] = response['latest_receipt_info'].first
|
116
|
+
client.stub(:json_response_from_verifying_data).and_return(response)
|
117
|
+
receipt = client.verify! 'asdf'
|
118
|
+
receipt.latest_receipt_info.should_not be_nil
|
119
|
+
receipt.latest_receipt_info.first.product_id.should eq 'com.ficklebits.nsscreencast.monthly_sub'
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
context 'with an error response' do
|
125
|
+
before do
|
126
|
+
client.stub(:json_response_from_verifying_data).and_return(response)
|
127
|
+
end
|
128
|
+
|
129
|
+
let(:response) do
|
130
|
+
{
|
131
|
+
'status' => 21000,
|
132
|
+
'receipt' => {}
|
133
|
+
}
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'raises a VerificationError' do
|
137
|
+
expect do
|
138
|
+
client.verify!('asdf')
|
139
|
+
end.to raise_error(Venice::Receipt::VerificationError) do |error|
|
140
|
+
expect(error.json).to eq(response)
|
141
|
+
expect(error.code).to eq(21000)
|
142
|
+
expect(error).not_to be_retryable
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
context 'with a retryable error response' do
|
148
|
+
before do
|
149
|
+
client.stub(:json_response_from_verifying_data).and_return(response)
|
150
|
+
end
|
151
|
+
|
152
|
+
let(:response) do
|
153
|
+
{
|
154
|
+
'status' => 21000,
|
155
|
+
'receipt' => {},
|
156
|
+
'is_retryable' => true
|
157
|
+
}
|
158
|
+
end
|
159
|
+
|
160
|
+
it 'raises a VerificationError' do
|
161
|
+
expect do
|
162
|
+
client.verify!('asdf')
|
163
|
+
end.to raise_error(Venice::Receipt::VerificationError) do |error|
|
164
|
+
expect(error).to be_retryable
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'spec_helper.rb'
|
2
|
+
|
3
|
+
describe Venice::InAppReceipt do
|
4
|
+
describe '.new' do
|
5
|
+
let :attributes do
|
6
|
+
{
|
7
|
+
'quantity' => 1,
|
8
|
+
'product_id' => 'com.foo.product1',
|
9
|
+
'transaction_id' => '1000000070107235',
|
10
|
+
'web_order_line_item_id' => '1000000026812043',
|
11
|
+
'purchase_date' => '2014-05-28 14:47:53 Etc/GMT',
|
12
|
+
'purchase_date_ms' => '1401288473000',
|
13
|
+
'purchase_date_pst' => '2014-05-28 07:47:53 America/Los_Angeles',
|
14
|
+
'original_transaction_id' => '140xxx867509',
|
15
|
+
'original_purchase_date' => '2014-05-28 14:47:53 Etc/GMT',
|
16
|
+
'original_purchase_date_ms' => '1401288473000',
|
17
|
+
'original_purchase_date_pst' => '2014-05-28 07:47:53 America/Los_Angeles',
|
18
|
+
'is_trial_period' => 'false',
|
19
|
+
'is_in_intro_offer_period' => 'true',
|
20
|
+
'version_external_identifier' => '123',
|
21
|
+
'app_item_id' => 'com.foo.app1',
|
22
|
+
'expires_date' => '2014-06-28 07:47:53 America/Los_Angeles',
|
23
|
+
'expires_date_ms' => '1403941673000'
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
subject(:in_app_receipt) do
|
28
|
+
Venice::InAppReceipt.new attributes
|
29
|
+
end
|
30
|
+
|
31
|
+
its(:quantity) { 1 }
|
32
|
+
its(:product_id) { 'com.foo.product1' }
|
33
|
+
its(:transaction_id) { '1000000070107235' }
|
34
|
+
its(:web_order_line_item_id) { '1000000026812043' }
|
35
|
+
its(:purchased_at) { should be_instance_of DateTime }
|
36
|
+
its(:app_item_id) { 'com.foo.app1' }
|
37
|
+
its(:version_external_identifier) { '123' }
|
38
|
+
its(:is_trial_period) { false }
|
39
|
+
its(:is_in_intro_offer_period) { true }
|
40
|
+
its(:original) { should be_instance_of Venice::InAppReceipt }
|
41
|
+
its(:expires_at) { should be_instance_of Time }
|
42
|
+
its(:original_json_data) { attributes }
|
43
|
+
|
44
|
+
it "should parse the 'original' attributes" do
|
45
|
+
subject.original.should be_instance_of Venice::InAppReceipt
|
46
|
+
subject.original.transaction_id.should == '140xxx867509'
|
47
|
+
subject.original.purchased_at.should be_instance_of DateTime
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'should parse expires_date when expires_date_ms is missing and expires_date is the ms epoch' do
|
51
|
+
attributes.delete('expires_date_ms')
|
52
|
+
attributes['expires_date'] = '1403941685000'
|
53
|
+
in_app_receipt.expires_at.should eq Time.at(1403941685000 / 1000)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should output a hash with attributes' do
|
57
|
+
in_app_receipt.to_h.should include(quantity: 1,
|
58
|
+
product_id: 'com.foo.product1',
|
59
|
+
transaction_id: '1000000070107235',
|
60
|
+
web_order_line_item_id: '1000000026812043',
|
61
|
+
is_trial_period: false,
|
62
|
+
is_in_intro_offer_period: true,
|
63
|
+
purchase_date: 'Wed, 28 May 2014 14:47:53 GMT',
|
64
|
+
original_purchase_date: 'Wed, 28 May 2014 14:47:53 GMT')
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|