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.



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 166

def derive_credit_delta(stored, entitlement)
  return CreditReplay.new if !stored.nil? && !should_apply?(stored.state_version, entitlement.state_version)

  incoming = entitlement.credit_granted_usd_micros
  if incoming.nil?
    return NoCreditDimension.new(
      StoredCreditGrant.new(
        state_version: entitlement.state_version,
        credit_granted_usd_micros: stored&.credit_granted_usd_micros,
        credit_reversed_usd_micros: stored&.credit_reversed_usd_micros
      )
    )
  end

  prior = stored_micros(stored&.credit_granted_usd_micros, "credit")
  prior_reversed = stored_micros(stored&.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.credit_reversed_usd_micros || 0].max
  delta = (next_credit - prior) - (next_reversed - prior_reversed)

  next_state = StoredCreditGrant.new(
    state_version: entitlement.state_version,
    credit_granted_usd_micros: next_credit,
    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.



69
70
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
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 69

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.optional_id(body, "buyerId")
  issues << "buyerId: expected a non-empty string or null" unless buyer_ok
  buyer_email, buyer_email_ok = Contracts.optional_id(body, "buyerEmail")
  issues << "buyerEmail: expected 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, "creditGrantedUsd", required: true, issues: issues)
  credit_reversed = Contracts.parse_money_field(state, "creditReversedUsd", required: false, issues: issues)

  status = state["status"]
  issues << "state.status: expected active or suspended" 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,
      credit_granted_usd: credit_granted, credit_reversed_usd: credit_reversed,
      base_price_usd_micros: base_price_micros,
      credit_granted_usd_micros: Money.parse_money_usd(credit_granted),
      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)


142
143
144
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 142

def period_advanced?(stored, incoming)
  period_start(stored) != period_start(incoming)
end

.period_start(value) ⇒ Object



134
135
136
137
138
139
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 134

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

.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)


130
131
132
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 130

def should_apply?(last_applied_version, incoming_version)
  last_applied_version.nil? || incoming_version > last_applied_version
end

.stored_micros(value, label) ⇒ Object



146
147
148
149
150
151
152
153
154
# File 'lib/zeroclick/sellers/stateful/entitlement.rb', line 146

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