Skip to content

fix(purchase): finalizePurchaseStatus silently swallows save-failure (mirrors COR-10) #54

Description

@cristim

Problem

handler_purchases.go:1594 finalizePurchaseStatus mirrors the COR-10 (LeanerCloud/cloud-commitments-cli#1184 / PR LeanerCloud/cloud-commitments-cli#1237) anti-pattern. When the approval email fails to send, it stamps Status="failed" on the row and calls SavePurchaseExecution. If that save fails, the function logs an error and returns "pending" to the API caller:

execution.Status = "failed"
execution.Error = emailReason
if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
    logging.Errorf("failed to mark execution %s as failed after email send error: %v", execution.ExecutionID, err)
    return "pending"
}
return "failed"

The row remains in whatever its previous status was (typically pending) and the API call appears successful to the caller, while:

  • the failed-email reason is not persisted onto the row,
  • the dashboard/history UI sees no signal that the approval email failed,
  • the row sits in pending until the History expiry sweep (LeanerCloud/cloud-commitments-cli#1158) reaps it, with no breadcrumb explaining why.

This is not on the money path: the email never went out so no human approval link can be clicked, and the row will expire rather than fire. But it is the same silent-failure shape COR-10 closed on the credential-resolution path, so the audit-trail loss is identical.

Suggested fix

Mirror PR LeanerCloud/cloud-commitments-cli#1237: bubble the save error so the caller can act on it (or at minimum return "failed_send_audit_loss" instead of "pending", plus an explicit error response). The two known shapes are:

  1. Return the save error from finalizePurchaseStatus. Change the signature to (string, error) and surface an AUDIT LOSS: error to the HTTP handler so the response carries a 5xx with the audit-loss reason. Callers at handler_purchases.go:1120 and :1927 would propagate the error to the existing error path.
  2. Persist via the atomic TransitionExecutionStatus(["pending","notified"], "failed", ...) CAS. This is what approvals.go / manager.go use elsewhere and gives an atomic guard against a concurrent approve clobbering the failed-state write.

Either approach removes the silent-failure window. (1) is the minimal change; (2) aligns the email-send-failed path with the rest of the failed-status state machine.

Sweep target

This file (internal/api/handler_purchases.go) and handler_purchases.go:2004 (direct-execute audit-fields stamp) are the two remaining log-only SavePurchaseExecution error sites in the handler_purchases package. The latter is best-effort attribution; this one is a real status-write.

References

Acceptance

  • finalizePurchaseStatus's save-failure no longer returns "pending" silently; the API caller sees either an error or a failed_send_audit_loss status.
  • Regression test that asserts the new contract: with SavePurchaseExecution returning an error, the response carries the save-failure (and the test fails against the current code).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions