Module: ZeroClick::Sellers::Stateful::Entitlement
- Defined in:
- lib/zeroclick/sellers/stateful/entitlement.rb
Overview
Entitlement parsing and the cumulative credit arithmetic.
Class Method Summary collapse
-
.derive_credit_delta(stored, entitlement) ⇒ Object
Compute the purse movement.
-
.parse(body) ⇒ Object
Collects EVERY issue rather than failing on the first: a seller debugging a rejected entitlement wants the whole list, not a one-at-a-time game.
-
.period_advanced?(stored, incoming) ⇒ Boolean
Whether a new desired state starts a different seller metering period.
- .period_start(value) ⇒ Object
-
.serviceable?(state, now = Time.now) ⇒ Boolean
The one request-time gate: serve only while the account is active AND its paid period (when it has one) is still current.
-
.should_apply?(last_applied_version, incoming_version) ⇒ Boolean
Never-regress: an entitlement is applied only when it is strictly newer than what was last applied.
- .stored_micros(value, label) ⇒ Object
Class Method Details
.derive_credit_delta(stored, entitlement) ⇒ Object
Compute the purse movement.
credit adds delta_usd; debit (a refund or dispute clawback)
subtracts it — clamp the purse at zero if it has already been spent
down. Persist next ATOMICALLY with the purse update, or a retry
applies the same cumulative movement twice.
The arithmetic is cumulative rather than incremental: both sides are monotonic totals, so a delivery that arrives twice, out of order, or after a gap still converges on the same purse.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 179 def derive_credit_delta(stored, entitlement) return CreditReplay.new if !stored.nil? && !should_apply?(stored.state_version, entitlement.state_version) incoming = entitlement.lifetime_credit_granted_usd_micros if incoming.nil? return NoCreditDimension.new( StoredCreditGrant.new( state_version: entitlement.state_version, lifetime_credit_granted_usd_micros: stored&.lifetime_credit_granted_usd_micros, lifetime_credit_reversed_usd_micros: stored&.lifetime_credit_reversed_usd_micros ) ) end prior = stored_micros(stored&.lifetime_credit_granted_usd_micros, "credit") prior_reversed = stored_micros(stored&.lifetime_credit_reversed_usd_micros, "reversal") # max(), not assignment: a stale delivery carrying a smaller total # must not walk the purse backwards. next_credit = [prior, incoming].max next_reversed = [prior_reversed, entitlement.lifetime_credit_reversed_usd_micros || 0].max delta = (next_credit - prior) - (next_reversed - prior_reversed) next_state = StoredCreditGrant.new( state_version: entitlement.state_version, lifetime_credit_granted_usd_micros: next_credit, lifetime_credit_reversed_usd_micros: next_reversed ) if delta.negative? CreditMovement.new(outcome: "debit", delta_usd: Money.format_money_usd(-delta), delta_usd_micros: -delta, next_state: next_state) else CreditMovement.new(outcome: "credit", delta_usd: Money.format_money_usd(delta), delta_usd_micros: delta, next_state: next_state) end end |
.parse(body) ⇒ Object
Collects EVERY issue rather than failing on the first: a seller debugging a rejected entitlement wants the whole list, not a one-at-a-time game.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 71 def parse(body) return ParseFailure.new(["body: expected an object"]) unless body.is_a?(Hash) issues = [] %w[accessId agentId idempotencyKey].each do |key| issues << "#{key}: expected a non-empty string" unless Contracts.non_empty_string?(body[key]) end buyer_id, buyer_ok = Contracts.nullable_id(body, "buyerId") issues << "buyerId: required, a non-empty string or null" unless buyer_ok buyer_email, buyer_email_ok = Contracts.nullable_id(body, "buyerEmail") issues << "buyerEmail: required, a non-empty string or null" unless buyer_email_ok state_version = Contracts.as_version(body["stateVersion"]) issues << "stateVersion: expected a non-negative integer" if state_version.nil? plan_issues = [] plan = Contracts.parse_plan(body["plan"], plan_issues) issues.concat(plan_issues) state = body["state"] unless state.is_a?(Hash) issues << "state: expected an object" return ParseFailure.new(issues) end period_issues = [] period = Contracts.parse_period(state["period"], period_issues) issues.concat(period_issues) credit_granted = Contracts.parse_money_field(state, "lifetimeCreditGrantedUsd", issues: issues) credit_reversed = Contracts.parse_money_field(state, "lifetimeCreditReversedUsd", issues: issues) status = state["status"] issues << "state.status: expected active, suspended, or closed" unless ACCESS_STATUSES.include?(status) return ParseFailure.new(issues) unless issues.empty? base_price_micros = Money.parse_money_usd(plan.base_price_usd) if base_price_micros.nil? raise Error.new("malformed_input", operation: "parse_entitlement", message: "basePriceUsd exceeds the supported micro-USD range") end ParseOk.new( ParsedEntitlement.new( access_id: body["accessId"], agent_id: body["agentId"], buyer_id: buyer_id, buyer_email: buyer_email, idempotency_key: body["idempotencyKey"], state_version: state_version, plan: plan, period: period, status: status, lifetime_credit_granted_usd: credit_granted, lifetime_credit_reversed_usd: credit_reversed, base_price_usd_micros: base_price_micros, lifetime_credit_granted_usd_micros: Money.parse_money_usd(credit_granted), lifetime_credit_reversed_usd_micros: Money.parse_money_usd(credit_reversed) ) ) end |
.period_advanced?(stored, incoming) ⇒ Boolean
Whether a new desired state starts a different seller metering period.
155 156 157 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 155 def period_advanced?(stored, incoming) period_start(stored) != period_start(incoming) end |
.period_start(value) ⇒ Object
147 148 149 150 151 152 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 147 def period_start(value) return nil if value.nil? return value.start if value.respond_to?(:start) value.is_a?(Hash) ? (value["start"] || value[:start]) : nil end |
.serviceable?(state, now = Time.now) ⇒ Boolean
The one request-time gate: serve only while the account is active AND its paid period (when it has one) is still current. Checking status alone misses period-based revocation; checking the period alone misses suspension and closure.
133 134 135 136 137 138 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 133 def serviceable?(state, now = Time.now) return false unless state.status == "active" finish = state.period&.end finish.nil? || Time.iso8601(finish) > now end |
.should_apply?(last_applied_version, incoming_version) ⇒ Boolean
Never-regress: an entitlement is applied only when it is strictly newer than what was last applied. Equal versions are replays, which is what makes delivery retries safe.
143 144 145 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 143 def should_apply?(last_applied_version, incoming_version) last_applied_version.nil? || incoming_version > last_applied_version end |
.stored_micros(value, label) ⇒ Object
159 160 161 162 163 164 165 166 167 |
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 159 def stored_micros(value, label) micros = value.nil? ? 0 : value unless micros.is_a?(Integer) && !micros.is_a?(TrueClass) && micros >= 0 && micros <= Money::MAX_SAFE_MICROS raise Error.new("malformed_input", operation: "derive_credit_delta", message: "stored #{label} must be non-negative micro-USD") end micros end |