zeroclick-sellers 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49a5e89b4a453d1bc17ba628fe6a1c15bcddf4b326425d039e00c9f44189089f
4
- data.tar.gz: bb98323586f2a706fcb0371b22635c990a67f70271b74f6f733c105884ff9f62
3
+ metadata.gz: 21a687776a099cffab368909929c6181de3425e83d6fe94bcfaba52cc40e76a8
4
+ data.tar.gz: 15b009873e6410a7703a6385ebdfc0d737f1be8d3d475b18ed22e96683117904
5
5
  SHA512:
6
- metadata.gz: 0e1196572a7e5077fd756f76f334201c10b4665c6d24085932228d84850dd9980bec5a9d535235fe6ce973a32252499dafbf5c8c9953e1f63ab6ec28d0a6b2c8
7
- data.tar.gz: e7ac43462966f8b769a79abce4330378ad276af8d44a6f4e5f82c8e1151ba9ee9ac5a8b9a0d578a1530615954baffba7eed660a53563842d0260e39e82d68fc0
6
+ metadata.gz: 10cc75f795e921fa55d77b5dce9f101d1f3c4afadb191fe70546db9bd01dd309fe8453cfedd24de1d1991e1716567de26378b17cb87fb8338866f1132d4e96bd
7
+ data.tar.gz: 82a1860e38b1a2b53195b1aa0c17830b60f2c7ad5cdc80efc280c6547678f74771ed799a42ddabaf405c1df3f00f36de6344402e3c104c03078cc94e2d00f90e
@@ -6,7 +6,9 @@ require_relative "money"
6
6
  module ZeroClick
7
7
  module Sellers
8
8
  module Stateful
9
- ACCESS_STATUSES = %w[active suspended].freeze
9
+ # suspended is recoverable; closed is terminal — revoke live keys, stop
10
+ # serving, keep your records.
11
+ ACCESS_STATUSES = %w[active suspended closed].freeze
10
12
 
11
13
  # zod v4's `iso.datetime({offset: true})` grammar, transcribed verbatim —
12
14
  # as the Python and Go SDKs also transcribe it. Seconds and the fraction
@@ -31,8 +33,8 @@ module ZeroClick
31
33
  ParsedEntitlement = Struct.new(
32
34
  :access_id, :agent_id, :buyer_id, :buyer_email, :idempotency_key,
33
35
  :state_version, :plan, :period, :status,
34
- :credit_granted_usd, :credit_reversed_usd,
35
- :base_price_usd_micros, :credit_granted_usd_micros, :credit_reversed_usd_micros,
36
+ :lifetime_credit_granted_usd, :lifetime_credit_reversed_usd,
37
+ :base_price_usd_micros, :lifetime_credit_granted_usd_micros, :lifetime_credit_reversed_usd_micros,
36
38
  keyword_init: true
37
39
  )
38
40
 
@@ -60,9 +62,11 @@ module ZeroClick
60
62
  nil
61
63
  end
62
64
 
63
- # Absent and null both mean "not present"; an empty string is invalid.
65
+ # Required but nullable: a missing key is an error, null is not.
64
66
  # Returns [value, ok].
65
- def optional_id(container, key)
67
+ def nullable_id(container, key)
68
+ return [nil, false] unless container.key?(key)
69
+
66
70
  value = container[key]
67
71
  return [nil, true] if value.nil?
68
72
  return [value, true] if non_empty_string?(value)
@@ -73,7 +77,7 @@ module ZeroClick
73
77
  def parse_mint_key_request(body)
74
78
  return nil unless body.is_a?(Hash) && non_empty_string?(body["agentId"])
75
79
 
76
- buyer_id, ok = optional_id(body, "buyerId")
80
+ buyer_id, ok = nullable_id(body, "buyerId")
77
81
  return nil unless ok
78
82
 
79
83
  MintKeyRequest.new(agent_id: body["agentId"], buyer_id: buyer_id)
@@ -118,9 +122,9 @@ module ZeroClick
118
122
  AccessPeriod.new(start: start, end: finish)
119
123
  end
120
124
 
121
- def parse_money_field(container, key, required:, issues:)
125
+ def parse_money_field(container, key, issues:)
122
126
  unless container.key?(key)
123
- issues << "state.#{key}: required (may be null)" if required
127
+ issues << "state.#{key}: required (may be null)"
124
128
  return nil
125
129
  end
126
130
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  require_relative "../errors"
4
6
  require_relative "contracts"
5
7
  require_relative "money"
@@ -7,8 +9,8 @@ require_relative "money"
7
9
  module ZeroClick
8
10
  module Sellers
9
11
  module Stateful
10
- StoredCreditGrant = Struct.new(:state_version, :credit_granted_usd_micros, :credit_reversed_usd_micros,
11
- keyword_init: true)
12
+ StoredCreditGrant = Struct.new(:state_version, :lifetime_credit_granted_usd_micros,
13
+ :lifetime_credit_reversed_usd_micros, keyword_init: true)
12
14
 
13
15
  class ParseOk
14
16
  attr_reader :entitlement
@@ -74,10 +76,10 @@ module ZeroClick
74
76
  issues << "#{key}: expected a non-empty string" unless Contracts.non_empty_string?(body[key])
75
77
  end
76
78
 
77
- buyer_id, buyer_ok = Contracts.optional_id(body, "buyerId")
78
- issues << "buyerId: expected a non-empty string or null" unless buyer_ok
79
- buyer_email, buyer_email_ok = Contracts.optional_id(body, "buyerEmail")
80
- issues << "buyerEmail: expected a non-empty string or null" unless buyer_email_ok
79
+ buyer_id, buyer_ok = Contracts.nullable_id(body, "buyerId")
80
+ issues << "buyerId: required, a non-empty string or null" unless buyer_ok
81
+ buyer_email, buyer_email_ok = Contracts.nullable_id(body, "buyerEmail")
82
+ issues << "buyerEmail: required, a non-empty string or null" unless buyer_email_ok
81
83
 
