candy_check 0.1.0.pre → 0.3.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 (76) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +23 -0
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +7 -8
  5. data/Guardfile +42 -0
  6. data/MIGRATION_GUIDE_0_2_0.md +141 -0
  7. data/README.md +86 -26
  8. data/Rakefile +1 -1
  9. data/candy_check.gemspec +33 -25
  10. data/lib/candy_check/app_store/receipt_collection.rb +5 -3
  11. data/lib/candy_check/app_store/subscription_verification.rb +25 -1
  12. data/lib/candy_check/app_store/verification.rb +1 -1
  13. data/lib/candy_check/app_store/verifier.rb +11 -11
  14. data/lib/candy_check/cli/app.rb +16 -33
  15. data/lib/candy_check/cli/commands/play_store.rb +12 -13
  16. data/lib/candy_check/play_store.rb +20 -10
  17. data/lib/candy_check/play_store/acknowledger.rb +19 -0
  18. data/lib/candy_check/play_store/android_publisher_service.rb +6 -0
  19. data/lib/candy_check/play_store/product_acknowledgements/acknowledgement.rb +45 -0
  20. data/lib/candy_check/play_store/product_acknowledgements/response.rb +24 -0
  21. data/lib/candy_check/play_store/product_purchases/product_purchase.rb +90 -0
  22. data/lib/candy_check/play_store/product_purchases/product_verification.rb +53 -0
  23. data/lib/candy_check/play_store/subscription_purchases/subscription_purchase.rb +154 -0
  24. data/lib/candy_check/play_store/subscription_purchases/subscription_verification.rb +55 -0
  25. data/lib/candy_check/play_store/verification_failure.rb +8 -6
  26. data/lib/candy_check/play_store/verifier.rb +24 -49
  27. data/lib/candy_check/utils/config.rb +5 -3
  28. data/lib/candy_check/version.rb +1 -1
  29. data/spec/app_store/receipt_collection_spec.rb +33 -0
  30. data/spec/app_store/subscription_verification_spec.rb +35 -2
  31. data/spec/app_store/verifier_spec.rb +24 -5
  32. data/spec/candy_check_spec.rb +2 -2
  33. data/spec/cli/commands/play_store_spec.rb +10 -43
  34. data/spec/fixtures/play_store/random_dummy_key.json +12 -0
  35. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/acknowledged.yml +105 -0
  36. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/already_acknowledged.yml +124 -0
  37. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/refunded.yml +122 -0
  38. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/permission_denied.yml +196 -0
  39. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/response_with_empty_body.yml +183 -0
  40. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/valid_but_not_consumed.yml +122 -0
  41. data/spec/fixtures/vcr_cassettes/play_store/subscription_purchases/permission_denied.yml +196 -0
  42. data/spec/fixtures/vcr_cassettes/play_store/subscription_purchases/valid_but_expired.yml +127 -0
  43. data/spec/play_store/acknowledger_spec.rb +48 -0
  44. data/spec/play_store/product_acknowledgements/acknowledgement_spec.rb +54 -0
  45. data/spec/play_store/product_acknowledgements/response_spec.rb +66 -0
  46. data/spec/play_store/product_purchases/product_purchase_spec.rb +110 -0
  47. data/spec/play_store/product_purchases/product_verification_spec.rb +49 -0
  48. data/spec/play_store/subscription_purchases/subscription_purchase_spec.rb +237 -0
  49. data/spec/play_store/subscription_purchases/subscription_verification_spec.rb +65 -0
  50. data/spec/play_store/verification_failure_spec.rb +18 -18
  51. data/spec/play_store/verifier_spec.rb +32 -96
  52. data/spec/spec_helper.rb +32 -10
  53. metadata +175 -75
  54. data/lib/candy_check/play_store/client.rb +0 -126
  55. data/lib/candy_check/play_store/config.rb +0 -51
  56. data/lib/candy_check/play_store/discovery_repository.rb +0 -33
  57. data/lib/candy_check/play_store/receipt.rb +0 -81
  58. data/lib/candy_check/play_store/subscription.rb +0 -138
  59. data/lib/candy_check/play_store/subscription_verification.rb +0 -30
  60. data/lib/candy_check/play_store/verification.rb +0 -48
  61. data/spec/fixtures/api_cache.dump +0 -1
  62. data/spec/fixtures/play_store/api_cache.dump +0 -1
  63. data/spec/fixtures/play_store/auth_failure.txt +0 -18
  64. data/spec/fixtures/play_store/auth_success.txt +0 -20
  65. data/spec/fixtures/play_store/discovery.txt +0 -2841
  66. data/spec/fixtures/play_store/dummy.p12 +0 -0
  67. data/spec/fixtures/play_store/empty.txt +0 -17
  68. data/spec/fixtures/play_store/products_failure.txt +0 -29
  69. data/spec/fixtures/play_store/products_success.txt +0 -22
  70. data/spec/play_store/client_spec.rb +0 -125
  71. data/spec/play_store/config_spec.rb +0 -96
  72. data/spec/play_store/discovery_respository_spec.rb +0 -31
  73. data/spec/play_store/receipt_spec.rb +0 -88
  74. data/spec/play_store/subscription_spec.rb +0 -138
  75. data/spec/play_store/subscription_verification_spec.rb +0 -98
  76. data/spec/play_store/verification_spec.rb +0 -82
@@ -0,0 +1,53 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ module ProductPurchases
4
+ # Verifies a purchase token against the PlayStore API
5
+ # The call return either a {ProductPurchase} or a {VerificationFailure}
6
+ class ProductVerification
7
+ # @return [String] the package_name which will be queried
8
+ attr_reader :package_name
9
+ # @return [String] the item id which will be queried
10
+ attr_reader :product_id
11
+ # @return [String] the token for authentication
12
+ attr_reader :token
13
+
14
+ # Initializes a new call to the API
15
+ # @param package_name [String]
16
+ # @param product_id [String]
17
+ # @param token [String]
18
+ def initialize(package_name:, product_id:, token:, authorization:)
19
+ @package_name = package_name
20
+ @product_id = product_id
21
+ @token = token
22
+ @authorization = authorization
23
+ end
24
+
25
+ # Performs the verification against the remote server
26
+ # @return [ProductPurchase] if successful
27
+ # @return [VerificationFailure] otherwise
28
+ def call!
29
+ verify!
30
+ if valid?
31
+ CandyCheck::PlayStore::ProductPurchases::ProductPurchase.new(@response[:result])
32
+ else
33
+ CandyCheck::PlayStore::VerificationFailure.new(@response[:error])
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def valid?
40
+ @response[:result] && @response[:result].purchase_state && @response[:result].consumption_state
41
+ end
42
+
43
+ def verify!
44
+ service = CandyCheck::PlayStore::AndroidPublisherService.new
45
+ service.authorization = @authorization
46
+ service.get_purchase_product(package_name, product_id, token) do |result, error|
47
+ @response = { result: result, error: error }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,154 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ module SubscriptionPurchases
4
+ # Describes a successfully validated subscription
5
+ class SubscriptionPurchase
6
+ include Utils::AttributeReader
7
+
8
+ # @return [Google::Apis::AndroidpublisherV3::SubscriptionPurchase] the raw subscription purchase from google-api-client
9
+ attr_reader :subscription_purchase
10
+
11
+ # The payment of the subscription is pending (paymentState)
12
+ PAYMENT_PENDING = 0
13
+ # The payment of the subscript is received (paymentState)
14
+ PAYMENT_RECEIVED = 1
15
+ # The subscription was canceled by the user (cancelReason)
16
+ PAYMENT_CANCELED = 0
17
+ # The payment failed during processing (cancelReason)
18
+ PAYMENT_FAILED = 1
19
+
20
+ # Initializes a new instance which bases on a JSON result
21
+ # from Google's servers
22
+ # @param subscription_purchase [Google::Apis::AndroidpublisherV3::SubscriptionPurchase]
23
+ def initialize(subscription_purchase)
24
+ @subscription_purchase = subscription_purchase
25
+ end
26
+
27
+ # Check if the expiration date is passed
28
+ # @return [bool]
29
+ def expired?
30
+ overdue_days > 0
31
+ end
32
+
33
+ # Check if in trial. This is actually not given by Google, but we assume
34
+ # that it is a trial going on if the paid amount is 0 and
35
+ # renewal is activated.
36
+ # @return [bool]
37
+ def trial?
38
+ price_is_zero = price_amount_micros == 0
39
+ price_is_zero && payment_received?
40
+ end
41
+
42
+ # see if payment is ok
43
+ # @return [bool]
44
+ def payment_received?
45
+ payment_state == PAYMENT_RECEIVED
46
+ end
47
+
48
+ # see if payment is pending
49
+ # @return [bool]
50
+ def payment_pending?
51
+ payment_state == PAYMENT_PENDING
52
+ end
53
+
54
+ # see if payment has failed according to Google
55
+ # @return [bool]
56
+ def payment_failed?
57
+ cancel_reason == PAYMENT_FAILED
58
+ end
59
+
60
+ # see if this the user has canceled its subscription
61
+ # @return [bool]
62
+ def canceled_by_user?
63
+ cancel_reason == PAYMENT_CANCELED
64
+ end
65
+
66
+ # Get number of overdue days. If this is negative, it is not overdue.
67
+ # @return [Integer]
68
+ def overdue_days
69
+ (Time.now.utc.to_date - expires_at.to_date).to_i
70
+ end
71
+
72
+ # Get the auto renewal status as given by Google
73
+ # @return [bool] true if renewing automatically, false otherwise
74
+ def auto_renewing?
75
+ @subscription_purchase.auto_renewing
76
+ end
77
+
78
+ # Get the payment state as given by Google
79
+ # @return [Integer]
80
+ def payment_state
81
+ @subscription_purchase.payment_state
82
+ end
83
+
84
+ # Get the price amount for the subscription in micros in the payed
85
+ # currency
86
+ # @return [Integer]
87
+ def price_amount_micros
88
+ @subscription_purchase.price_amount_micros
89
+ end
90
+
91
+ # Get the cancel reason, as given by Google
92
+ # @return [Integer]
93
+ def cancel_reason
94
+ @subscription_purchase.cancel_reason
95
+ end
96
+
97
+ # Get the kind of subscription as stored in the android publisher service
98
+ # @return [String]
99
+ def kind
100
+ @subscription_purchase.kind
101
+ end
102
+
103
+ # Get developer-specified supplemental information about the order
104
+ # @return [String]
105
+ def developer_payload
106
+ @subscription_purchase.developer_payload
107
+ end
108
+
109
+ # Get the currency code in ISO 4217 format, e.g. "GBP" for British pounds
110
+ # @return [String]
111
+ def price_currency_code
112
+ @subscription_purchase.price_currency_code
113
+ end
114
+
115
+ # Get start time for subscription in milliseconds since Epoch
116
+ # @return [Integer]
117
+ def start_time_millis
118
+ @subscription_purchase.start_time_millis
119
+ end
120
+
121
+ # Get expiry time for subscription in milliseconds since Epoch
122
+ # @return [Integer]
123
+ def expiry_time_millis
124
+ @subscription_purchase.expiry_time_millis
125
+ end
126
+
127
+ # Get cancellation time for subscription in milliseconds since Epoch.
128
+ # Only present if cancelReason is 0.
129
+ # @return [Integer]
130
+ def user_cancellation_time_millis
131
+ @subscription_purchase.user_cancellation_time_millis if canceled_by_user?
132
+ end
133
+
134
+ # Get start time in UTC
135
+ # @return [DateTime]
136
+ def starts_at
137
+ Time.at(start_time_millis / 1000).utc.to_datetime
138
+ end
139
+
140
+ # Get expiration time in UTC
141
+ # @return [DateTime]
142
+ def expires_at
143
+ Time.at(expiry_time_millis / 1000).utc.to_datetime
144
+ end
145
+
146
+ # Get cancellation time in UTC
147
+ # @return [DateTime]
148
+ def canceled_at
149
+ Time.at(user_cancellation_time_millis / 1000).utc.to_datetime if user_cancellation_time_millis
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,55 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ module SubscriptionPurchases
4
+ # Verifies a purchase token against the Google API
5
+ # The call return either an {SubscriptionPurchase} or an {VerificationFailure}
6
+ class SubscriptionVerification
7
+ # @return [String] the package which will be queried
8
+ attr_reader :package_name
9
+ # @return [String] the item id which will be queried
10
+ attr_reader :subscription_id
11
+ # @return [String] the token for authentication
12
+ attr_reader :token
13
+
14
+ # Initializes a new call to the API
15
+ # @param package_name [String]
16
+ # @param subscription_id [String]
17
+ # @param token [String]
18
+ def initialize(package_name:, subscription_id:, token:, authorization:)
19
+ @package_name = package_name
20
+ @subscription_id = subscription_id
21
+ @token = token
22
+ @authorization = authorization
23
+ end
24
+
25
+ # Performs the verification against the remote server
26
+ # @return [SubscriptionPurchase] if successful
27
+ # @return [VerificationFailure] otherwise
28
+ def call!
29
+ verify!
30
+ if valid?
31
+ CandyCheck::PlayStore::SubscriptionPurchases::SubscriptionPurchase.new(@response[:result])
32
+ else
33
+ CandyCheck::PlayStore::VerificationFailure.new(@response[:error])
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def valid?
40
+ return false unless @response[:result]
41
+ ok_kind = @response[:result].kind == "androidpublisher#subscriptionPurchase"
42
+ @response && @response[:result].expiry_time_millis && ok_kind
43
+ end
44
+
45
+ def verify!
46
+ service = CandyCheck::PlayStore::AndroidPublisherService.new
47
+ service.authorization = @authorization
48
+ service.get_purchase_subscription(package_name, subscription_id, token) do |result, error|
49
+ @response = { result: result, error: error }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -5,25 +5,27 @@ module CandyCheck
5
5
  include Utils::AttributeReader
6
6
 
7
7
  # @return [Hash] the raw attributes returned from the server
8
- attr_reader :attributes
8
+ attr_reader :error
9
9
 
10
10
  # Initializes a new instance which bases on a JSON result
11
11
  # from Google API servers
12
- # @param attributes [Hash]
13
- def initialize(attributes)
14
- @attributes = attributes || {}
12
+ # @param error [Hash]
13
+ def initialize(error)
14
+ @error = error
15
15
  end
16
16
 
17
17
  # The code of the failure
18
18
  # @return [Fixnum]
19
19
  def code
20
- read('code') || -1
20
+ Integer(error.status_code)
21
+ rescue
22
+ -1
21
23
  end
22
24
 
23
25
  # The message of the failure
24
26
  # @return [String]
25
27
  def message
26
- read('message') || 'Unknown error'
28
+ error.message || "Unknown error"
27
29
  end
28
30
  end
29
31
  end
@@ -1,69 +1,44 @@
1
1
  module CandyCheck
2
2
  module PlayStore
3
3
  # Verifies purchase tokens against the Google API.
4
- # The call return either an {Receipt} or a {VerificationFailure}
4
+ # The call return either a {SubscriptionPurchases::SubscriptionPurchase} or a {VerificationFailure}
5
5
  class Verifier
