{"record":{"id":"6f395fa75e82f5c2","repo":"we-promise/sure","slug":"only-open-pledges-can-be-cancelled","errorCode":null,"errorMessage":"Only open pledges can be cancelled","messagePattern":"Only open pledges can be cancelled","errorType":"exception","errorClass":"GoalPledge::NotOpenError","httpStatus":null,"severity":"warning","filePath":"app/models/goal_pledge.rb","lineNumber":127,"sourceCode":"\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?\n\n    delta = ((expires_at - Time.current) / 1.day).ceil\n    [ delta, 0 ].max\n  end\n\n  private","sourceCodeStart":109,"sourceCodeEnd":145,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/goal_pledge.rb#L109-L145","documentation":"GoalPledge#cancel! raises NotOpenError unless status_open?. Cancellation is only defined for open pledges; matched, already-cancelled, or expired pledges cannot be cancelled again.","triggerScenarios":"Calling cancel! on a pledge whose status is anything but open — e.g. a cancel request racing the reconciler that just matched the pledge, or a duplicate cancel submission.","commonSituations":"User clicks cancel after an incoming transaction matched the pledge; double-submit of the cancel action; stale UI showing an already-cancelled pledge as open.","solutions":["Guard with return unless pledge.reload.status_open? before calling cancel!","Rescue GoalPledge::NotOpenError in controllers/jobs and treat it as an idempotent no-op","Reflect current pledge status in the UI before offering the cancel action"],"exampleFix":"// before\npledge.cancel!\n\n// after\npledge.reload\npledge.cancel! if pledge.status_open?","handlingStrategy":"validation","validationCode":"pledge.reload.status_open? # false => cancel! will raise NotOpenError","typeGuard":"def cancellable?(pledge) = pledge.reload.status_open? # narrow before calling cancel!","tryCatchPattern":"rescue GoalPledge::NotOpenError in cancel endpoints/jobs and return idempotent success (the end state — not open — is what the caller wanted)","preventionTips":["Make cancel actions idempotent; a second cancel of a cancelled pledge is not an error","Reload before acting in UI flows to avoid stale-status actions","Treat NotOpenError as expected concurrency, matching the reconciler's pattern"],"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"}