Skip to content

fix(connector): [WORLDPAY] parse refused responses when refusalCode or refusalDescription is missing - #14263

Open
errmakov wants to merge 2 commits into
juspay:mainfrom
errmakov:fix/worldpay-refusal-partial-fields
Open

errmakov wants to merge 2 commits into
juspay:mainfrom
errmakov:fix/worldpay-refusal-partial-fields

Conversation

@errmakov

@errmakov errmakov commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

Description

WorldpayPaymentResponseFields was an untagged enum, and its RefusedResponse variant required both refusalCode and refusalDescription. When Worldpay refuses a payment and sends only one of them, the body matches no variant, and because other_fields is a flattened Option, serde silently deserializes it as None instead of failing. The payment is still marked Failure (from outcome: refused), but the connector response carries no error code or message, so the decline reason is lost.

Checked against the pre-fix types: {"outcome": "refused", "refusalDescription": "Do not honour"} deserializes to Ok(WorldpayPaymentsResponse { outcome: Refused, other_fields: None }).

As suggested in the review of #8763, simply making the two fields optional isn't enough: with an untagged enum, almost any body would then match RefusedResponse. This PR:

  • Implements Deserialize for WorldpayPaymentsResponse by hand (crates/hyperswitch_connectors/src/connectors/worldpay/response.rs). It reads outcome and transactionReference, then parses the remaining fields into the shape that outcome implies:

    • refusedRefusedResponse
    • 3dsDeviceDataRequiredDDCResponse
    • 3dsChallengedThreeDsChallenged
    • fraudHighRiskFraudHighRisk
    • authorized, sentForSettlement, sentForRefund, sentForPartialRefund, sentForCancellation, 3dsAuthenticationFailed, 3dsUnavailableAuthorizedResponse

    Every outcome is matched explicitly. A body that doesn't match its outcome's shape (e.g. a cancellation response with only _links) still has no extra fields, as before, but the mismatch is now logged as a warn with the outcome, the expected fields type and the error. The logged error omits response values; only missing field messages are kept verbatim.

  • Removes the derived Deserialize from WorldpayPaymentResponseFields (it keeps Serialize), so the ambiguous untagged deserialization can't be used by accident.

  • Makes RefusedResponse.refusal_code and refusal_description Option<String>, keeping the existing comment on the raw response code.

  • In crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs, when a refusal field is missing, ErrorResponse.code / message fall back to NO_ERROR_CODE / NO_ERROR_MESSAGE. reason, network_decline_code and network_error_message are left None rather than filled with a placeholder, so a placeholder is never reported as coming from the card network.

Additional Changes

  • This PR modifies the API contract
  • This PR modifies the database schema
  • This PR modifies application configuration/environment variables

Motivation and Context

Fixes #8749

Supersedes #8763, which stalled on the review asking for a custom deserializer.

How did you test it?

Unit tests (connectors::worldpay::response::tests, 13 new), run with cargo test -p hyperswitch_connectors --features v1,payouts,frm --lib worldpay::response::tests:

Response body Parsed as
refused with refusalCode, refusalDescription, advice, riskFactors RefusedResponse, all fields kept
refused with only refusalDescription RefusedResponse, code None
refused with only refusalCode RefusedResponse, description None
refused with neither RefusedResponse, both None
authorized with paymentInstrument, _links, _actions AuthorizedResponse
3dsDeviceDataRequired DDCResponse
3dsChallenged ThreeDsChallenged
fraudHighRisk FraudHighRisk
sentForCancellation with only _links no extra fields
refused with a numeric refusalCode no extra fields (mismatch logged)
no outcome deserialization error

Two more tests cover the logged error detail: a missing field keeps its name, and a mistyped value is not echoed.

The tests failed to compile before the change (refusal fields were String) and all 13 pass after it.

  • cargo +nightly fmt applied.
  • cargo clippy -p hyperswitch_connectors --features v1,payouts,frm --all-targets reports no warnings in hyperswitch_connectors.
  • Not tested against a live Worldpay sandbox; the response bodies in the tests follow the shapes the existing structs already expect.

Checklist

  • I formatted the code cargo +nightly fmt --all
  • I addressed lints thrown by cargo clippy
  • I reviewed the submitted code
  • I added unit tests for my changes where possible

🤖 Generated with Claude Code

…r refusalDescription is missing

`WorldpayPaymentResponseFields` was an untagged enum whose `RefusedResponse`
variant required both `refusalCode` and `refusalDescription`. A refusal
carrying only one of them matched no variant, so the flattened
`other_fields` silently became `None` and the refusal code and message
were dropped.

Deserialize `WorldpayPaymentsResponse` by reading `outcome` first and
parsing the remaining fields into the shape that outcome implies, as
suggested in the review of juspay#8763. `refusalCode` and `refusalDescription`
are now optional. When one is missing, the error response falls back to
`NO_ERROR_CODE` / `NO_ERROR_MESSAGE`, and the network decline code and
network error message are left unset rather than filled with placeholders.