6
- # Error thrown when the verifier isn't booted before the first
7
- # verification check or on double invocation
8
- class BootRequiredError < RuntimeError; end
9
-
10
- # @return [Config] the current configuration
11
- attr_reader :config
12
-
13
- # Initializes a new verifier for the application which is bound
14
- # to a configuration
15
- # @param config [Config]
16
- def initialize(config)
17
- @config = config
18
- end
19
-
20
- # Boot the module
21
- def boot!
22
- boot_error('You\'re only allowed to boot the verifier once') if booted?
23
- @client = Client.new(config)
24
- @client.boot!
6
+ # Initializes a new verifier which is bound to an authorization
7
+ # @param authorization [Google::Auth::ServiceAccountCredentials] to use against the PlayStore API
8
+ def initialize(authorization:)
9
+ @authorization = authorization
25
10
  end
26
11
 
27
12
  # Contacts the Google API and requests the product state
28
- # @param package [String] to query
13
+ # @param package_name [String] to query
29
14
  # @param product_id [String] to query
30
15
  # @param token [String] to use for authentication
31
- # @return [Receipt] if successful
16
+ # @return [ProductPurchases::ProductPurchase] if successful
32
17
  # @return [VerificationFailure] otherwise
33
- def verify(package, product_id, token)
34
- check_boot!
35
- verification = Verification.new(@client, package, product_id, token)
36
- verification.call!
18
+ def verify_product_purchase(package_name:, product_id:, token:)
19
+ verifier = CandyCheck::PlayStore::ProductPurchases::ProductVerification.new(
20
+ package_name: package_name,
21
+ product_id: product_id,
22
+ token: token,
23
+ authorization: @authorization,
24
+ )
25
+ verifier.call!
37
26
  end
38
27
 
39
28
  # Contacts the Google API and requests the product state
40
- # @param package [String] to query
29
+ # @param package_name [String] to query
41
30
  # @param subscription_id [String] to query
42
31
  # @param token [String] to use for authentication
43
- # @return [Receipt] if successful
32
+ # @return [SubscriptionPurchases::SubscriptionPurchase] if successful
44
33
  # @return [VerificationFailure] otherwise
