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

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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