[BUG] [GETNET] Failed capture mapped to AttemptStatus::Authorized instead of CaptureFailed
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.
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:
- The merchant thinks the funds are still capturable when Getnet has actually rejected the capture, so reconciliation drifts.
- Any automated capture retry sees
requires_captureand keeps retrying a capture the connector already declined. - It's inconsistent within the connector itself, since a failed void gives
failedbut a failed capture givesrequires_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:
- Set up Getnet with
capture_method = "manual". - Create and confirm a payment so the attempt reaches
requires_capture. - 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". POST /payments/{payment_id}/capture.- The response is
"status": "requires_capture"with noerror_codeorerror_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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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