45
- def verify_subscription(package, subscription_id, token)
46
- check_boot!
47
- v = SubscriptionVerification.new(
48
- @client, package, subscription_id, token
34
+ def verify_subscription_purchase(package_name:, subscription_id:, token:)
35
+ verifier = CandyCheck::PlayStore::SubscriptionPurchases::SubscriptionVerification.new(
36
+ package_name: package_name,
37
+ subscription_id: subscription_id,
38
+ token: token,
39
+ authorization: @authorization,
49
40
  )
50
- v.call!
51
- end
52
-
53
- private
54
-
55
- def booted?
56
- instance_variable_defined?(:@client)
57
- end
58
-
59
- def check_boot!
60
- return if booted?
61
- boot_error 'You need to boot the verifier service first: '\
62
- 'CandyCheck::PlayStore::Verifier#boot!'
63
- end
64
-
65
- def boot_error(message)
66
- raise BootRequiredError, message
41
+ verifier.call!
67
42
  end
68
43
  end
69
44
  end
@@ -5,9 +5,11 @@ module CandyCheck
5
5
  # Initializes a new configuration from a hash
6
6
  # @param attributes [Hash]
7
7
  def initialize(attributes)
8
- attributes.each do |k, v|
9
- instance_variable_set "@#{k}", v
10
- end if attributes.is_a? Hash
8
+ if attributes.is_a?(Hash)
9
+ attributes.each do |k, v|
10
+ instance_variable_set "@#{k}", v
11
+ end
12
+ end
11
13
  validate!
12
14
  end
13
15
 
@@ -1,4 +1,4 @@
1
1
  module CandyCheck
2
2
  # The current gem's version
3
- VERSION = '0.1.0.pre'.freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
@@ -8,10 +8,12 @@ describe CandyCheck::AppStore::ReceiptCollection do
8
8
  [{
9
9
  'expires_date' => '2014-04-15 12:52:40 Etc/GMT',
10
10
  'expires_date_pst' => '2014-04-15 05:52:40 America/Los_Angeles',
11
+ 'purchase_date' => '2014-04-14 12:52:40 Etc/GMT',
11
12
  'is_trial_period' => 'false'
12
13
  }, {
13
14
  'expires_date' => '2015-04-15 12:52:40 Etc/GMT',
14
15
  'expires_date_pst' => '2015-04-15 05:52:40 America/Los_Angeles',
16
+ 'purchase_date' => '2015-04-14 12:52:40 Etc/GMT',
15
17
  'is_trial_period' => 'false'
16
18
  }]
17
19
  end
@@ -34,6 +36,34 @@ describe CandyCheck::AppStore::ReceiptCollection do
34
36
  expected = DateTime.new(2015, 4, 15, 12, 52, 40)
35
37
  subject.expires_at.must_equal expected
36
38
  end
39
+
40
+ it 'is expired? at same pointin time' do
41
+ Timecop.freeze(Time.utc(2015, 4, 15, 12, 52, 40)) do
42
+ subject.expired?.must_be_true
43
+ end
44
+ end
45
+ end
46
+
47
+ describe 'unordered receipts' do
48
+ let(:attributes) do
49
+ [{
50
+ 'expires_date' => '2015-04-15 12:52:40 Etc/GMT',
51
+ 'expires_date_pst' => '2015-04-15 05:52:40 America/Los_Angeles',
52
+ 'purchase_date' => '2015-04-14 12:52:40 Etc/GMT',
53
+ 'is_trial_period' => 'false'
54
+ }, {
55
+ 'expires_date' => '2014-04-15 12:52:40 Etc/GMT',
56
+ 'expires_date_pst' => '2014-04-15 05:52:40 America/Los_Angeles',
57
+ 'purchase_date' => '2014-04-14 12:52:40 Etc/GMT',
58
+ 'is_trial_period' => 'false'
59
+ }]
60
+ end
61
+
62
+ it 'the expires date is the latest one in time' do
63
+ expected = DateTime.new(2015, 4, 15, 12, 52, 40)
64
+ subject.expires_at.must_equal expected
65
+ end
66
+
37
67
  end
38
68
 
39
69
  describe 'unexpired trial subscription' do
@@ -42,10 +72,12 @@ describe CandyCheck::AppStore::ReceiptCollection do
42
72
  let(:attributes) do
43
73
  [{
44
74
  'expires_date' => '2016-04-15 12:52:40 Etc/GMT',
75
+ 'purchase_date' => '2016-04-15 12:52:40 Etc/GMT',
45
76
  'is_trial_period' => 'true'
46
77
  }, {
47
78
  'expires_date' =>
48
79
  two_days_from_now.strftime('%Y-%m-%d %H:%M:%S Etc/GMT'),
80
+ 'purchase_date' => '2016-04-15 12:52:40 Etc/GMT',
49
81
  'is_trial_period' => 'true'
50
82
  }]
51
83
  end
@@ -62,4 +94,5 @@ describe CandyCheck::AppStore::ReceiptCollection do
62
94
  subject.overdue_days.must_equal(-2)
63
95
  end
64
96
  end
97
+
65
98
  end