juspay / juspay/hyperswitch

[BUG] [GETNET] Failed capture mapped to AttemptStatus::Authorized instead of CaptureFailed

Open Beginner friendly
#13,530 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
43.6k
Forks
5.1k
Avg merge
2d 21h
Merged PRs (30d)
210

Description

Bug Description

In the Getnet connector, a failed capture is mapped to AttemptStatus::Authorized instead of AttemptStatus::CaptureFailed.

https://github.com/juspay/hyperswitch/blob/main/crates/hyperswitch_connectors/src/connectors/getnet/transformers.rs#L626-L632

pub fn capture_status_from_transaction_state(getnet_status: GetnetPaymentStatus) -> AttemptStatus {
    match getnet_status {
        GetnetPaymentStatus::Success => AttemptStatus::Charged,
        GetnetPaymentStatus::InProgress => AttemptStatus::Pending,
        GetnetPaymentStatus::Failed => AttemptStatus::Authorized,   // should be CaptureFailed
    }
}

Authorized is a success path state and it isn't terminal, so mapping a failed capture onto it makes the failure disappear. The payment goes back to the merchant looking like it's still waiting to be captured.

This is the only place in the connector where GetnetPaymentStatus::Failed isn't treated as a failure. Every other flow in the same file handles it correctly:

Flow Line GetnetPaymentStatus::Failed maps to
impl From<GetnetPaymentStatus> for AttemptStatus 317 AttemptStatus::Failure
Authorize 401 AttemptStatus::Failure
PSync 454 AttemptStatus::Failure
Capture 630 AttemptStatus::Authorized
Void / Cancel 964 AttemptStatus::VoidFailed
Webhooks 1107 IncomingWebhookEvent::PaymentIntentFailure

cancel_status_from_transaction_state at line 960 has the same shape and gets this right by using the flow specific VoidFailed. CaptureFailed is the capture equivalent, and it's what other connectors use in this exact spot, for example hipay, worldline, helcim, archipel, payeezy and amazonpay.

Expected Behavior

A Getnet capture that comes back with transaction-state: "failed" should set the attempt to AttemptStatus::CaptureFailed, which maps to IntentStatus::Failed:

https://github.com/juspay/hyperswitch/blob/main/crates/common_enums/src/transformers.rs#L2131-L2136

AttemptStatus::AuthenticationFailed
| AttemptStatus::AuthorizationFailed
| AttemptStatus::VoidFailed
| AttemptStatus::RouterDeclined
| AttemptStatus::CaptureFailed
| AttemptStatus::Failure => Self::Failed,

So POST /payments/{id}/capture should come back with "status": "failed", the same way a failed void already does on this connector.

Actual Behavior

Authorized maps to IntentStatus::RequiresCapture:

https://github.com/juspay/hyperswitch/blob/main/crates/common_enums/src/transformers.rs#L2115

AttemptStatus::Authorized => Self::RequiresCapture,

so the merchant gets "status": "requires_capture" for a capture that Getnet rejected.

The response is also built as response: Ok(PaymentsResponseData::TransactionResponse { .. }) on line 643 rather than Err(ErrorResponse { .. }), so error_code and error_message are both left unset. There's nothing in the response indicating anything went wrong. The payment just looks like it's still sitting in requires_capture, which is indistinguishable from a capture that was never attempted.

What this leads to:

  1. The merchant thinks the funds are still capturable when Getnet has actually rejected the capture, so reconciliation drifts.
  2. Any automated capture retry sees requires_capture and keeps retrying a capture the connector already declined.
  3. It's inconsistent within the connector itself, since a failed void gives failed but a failed capture gives requires_capture.
Steps To Reproduce

I found this by reading the code rather than by hitting it in production, so the inconsistency above is the main evidence. To see it against the connector:

  1. Set up Getnet with capture_method = "manual".
  2. Create and confirm a payment so the attempt reaches requires_capture.
  3. Trigger a capture that Getnet rejects, for example a capture amount above the authorized amount, or an authorization that has already expired, so the capture response carries transaction-state: "failed".
  4. POST /payments/{payment_id}/capture.
  5. The response is "status": "requires_capture" with no error_code or error_message, where it should be "status": "failed".

If you'd rather check it without connector credentials, every GetnetPaymentStatus::Failed arm in the file lines up except this one:

$ grep -n "GetnetPaymentStatus::Failed" crates/hyperswitch_connectors/src/connectors/getnet/transformers.rs
317:            GetnetPaymentStatus::Failed => Self::Failure,
401:        GetnetPaymentStatus::Failed => AttemptStatus::Failure,
454:        GetnetPaymentStatus::Failed => AttemptStatus::Failure,
630:        GetnetPaymentStatus::Failed => AttemptStatus::Authorized,
964:        GetnetPaymentStatus::Failed => AttemptStatus::VoidFailed,
1107:            GetnetPaymentStatus::Failed => IncomingWebhookEvent::PaymentIntentFailure,
Context For The Bug

The fix is one line, bringing capture in line with the cancel flow next to it:

 pub fn capture_status_from_transaction_state(getnet_status: GetnetPaymentStatus) -> AttemptStatus {
     match getnet_status {
         GetnetPaymentStatus::Success => AttemptStatus::Charged,
         GetnetPaymentStatus::InProgress => AttemptStatus::Pending,
-        GetnetPaymentStatus::Failed => AttemptStatus::Authorized,
+        GetnetPaymentStatus::Failed => AttemptStatus::CaptureFailed,
     }
 }

Happy to raise a PR for this if you agree with the direction.

Environment

Checked against main, in crates/hyperswitch_connectors/src/connectors/getnet/transformers.rs lines 626 to 632.

Are you using hyperswitch hosted version? No, self hosted from source.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in crates/hyperswitch_connectors/src/connectors/getnet/transformers.rs at capture_status_from_transaction_state around lines 626-632, then compare the neighboring cancel mapping and other GetnetPaymentStatus::Failed arms. Done means a failed Getnet capture produces AttemptStatus::CaptureFailed and the resulting payment status is failed rather than requires_capture.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend
Issue type
Bug
Difficulty
1/5
Estimated time
Under an hour
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
91/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.