82
84
  state_version = Contracts.as_version(body["stateVersion"])
83
85
  issues << "stateVersion: expected a non-negative integer" if state_version.nil?
@@ -96,11 +98,11 @@ module ZeroClick
96
98
  period = Contracts.parse_period(state["period"], period_issues)
97
99
  issues.concat(period_issues)
98
100
 
99
- credit_granted = Contracts.parse_money_field(state, "creditGrantedUsd", required: true, issues: issues)
100
- credit_reversed = Contracts.parse_money_field(state, "creditReversedUsd", required: false, issues: issues)
101
+ credit_granted = Contracts.parse_money_field(state, "lifetimeCreditGrantedUsd", issues: issues)
102
+ credit_reversed = Contracts.parse_money_field(state, "lifetimeCreditReversedUsd", issues: issues)
101
103
 
102
104
  status = state["status"]
103
- issues << "state.status: expected active or suspended" unless ACCESS_STATUSES.include?(status)
105
+ issues << "state.status: expected active, suspended, or closed" unless ACCESS_STATUSES.include?(status)
104
106
 
105
107
  return ParseFailure.new(issues) unless issues.empty?
106
108
 
@@ -116,14 +118,25 @@ module ZeroClick
116
118
  buyer_id: buyer_id, buyer_email: buyer_email,
117
119
  idempotency_key: body["idempotencyKey"], state_version: state_version,
118
120
  plan: plan, period: period, status: status,
119
- credit_granted_usd: credit_granted, credit_reversed_usd: credit_reversed,
121
+ lifetime_credit_granted_usd: credit_granted, lifetime_credit_reversed_usd: credit_reversed,
120
122
  base_price_usd_micros: base_price_micros,
121
- credit_granted_usd_micros: Money.parse_money_usd(credit_granted),
122
- credit_reversed_usd_micros: Money.parse_money_usd(credit_reversed)
123
+ lifetime_credit_granted_usd_micros: Money.parse_money_usd(credit_granted),
124
+ lifetime_credit_reversed_usd_micros: Money.parse_money_usd(credit_reversed)
123
125
  )
124
126
  )
125
127
  end
126
128
 
129
+ # The one request-time gate: serve only while the account is active AND
130
+ # its paid period (when it has one) is still current. Checking status
131
+ # alone misses period-based revocation; checking the period alone
132
+ # misses suspension and closure.
133
+ def serviceable?(state, now = Time.now)
134
+ return false unless state.status == "active"
135
+
136
+ finish = state.period&.end
137
+ finish.nil? || Time.iso8601(finish) > now
138
+ end
139
+
127
140
  # Never-regress: an entitlement is applied only when it is strictly
128
141
  # newer than what was last applied. Equal versions are replays, which
129
142
  # is what makes delivery retries safe.
@@ -166,30 +179,30 @@ module ZeroClick
166
179
  def derive_credit_delta(stored, entitlement)
167
180
  return CreditReplay.new if !stored.nil? && !should_apply?(stored.state_version, entitlement.state_version)
168
181
 
169
- incoming = entitlement.credit_granted_usd_micros
182
+ incoming = entitlement.lifetime_credit_granted_usd_micros
170
183
  if incoming.nil?
171
184
  return NoCreditDimension.new(
172
185
  StoredCreditGrant.new(
173
186
  state_version: entitlement.state_version,
174
- credit_granted_usd_micros: stored&.credit_granted_usd_micros,
175
- credit_reversed_usd_micros: stored&.credit_reversed_usd_micros
187
+ lifetime_credit_granted_usd_micros: stored&.lifetime_credit_granted_usd_micros,
188
+ lifetime_credit_reversed_usd_micros: stored&.lifetime_credit_reversed_usd_micros
176
189
  )
177
190
  )
178
191
  end
179
192
 
180
- prior = stored_micros(stored&.credit_granted_usd_micros, "credit")
181
- prior_reversed = stored_micros(stored&.credit_reversed_usd_micros, "reversal")
193
+ prior = stored_micros(stored&.lifetime_credit_granted_usd_micros, "credit")
194
+ prior_reversed = stored_micros(stored&.lifetime_credit_reversed_usd_micros, "reversal")
182
195
 
183
196
  # max(), not assignment: a stale delivery carrying a smaller total
184
197
  # must not walk the purse backwards.
185
198
  next_credit = [prior, incoming].max
186
- next_reversed = [prior_reversed, entitlement.credit_reversed_usd_micros || 0].max
199
+ next_reversed = [prior_reversed, entitlement.lifetime_credit_reversed_usd_micros || 0].max
187
200
  delta = (next_credit - prior) - (next_reversed - prior_reversed)
188
201
 
189
202
  next_state = StoredCreditGrant.new(
190
203
  state_version: entitlement.state_version,
191
- credit_granted_usd_micros: next_credit,
192
- credit_reversed_usd_micros: next_reversed
204
+ lifetime_credit_granted_usd_micros: next_credit,
205
+ lifetime_credit_reversed_usd_micros: next_reversed
193
206
  )
194
207
 
195
208
  if delta.negative?
@@ -4,6 +4,6 @@ module ZeroClick
4
4
  module Sellers
5
5
  # Bump this to publish. The workflow reads it, asks RubyGems whether this
6
6
  # version already exists, and publishes only when it does not.
7
- VERSION = "0.3.0"
7
+ VERSION = "0.4.0"
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zeroclick-sellers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZeroClick