{"record":{"id":"e319d155f781f2e7","repo":"we-promise/sure","slug":"only-open-pledges-can-be-extended","errorCode":null,"errorMessage":"Only open pledges can be extended","messagePattern":"Only open pledges can be extended","errorType":"exception","errorClass":"GoalPledge::NotOpenError","httpStatus":null,"severity":"warning","filePath":"app/models/goal_pledge.rb","lineNumber":121,"sourceCode":"  end\n\n  # Valuation-backed match: no transaction to stamp, just flip the pledge.\n  def resolve_with_valuation!\n    with_lock do\n      raise NotOpenError, \"Pledge no longer open\" unless status_open?\n\n      update!(status: \"matched\")\n    end\n  end\n\n  class NotOpenError < StandardError; end\n  # Raised when a Transaction is already claimed by a different open\n  # pledge. Lets the reconciler distinguish a known race (\"another worker\n  # got there first\") from a generic validation failure.\n  class AlreadyClaimedError < StandardError; end\n\n  def extend!(days: EXTEND_DAYS)\n    raise NotOpenError, \"Only open pledges can be extended\" unless status_open?\n\n    update!(expires_at: expires_at + days.days)\n  end\n\n  def cancel!\n    raise NotOpenError, \"Only open pledges can be cancelled\" unless status_open?\n\n    update!(status: \"cancelled\")\n  end\n\n  def expire!\n    return unless status_open?\n\n    update!(status: \"expired\")\n  end\n\n  def days_left\n    return 0 unless status_open?","sourceCodeStart":103,"sourceCodeEnd":139,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/goal_pledge.rb#L103-L139","documentation":"GoalPledge#extend! raises NotOpenError unless status_open?. Pledges are a small state machine (open to matched/cancelled/expired); extending expires_at is only defined while the pledge is open.","triggerScenarios":"Calling pledge.extend!(days:) on a pledge whose status is matched, cancelled, or expired — commonly a concurrent worker matched or cancelled it between load and extend, or the UI acts on a stale pledge object.","commonSituations":"Race between the reconciler matching a pledge and a user/job extending it; double-click on an extend button; scheduled extend jobs picking up pledges that matched after enqueue.","solutions":["Reload before acting: pledge.reload, then skip when !pledge.status_open? instead of raising","Rescue GoalPledge::NotOpenError in background jobs and treat it as a benign no-op race (same pattern the reconciler uses for AlreadyClaimedError)","In the UI, disable the extend action for non-open statuses"],"exampleFix":"// before\npledge.extend!\n\n// after\npledge.reload\npledge.extend! if pledge.status_open?","handlingStrategy":"validation","validationCode":"pledge.reload.status_open? # false => extend! will raise NotOpenError","typeGuard":"def extendable?(pledge) = pledge.reload.status_open? # narrow before calling extend!","tryCatchPattern":"rescue GoalPledge::NotOpenError and treat as a benign race (another worker matched/cancelled first); log at info, not error","preventionTips":["Reload pledges immediately before state-changing calls in concurrent flows","Design pledge jobs to tolerate AlreadyClaimedError and NotOpenError as expected races","Disable extend/cancel UI for non-open statuses"],"tags":["goal-pledge","state-machine","concurrency"],"backgroundTag":"invalid-state-transition","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}