Fixes juspay#8749

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@errmakov
errmakov requested a review from a team as a code owner September 16, 2026 16:55
@semanticdiff-com

semanticdiff-com Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs  83% smaller
  crates/hyperswitch_connectors/src/connectors/worldpay/response.rs Unsupported file format

@XyneSpaces

Copy link
Copy Markdown
Contributor

[should-fix] parse_fields at crates/hyperswitch_connectors/src/connectors/worldpay/response.rs:80 uses serde_json::from_value(fields).ok() and silently drops deserialization errors.

A type mismatch or unexpected shape in a Worldpay response will be discarded as None, making integration issues hard to detect and affecting downstream error/code extraction. Consider logging the error or propagating it instead of swallowing it with .ok().

@XyneSpaces

Copy link
Copy Markdown
Contributor

⚠️ parse_fields in crates/hyperswitch_connectors/src/connectors/worldpay/response.rs silently discards serde deserialization errors via .ok(), so a malformed refusal response (e.g., a wrong field type) is reported as other_fields: None instead of failing parsing.

Replace the custom Deserialize impl with #[serde(default)] on the new Option<String> fields in RefusedResponse and keep the derived Deserialize on the untagged enum; this fixes the missing-field case without swallowing real parse errors.

@XyneSpaces

Copy link
Copy Markdown
Contributor

[should-fix] Deserialization failure is silently dropped

crates/hyperswitch_connectors/src/connectors/worldpay/response.rs:78 uses serde_json::from_value(fields).ok(), converting parse errors into None without logging. This makes malformed Worldpay responses impossible to debug and can mask integration breakage. Return Result<T, serde_json::Error> and propagate or log the error instead of using .ok().

…r outcome

`parse_fields` turned a deserialization error into `None` without a trace,
so a malformed Worldpay response was indistinguishable from one that carries
no extra fields. Log a warning with the outcome, the expected fields type and
the error before returning `None`.

Parsing stays lenient on purpose: capture, void and refund responses carry
only `_links`, and a refusal with one unexpected value should still be
recorded as a refusal rather than failing the whole response.

The logged error omits response values, which serde quotes in messages such
as `invalid type: string "..."`; only `missing field` messages are kept verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@errmakov

Copy link
Copy Markdown
Contributor Author

@XyneSpaces thanks for the review. Addressed in fb6cb31.

Silently dropped errors. parse_fields now logs a warn with the outcome, the expected fields type and the error before returning None, so a Worldpay response that doesn't match its outcome is visible in the logs. The logged error omits response values, since serde quotes them in messages like invalid type: string "..." and they can include tokens or JWTs; missing field messages are kept as they are. Covered by refused_with_mistyped_field_has_no_other_fields, deserialization_error_detail_keeps_missing_field_name and deserialization_error_detail_omits_response_values.

I kept the result lenient rather than propagating the error:

  • Capture, void and refund responses (sentForSettlement, sentForCancellation, sentForRefund) legitimately carry only _links, so failing on a mismatch would break those flows.
  • A refusal with one unexpected value, for example a new riskFactors type from Worldpay, should still be recorded as a failed payment, not become a response deserialization error.
  • It matches the behaviour before this PR, where the flattened Option also became None on a mismatch, just without a log.

#[serde(default)] with the derived untagged enum. That brings back the problem raised in the review of #8763. Serde already treats a missing Option field as None, so once refusalCode and refusalDescription are optional, RefusedResponse accepts almost any object. As the first variant of an untagged enum, it would also match authorized, 3DS challenge and device data collection responses, and those payments would lose their redirect and mandate data. Reading outcome first avoids that.

@XyneSpaces

Copy link
Copy Markdown
Contributor

[should-fix] The Reason::Refused arm maps refusal details to ErrorReason, but the catch-all _ => None silently drops every other refusal-shaped variant. This loses the actual refusal code/description that the PR title says should now be handled.

Either exhaustively map all refusal variants or preserve the raw refusal object in the fallback arm instead of returning None.

@errmakov

errmakov commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@XyneSpaces I think this one doesn't match the code in this PR: there is no Reason::Refused arm or ErrorReason type in the Worldpay connector, and the PR doesn't add a _ => None.

The closest _ => None is the existing optional_error_message match (transformers.rs#L910-L919), which only supplies messages for 3dsAuthenticationFailed, 3dsUnavailable and fraudHighRisk. Refusal details don't go through it:

  • The RefusedResponse arm (L893) carries refusalCode, refusalDescription and advice.code.
  • The Err(ErrorResponse { .. }) arm (L955) turns them into code, message, network_decline_code, network_error_message and network_advice_code.

A refused response takes that path whether both refusal fields are present, only one, or neither. The only exception is a body whose fields have the wrong type (e.g. a numeric refusalCode), which is logged as a mismatch and has no extra fields, as covered by refused_with_mistyped_field_has_no_other_fields.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] handle refused responses when only one of refusalCode or refusalDescription is present for Worldpay

2 participants