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