From e0a969ebcf88fe7627a24a3a2cb557b6c76b8872 Mon Sep 17 00:00:00 2001 From: shuklatushar226 Date: Mon, 3 Aug 2026 03:43:37 +0530 Subject: [PATCH 1/4] feat(connector): implement IncrementalAuthorization for paypal --- .../src/connectors/paypal.rs | 152 +++++++++- .../src/connectors/paypal/transformers.rs | 262 ++++++++++++++++-- crates/types-traits/domain_types/src/types.rs | 23 +- 3 files changed, 396 insertions(+), 41 deletions(-) diff --git a/crates/integrations/connector-integration/src/connectors/paypal.rs b/crates/integrations/connector-integration/src/connectors/paypal.rs index 4e06f0f726..080ddff78f 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal.rs @@ -21,11 +21,11 @@ use domain_types::{ connector_types::{ ClientAuthenticationTokenRequestData, EventContext, PaymentCreateOrderData, PaymentCreateOrderResponse, PaymentFlowData, PaymentVoidData, PaymentsAuthorizeData, - PaymentsCaptureData, PaymentsPostAuthenticateData, PaymentsResponseData, PaymentsSyncData, - RefundFlowData, RefundSyncData, RefundsData, RefundsResponseData, RepeatPaymentData, - RequestDetails, ServerAuthenticationTokenRequestData, - ServerAuthenticationTokenResponseData, SetupMandateRequestData, - VerifyWebhookSourceFlowData, + PaymentsCaptureData, PaymentsIncrementalAuthorizationData, PaymentsPostAuthenticateData, + PaymentsResponseData, PaymentsSyncData, RefundFlowData, RefundSyncData, RefundsData, + RefundsResponseData, RepeatPaymentData, RequestDetails, + ServerAuthenticationTokenRequestData, ServerAuthenticationTokenResponseData, + SetupMandateRequestData, VerifyWebhookSourceFlowData, }, merchant_authentication_flow_data::MerchantAuthenticationFlowData, payment_method_data::{PaymentMethodData, PaymentMethodDataTypes, WalletData}, @@ -49,11 +49,12 @@ use crate::{ connectors::paypal::transformers::{ self as paypal, auth_headers, constants as paypal_constants, PaypalAuthResponse, PaypalAuthUpdateRequest, PaypalAuthUpdateResponse, PaypalCaptureResponse, - PaypalClientAuthTokenRequest, PaypalClientAuthTokenResponse, PaypalOrderCreateRequest, - PaypalOrderCreateResponse, PaypalPaymentsCancelResponse, PaypalPaymentsCaptureRequest, - PaypalPaymentsRequest, PaypalRefundRequest, PaypalRepeatPaymentRequest, - PaypalRepeatPaymentResponse, PaypalSetupMandatesResponse, PaypalSyncResponse, - PaypalZeroMandateRequest, RefundResponse, RefundSyncResponse, + PaypalClientAuthTokenRequest, PaypalClientAuthTokenResponse, PaypalIncrementalAuthRequest, + PaypalIncrementalAuthResponse, PaypalOrderCreateRequest, PaypalOrderCreateResponse, + PaypalPaymentsCancelResponse, PaypalPaymentsCaptureRequest, PaypalPaymentsRequest, + PaypalRefundRequest, PaypalRepeatPaymentRequest, PaypalRepeatPaymentResponse, + PaypalSetupMandatesResponse, PaypalSyncResponse, PaypalZeroMandateRequest, RefundResponse, + RefundSyncResponse, }, types::ResponseRouterData, utils::{self, ConnectorErrorType, ConnectorErrorTypeMapping}, @@ -106,6 +107,10 @@ impl connector_types::PaymentCapture for Paypal { } +impl + connector_types::PaymentIncrementalAuthorization for Paypal +{ +} impl connector_types::ValidationTrait for Paypal { @@ -461,6 +466,12 @@ macros::create_all_prerequisites!( response_body: PaypalPaymentsCancelResponse, router_data: RouterDataV2, ), + ( + flow: IncrementalAuthorization, + request_body: PaypalIncrementalAuthRequest, + response_body: PaypalIncrementalAuthResponse, + router_data: RouterDataV2, + ), ( flow: ServerAuthenticationToken, request_body: PaypalAuthUpdateRequest, @@ -1036,6 +1047,118 @@ macros::macro_connector_implementation!( } ); +// IncrementalAuthorization — POST /v2/payments/authorizations/{id}/reauthorize. +// +// PayPal has no dedicated incremental-authorization API; "Reauthorize Authorized Payment" is the +// operation this flow maps onto. It targets the authorization id that the original Authorize call +// handed back in `connector_feature_data`, and mints a NEW authorization id in the response. +// Both HTTP 201 (created) and HTTP 200 (idempotent replay of the same `PayPal-Request-Id`) are +// success and carry the same `authorization-2` schema. +// Doc: https://developer.paypal.com/docs/api/payments/v2/#authorizations_reauthorize +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Paypal, + curl_request: Json(PaypalIncrementalAuthRequest), + curl_response: PaypalIncrementalAuthResponse, + flow_name: IncrementalAuthorization, + resource_common_data: PaymentFlowData, + flow_request: PaymentsIncrementalAuthorizationData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, IntegrationError> { + let access_token = req.resource_common_data + .access_token + .clone() + .ok_or_else(|| report!(IntegrationError::FailedToObtainAuthType { + context: paypal::paypal_err_ctx( + "PayPal authorizes every Payments v2 call with an OAuth 2.0 bearer token; \ + no access token was present on the IncrementalAuthorization request", + "Call MerchantAuthenticationService/CreateServerAuthenticationToken first \ + and pass the result in `state.access_token`", + ), + })) + .attach_printable("Missing access_token for PayPal reauthorize")?; + let connector_metadata = req.resource_common_data.connector_feature_data + .as_ref() + .map(|secret| secret.clone().expose()); + self.build_headers( + &access_token.access_token.expose(), + &req.resource_common_data.connector_request_reference_id, + &req.connector_config, + connector_metadata.as_ref(), + ) + } + + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + let connector_metadata_value = req + .request + .connector_feature_data + .clone() + .map(|secret| secret.expose()) + .ok_or_else(|| report!(IntegrationError::MissingRequiredField { + field_name: "connector_feature_data", + context: paypal::paypal_err_ctx( + "PayPal reauthorize is addressed by the authorization id minted by the \ + original Authorize call, which UCS returns to the caller inside \ + `connector_feature_data`", + "Echo the `connector_feature_data` from the Authorize response back on the \ + IncrementalAuthorization request", + ), + })) + .attach_printable( + "connector_feature_data absent on PayPal IncrementalAuthorization request", + )?; + + let paypal_meta: paypal::PaypalMeta = serde_json::from_value(connector_metadata_value) + .change_context(IntegrationError::InvalidDataFormat { + field_name: "connector_feature_data", + context: paypal::paypal_err_ctx( + "`connector_feature_data` could not be parsed as the PayPal connector \ + metadata object that the Authorize response emits", + "Pass the `connector_feature_data` value through unmodified instead of \ + reconstructing it", + ), + }) + .attach_printable("Failed to deserialize PaypalMeta from connector_feature_data")?; + + let incremental_authorization_id = paypal_meta + .incremental_authorization_id + .ok_or_else(|| report!(IntegrationError::MissingRequiredField { + field_name: "connector_feature_data.incremental_authorization_id", + context: paypal::paypal_err_ctx( + "PayPal only populates an incremental authorization id when the original \ + payment was created with intent AUTHORIZE and \ + `request_incremental_authorization` set; there is nothing to reauthorize \ + without it", + "Authorize with `capture_method: MANUAL` and \ + `request_incremental_authorization: true`, then reuse the resulting \ + `connector_feature_data`", + ), + })) + .attach_printable( + "PaypalMeta.incremental_authorization_id missing — cannot build reauthorize URL", + )?; + + Ok(format!( + "{}{}/{}/{}", + self.connector_base_url_payments(req), + paypal_constants::AUTHORIZATIONS_PATH, + incremental_authorization_id, + paypal_constants::REAUTHORIZE_ACTION, + )) + } + } +); + macros::macro_connector_implementation!( connector_default_implementations: [get_content_type, get_error_response_v2], connector: Paypal, @@ -1744,6 +1867,12 @@ impl Conn .or(Some(err_reason)), None => Some(response.message.to_owned()), }; + // PayPal's non-2xx envelope always carries a top-level `name` (e.g. UNPROCESSABLE_ENTITY) + // and `message`, and usually a more specific `details[].issue` + // (e.g. REAUTHORIZATION_TOO_SOON). Prefer the issue, fall back to the envelope's own + // name/message, and only report the NO_ERROR_* placeholders if PayPal really sent neither. + let error_name = response.name.clone(); + let error_message = response.message.clone(); let errors_list = response.details.unwrap_or_default(); let option_error_code_message = utils::get_error_code_error_message_based_on_priority( self.clone(), @@ -1758,9 +1887,11 @@ impl Conn code: option_error_code_message .clone() .map(|error_code_message| error_code_message.error_code) + .or(error_name) .unwrap_or(NO_ERROR_CODE.to_string()), message: option_error_code_message .map(|error_code_message| error_code_message.error_message) + .or(Some(error_message)) .unwrap_or(NO_ERROR_MESSAGE.to_string()), reason, attempt_status: None, @@ -1791,7 +1922,6 @@ macros::macro_connector_flow_status_impls!( generic_type: T, [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], not_implemented: [ - IncrementalAuthorization, Accept, SubmitEvidence, DefendDispute, diff --git a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs index bcfe11415b..691a531081 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs @@ -13,13 +13,14 @@ use common_utils::{ }; use domain_types::{ connector_flow::{ - Authorize, Capture, ClientAuthenticationToken, CreateOrder, PSync, PostAuthenticate, - RepeatPayment, VerifyWebhookSource, + Authorize, Capture, ClientAuthenticationToken, CreateOrder, IncrementalAuthorization, + PSync, PostAuthenticate, RepeatPayment, VerifyWebhookSource, }, connector_types::{ ClientAuthenticationTokenData, ClientAuthenticationTokenRequestData, MandateReference, PaymentCreateOrderData, PaymentCreateOrderResponse, PaymentFlowData, PaymentsAuthorizeData, - PaymentsCaptureData, PaymentsPostAuthenticateData, PaymentsResponseData, PaymentsSyncData, + PaymentsCaptureData, PaymentsIncrementalAuthorizationData, PaymentsPostAuthenticateData, + PaymentsResponseData, PaymentsSyncData, PaypalClientAuthenticationResponse as PaypalClientAuthenticationResponseDomain, PaypalFlow as PaypalFlowDomain, PaypalTransactionInfo as PaypalTransactionInfoDomain, RefundFlowData, RefundSyncData, RefundsData, RefundsResponseData, RepeatPaymentData, @@ -84,6 +85,34 @@ pub mod constants { pub const DEFAULT_NOTIFICATION_LANGUAGE: &str = "en-US"; pub const DEFAULT_PARTNER_ATTRIBUTION_ID: &str = "HyperSwitchPPCP_SP"; pub const DEFAULT_LEGACY_PARTNER_ATTRIBUTION_ID: &str = "HyperSwitchlegacy_Ecom"; + + /// Path prefix of the PayPal Payments v2 authorization resource. + /// Doc: + pub const AUTHORIZATIONS_PATH: &str = "v2/payments/authorizations"; + + /// Sub-resource action that reauthorizes an already authorized payment. PayPal offers no + /// dedicated incremental-authorization API; `reauthorize` is the operation Hyperswitch's + /// `IncrementalAuthorization` flow maps onto. + /// Doc: + pub const REAUTHORIZE_ACTION: &str = "reauthorize"; +} + +/// Documentation surfaced on every `IntegrationError` this connector raises, so a caller who hits +/// a validation failure has a direct pointer to the PayPal reference for the field in question. +pub const PAYPAL_INTEGRATION_DOC_URL: &str = "https://developer.paypal.com/docs/api/payments/v2/"; + +/// Single construction point for [`domain_types::errors::IntegrationErrorContext`] in the PayPal +/// connector. Every error site passes a concrete `additional_context` (what is missing and why +/// PayPal needs it) and a concrete `suggested_action` (what the caller should change). +pub(crate) fn paypal_err_ctx( + additional_context: impl Into, + suggested_action: impl Into, +) -> domain_types::errors::IntegrationErrorContext { + domain_types::errors::IntegrationErrorContext { + additional_context: Some(additional_context.into()), + suggested_action: Some(suggested_action.into()), + doc_url: Some(PAYPAL_INTEGRATION_DOC_URL.to_string()), + } } const ORDER_QUANTITY: u16 = 1; @@ -1874,55 +1903,232 @@ impl TryFrom> } } -#[derive(Debug, Clone, Deserialize, Serialize)] +// ---------------------------------------------------------------------------- +// IncrementalAuthorization — POST /v2/payments/authorizations/{id}/reauthorize +// +// PayPal exposes no dedicated incremental-authorization API. The closest operation, and the one +// Hyperswitch's `IncrementalAuthorization` flow maps onto, is "Reauthorize Authorized Payment": +// it refreshes the 3-day honor period on an existing authorization and, where the network and +// geography permit, changes the held amount. It mints a NEW authorization id. +// Doc: +// ---------------------------------------------------------------------------- + +/// Status of a PayPal authorization resource (`authorization-2.status`), as returned by the +/// reauthorize endpoint. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, strum::Display)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum PaypalIncrementalStatus { - CREATED, - CAPTURED, - DENIED, - PARTIALLYCAPTURED, - VOIDED, - PENDING, + /// Funds are held under the newly minted authorization — the success terminal state for a + /// reauthorization. + Created, + Captured, + PartiallyCaptured, + Denied, + Pending, + Voided, + Expired, + /// PayPal's published OpenAPI enum and its integration guides disagree on the exact variant + /// list for this field, so an unrecognised value must not fail deserialization of an + /// otherwise successful reauthorization. Mapped to `AuthorizationStatus::Unresolved`. + #[serde(other)] + Unknown, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct PaypalNetworkTransactionReference { id: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct PaypalIncrementalAuthStatusDetails { + /// PayPal only populates a reason when it has one to give (risk hold / manual review), so it + /// is absent on a plain `PENDING`. reason: Option, } -#[derive(Debug, Deserialize, Serialize, strum::Display)] +#[derive(Debug, Clone, Deserialize, Serialize, strum::Display)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum PaypalStatusPendingReason { - PENDINGREVIEW, - DECLINEDBYRISKFRAUDFILTERS, + PendingReview, + DeclinedByRiskFraudFilters, + /// PayPal documents only the two reasons above but does not declare the enum closed; an + /// unrecognised reason is diagnostic-only and must never fail the response mapping. + #[serde(other)] + Unknown, } impl From for common_enums::AuthorizationStatus { fn from(item: PaypalIncrementalStatus) -> Self { + // Exhaustive on purpose — no wildcard arm, so a newly added PayPal status is a compile + // error here rather than a silently mis-mapped authorization. match item { - PaypalIncrementalStatus::CREATED - | PaypalIncrementalStatus::CAPTURED - | PaypalIncrementalStatus::PARTIALLYCAPTURED => Self::Success, - PaypalIncrementalStatus::PENDING => Self::Processing, - PaypalIncrementalStatus::DENIED | PaypalIncrementalStatus::VOIDED => Self::Failure, + // `CREATED` is the normal outcome of a reauthorization; `CAPTURED` / + // `PARTIALLY_CAPTURED` mean the (re)authorized funds have since been drawn down, which + // is still a successfully held authorization from this flow's point of view. + PaypalIncrementalStatus::Created + | PaypalIncrementalStatus::Captured + | PaypalIncrementalStatus::PartiallyCaptured => Self::Success, + PaypalIncrementalStatus::Pending => Self::Processing, + PaypalIncrementalStatus::Denied + | PaypalIncrementalStatus::Voided + | PaypalIncrementalStatus::Expired => Self::Failure, + PaypalIncrementalStatus::Unknown => { + tracing::warn!( + connector = "paypal", + flow = "incremental_authorization", + "PayPal returned an authorization status outside the documented enum; \ + reporting the authorization as Unresolved so it is reconciled rather than \ + wrongly settled or wrongly failed" + ); + Self::Unresolved + } } } } -impl From for common_enums::AttemptStatus { - fn from(item: PaypalIncrementalStatus) -> Self { - match item { - PaypalIncrementalStatus::CREATED - | PaypalIncrementalStatus::CAPTURED - | PaypalIncrementalStatus::PARTIALLYCAPTURED => Self::Authorized, - PaypalIncrementalStatus::PENDING => Self::Pending, - PaypalIncrementalStatus::DENIED | PaypalIncrementalStatus::VOIDED => Self::Failure, +/// Request body for `POST /v2/payments/authorizations/{authorization_id}/reauthorize`. +/// +/// PayPal documents `amount` as the only accepted request parameter on this endpoint — no +/// `invoice_id`, `final_capture`, `soft_descriptor` or `payment_instruction`. The body itself is +/// optional (omitting it reauthorizes the original amount unchanged), but UCS always carries an +/// explicit target amount on `PaymentsIncrementalAuthorizationData`, so it is always sent and the +/// field is therefore not optional. +#[derive(Debug, Clone, Serialize)] +pub struct PaypalIncrementalAuthRequest { + amount: OrderAmount, +} + +impl + TryFrom< + PaypalRouterData< + RouterDataV2< + IncrementalAuthorization, + PaymentFlowData, + PaymentsIncrementalAuthorizationData, + PaymentsResponseData, + >, + T, + >, + > for PaypalIncrementalAuthRequest +{ + type Error = Report; + fn try_from( + item: PaypalRouterData< + RouterDataV2< + IncrementalAuthorization, + PaymentFlowData, + PaymentsIncrementalAuthorizationData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + let request = &item.router_data.request; + // PayPal money values are currency-scaled decimal strings ("10.99"), never minor units, + // so reuse the connector's configured StringMajorUnit converter. + let value = item + .connector + .amount_converter + .convert(request.minor_amount, request.currency) + .change_context(IntegrationError::AmountConversionFailed { + context: paypal_err_ctx( + "PayPal expects the reauthorization amount as a currency-scaled decimal \ + string (for example \"10.99\"); converting the requested minor amount to \ + that representation failed", + "Check that `amount.minor_amount` and `amount.currency` form a valid amount \ + for the currency", + ), + }) + .attach_printable( + "Failed to convert minor_amount to StringMajorUnit for PayPal reauthorize", + )?; + Ok(Self { + amount: OrderAmount { + // PayPal rejects a reauthorization whose currency differs from the original + // authorization with issue `AUTH_CURRENCY_MISMATCH`. + currency_code: request.currency, + value, + }, + }) + } +} + +/// Response body of `POST /v2/payments/authorizations/{authorization_id}/reauthorize` +/// (schema `authorization-2`). HTTP 201 is the normal success; HTTP 200 is returned on an +/// idempotent replay of a previously seen `PayPal-Request-Id` and carries the same schema. +/// +/// Only `id` and `status` are guaranteed: with the default `Prefer: return=minimal` PayPal +/// returns just `id`, `status` and `links`. This connector sends `Prefer: return=representation` +/// (see `build_headers`), which is what populates the optional blocks below — but the header is a +/// preference, not a contract, so every one of them stays optional. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PaypalIncrementalAuthResponse { + /// The **new** PayPal-generated authorization id produced by the reauthorization. The previous + /// authorization id is superseded and must not be reused for capture or void. + id: String, + status: PaypalIncrementalStatus, + /// Present only when PayPal has a reason to report alongside the status — in practice when + /// `status` is `PENDING` because the authorization is risk-held or queued for manual review. + status_details: Option, + /// The reauthorized amount. Omitted under `Prefer: return=minimal`. + amount: Option, + /// Merchant invoice number carried over from the original authorization; absent when the + /// original order carried none, and under `Prefer: return=minimal`. + invoice_id: Option, + /// Merchant free-form identifier; absent when it was not set on the original order, and under + /// `Prefer: return=minimal`. + custom_id: Option, + /// Card-network transaction reference. PayPal only returns it for card-funded authorizations + /// where the network supplied one, so it is absent for PayPal-wallet-funded authorizations. + network_transaction_reference: Option, + /// HATEOAS links for the new authorization (`self`, `capture`, `void`, `reauthorize`). + /// Modelled as optional because PayPal omits the block entirely on some minimal + /// representations rather than returning an empty array. + links: Option>, +} + +impl TryFrom> + for RouterDataV2< + IncrementalAuthorization, + PaymentFlowData, + PaymentsIncrementalAuthorizationData, + PaymentsResponseData, + > +{ + type Error = Report; + fn try_from( + item: ResponseRouterData, + ) -> Result { + let response = item.response; + + // Diagnostic only: PayPal explains a held authorization through `status_details.reason`, + // which has no representation on `IncrementalAuthorizationResponse`. Log it rather than + // dropping it silently, and never fail an otherwise valid response on account of it. + if let Some(reason) = response + .status_details + .as_ref() + .and_then(|details| details.reason.as_ref()) + { + tracing::warn!( + connector = "paypal", + flow = "incremental_authorization", + paypal_status = %response.status, + paypal_status_reason = %reason, + "PayPal reported a status reason on the reauthorized authorization" + ); } + + Ok(Self { + response: Ok(PaymentsResponseData::IncrementalAuthorizationResponse { + status: common_enums::AuthorizationStatus::from(response.status), + // The reauthorization mints a new authorization id; surfacing it lets the caller + // address the subsequent capture/void at the authorization that actually holds + // the funds. + connector_authorization_id: Some(response.id), + status_code: item.http_code, + }), + ..item.router_data + }) } } diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index 6bf1cd7a75..62973c3166 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -9358,6 +9358,25 @@ impl let merchant_id_from_header = extract_merchant_id_from_metadata(metadata)?; + // Connectors whose incremental-authorization endpoint is OAuth-protected (PayPal) read the + // bearer token off `PaymentFlowData`, so carry `state.access_token` through exactly as the + // Capture flow does. + let access_token = value + .state + .as_ref() + .and_then(|state| state.access_token.as_ref()) + .map(ServerAuthenticationTokenResponseData::foreign_try_from) + .transpose()?; + + // Header construction (for example PayPal's partner-attribution headers) reads the feature + // data off `PaymentFlowData`, while URL construction reads it off the request data; both + // are sourced from the same caller-supplied `connector_feature_data`. + let connector_feature_data = value + .connector_feature_data + .clone() + .map(|m| ForeignTryFrom::foreign_try_from((m, "feature data"))) + .transpose()?; + Ok(Self { raw_connector_status: None, merchant_id: merchant_id_from_header, @@ -9374,10 +9393,10 @@ impl connector_customer: None, description: None, return_url: None, - connector_feature_data: None, + connector_feature_data, amount_captured: None, minor_amount_captured: None, - access_token: None, + access_token, session_token: None, reference_id: None, connector_order_id: None, From f634af85a0cff9dd21837d849c31f86950d5221c Mon Sep 17 00:00:00 2001 From: "hyperswitch-bot[bot]" <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:34:28 +0000 Subject: [PATCH 2/4] chore: auto-fix formatting and generated code Auto-applied by CI: - cargo +nightly fmt --all - make -C sdk generate (if applicable) - make docs (if applicable) This commit was automatically generated by GitHub Actions. --- data/field_probe/paypal.json | 4 ++-- docs-generated/all_connector.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/field_probe/paypal.json b/data/field_probe/paypal.json index 089a7649e2..4b813d5f03 100644 --- a/data/field_probe/paypal.json +++ b/data/field_probe/paypal.json @@ -1029,8 +1029,8 @@ }, "incremental_authorization": { "default": { - "status": "not_implemented", - "error": "This feature is not implemented: incremental_authorization flow for paypal" + "status": "error", + "error": "Stuck on field: connector_feature_data.incremental_authorization_id. PayPal only populates an incremental authorization id when the original payment was created with intent AUTHORIZE and `request_incremental_authorization` set; there is nothing to reauthorize without it — Missing required field: connector_feature_data.incremental_authorization_id. PayPal only populates an incremental authorization id when the original payment was created with intent AUTHORIZE and `request_incremental_authorization` set; there is nothing to reauthorize without it" } }, "parse_event": { diff --git a/docs-generated/all_connector.md b/docs-generated/all_connector.md index c995a47667..fcf37053dd 100644 --- a/docs-generated/all_connector.md +++ b/docs-generated/all_connector.md @@ -196,7 +196,7 @@ Consolidated view of Get, Void, Refund, Capture, Reverse, CreateOrder, and other | [Payconex](connectors/payconex.md) | ✓ | ✓ | x | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | x | x | x | x | x | x | x | ⚠ | x | x | ⚠ | ⚠ | x | | [Payload](connectors/payload.md) | ✓ | ✓ | x | ✓ | x | ✓ | x | ⚠ | ✓ | ✓ | x | ✓ | ✓ | ✓ | x | ✓ | x | ✓ | ⚠ | ⚠ | x | x | x | x | x | x | x | ✓ | x | x | x | x | ⚠ | x | x | ✓ | ⚠ | x | | [Payme](connectors/payme.md) | ✓ | ✓ | x | ✓ | ✓ | ✓ | x | ⚠ | ⚠ | x | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | x | x | x | ⚠ | ⚠ | ⚠ | x | ⚠ | x | x | ⚠ | ⚠ | x | -| [Paypal](connectors/paypal.md) | ✓ | ✓ | x | ✓ | ✓ | ✓ | ⚠ | ⚠ | ✓ | x | x | ✓ | ? | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | ✓ | x | ✓ | ⚠ | ⚠ | ? | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ✓ | x | +| [Paypal](connectors/paypal.md) | ✓ | ✓ | x | ✓ | ✓ | ✓ | ? | ⚠ | ✓ | x | x | ✓ | ? | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | ✓ | x | ✓ | ⚠ | ⚠ | ? | ⚠ | ⚠ | ⚠ | ⚠ | ✓ | ✓ | x | | [Paysafe](connectors/paysafe.md) | ✓ | ✓ | ⚠ | ✓ | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ✓ | ⚠ | ? | ⚠ | ? | ⚠ | ✓ | x | ✓ | ⚠ | ✓ | x | x | x | x | x | ⚠ | ⚠ | ⚠ | ✓ | ✓ | ⚠ | x | ⚠ | x | x | ⚠ | ⚠ | x | | [Paytm](connectors/paytm.md) | ✓ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ? | ⚠ | ? | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | x | ✓ | x | x | x | x | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | ⚠ | x | | [PayU](connectors/payu.md) | ✓ | ✓ | x | ✓ | ⚠ | ✓ | x | ⚠ | ⚠ | x | ⚠ | x | ⚠ | ⚠ | ⚠ | ✓ | x | ⚠ | ⚠ | ⚠ | x | x | x | x | x | ⚠ | ✓ | ⚠ | ⚠ | ⚠ | ⚠ | x | ⚠ | x | x | ⚠ | ⚠ | x | From 39c85e12764ab40685401ffea7ad2dcb646a4759 Mon Sep 17 00:00:00 2001 From: shuklatushar226 Date: Mon, 3 Aug 2026 04:26:11 +0530 Subject: [PATCH 3/4] feat(connector): add Wallet(PayPal) coverage for paypal IncrementalAuthorization Extend the IncrementalAuthorization flow for Paypal to Wallet(PayPal), covering both the PaypalRedirect and PaypalSdk sub-variants. No functional change was required. PayPal's REAUTHORIZE endpoint (POST /v2/payments/authorizations/{id}/reauthorize) is funding-source agnostic: the path selector is an authorization id, the request body accepts only `amount`, and the `authorization-2` response carries no funding-instrument block. The existing implementation was verified in code and at runtime to already work for Wallet(PayPal), so the diff is documentation only. - paypal.rs: comment on the IncrementalAuthorization macro block recording why it deliberately carries no funding-source branch. - transformers.rs: doc comment on `extract_incremental_authorization_id` recording which Authorize leg mints the authorization id per funding source (Card: create-order; Wallet/PaypalRedirect: the second Authorize carrying `connector_order_id`; Wallet/PaypalSdk: the single Authorize using the SDK token as the order id). --- .../src/connectors/paypal.rs | 9 ++++++++ .../src/connectors/paypal/transformers.rs | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/integrations/connector-integration/src/connectors/paypal.rs b/crates/integrations/connector-integration/src/connectors/paypal.rs index 080ddff78f..ec2819cbae 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal.rs @@ -1054,6 +1054,15 @@ macros::macro_connector_implementation!( // handed back in `connector_feature_data`, and mints a NEW authorization id in the response. // Both HTTP 201 (created) and HTTP 200 (idempotent replay of the same `PayPal-Request-Id`) are // success and carry the same `authorization-2` schema. +// +// The endpoint is funding-source agnostic, so this block is deliberately free of any +// payment-method branch: the path selector is an authorization id (not an order or a funding +// instrument), the request body accepts only `amount`, and `authorization-2` carries no +// funding-instrument block. PayPal's own operation description is in fact written for the wallet +// case — "Reauthorizes an authorized PayPal account payment, by ID." Card and +// Wallet(PayPal) — both `PaypalRedirect` and `PaypalSdk` — therefore share this code verbatim; +// they differ only in which Authorize leg mints the authorization id (see +// `extract_incremental_authorization_id`). // Doc: https://developer.paypal.com/docs/api/payments/v2/#authorizations_reauthorize macros::macro_connector_implementation!( connector_default_implementations: [get_content_type, get_error_response_v2], diff --git a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs index 691a531081..d1fbb9cfc0 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs @@ -2518,6 +2518,28 @@ fn get_id_based_on_intent( }) } +/// Pulls the authorization id that `IncrementalAuthorization` later reauthorizes out of an +/// `intent: AUTHORIZE` order response. +/// +/// This is funding-source agnostic — every funding source converges on +/// `purchase_units[].payments.authorizations[0].id`. What differs is which Authorize leg produces +/// the response this runs against: +/// +/// * **Card** — the create-order call itself (`POST /v2/checkout/orders` with +/// `payment_source.card`) already carries the authorization, so the id is available on the first +/// Authorize. +/// * **Wallet — `PaypalRedirect`** — create-order returns `PAYER_ACTION_REQUIRED` with no +/// `payments` block at all, so it deserializes as [`PaypalRedirectResponse`] and correctly yields +/// no id. The authorization only exists after the buyer approves at PayPal and the caller issues +/// a second Authorize carrying `connector_order_id`, which posts to +/// `/v2/checkout/orders/{order_id}/authorize`; that response is a full order object and lands +/// here. +/// * **Wallet — `PaypalSdk`** — the JS SDK performs create-order plus approval client-side, so the +/// single Authorize posts straight to `/v2/checkout/orders/{sdk_token}/authorize` and lands here +/// on the first call. +/// +/// Returns `None` rather than erroring: an order that legitimately has no authorization yet (the +/// wallet redirect leg, or an `intent: CAPTURE` order) must not fail an otherwise valid response. fn extract_incremental_authorization_id(response: &PaypalOrdersResponse) -> Option { for unit in &response.purchase_units { if let Some(authorizations) = &unit.payments.authorizations { From ab913653fc03399337d320bdc2644fcd17e15915 Mon Sep 17 00:00:00 2001 From: shuklatushar226 Date: Mon, 3 Aug 2026 04:52:35 +0530 Subject: [PATCH 4/4] fix(paypal): parity-review nits on IncrementalAuthorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the parity review against the hyperswitch reference: - `PaypalIncrementalStatus` derived `strum::Display` without a casing directive. `serde(rename_all)` governs only the wire format, so a status PayPal sent as `PARTIALLY_CAPTURED` was logged as `PartiallyCaptured`. Added `strum(serialize_all = "SCREAMING_SNAKE_CASE")` so logs quote the value PayPal actually sent. - The `connector_feature_data` parse failure on the IncrementalAuthorization request reported `field_name: "unknown"` with no suggested action. Named the field and filled in the context — this is the field carrying the authorization id the reauthorize call targets, so an opaque error here is the difference between a one-line fix and a debugging session. --- .../src/connectors/paypal/transformers.rs | 3 +++ crates/types-traits/domain_types/src/types.rs | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs index d1fbb9cfc0..07c98f5514 100644 --- a/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/paypal/transformers.rs @@ -1917,6 +1917,9 @@ impl TryFrom> /// reauthorize endpoint. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, strum::Display)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] +// `serde(rename_all)` governs the wire format only; `strum` needs its own casing directive or +// `Display` would log `PartiallyCaptured` where PayPal sent `PARTIALLY_CAPTURED`. +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum PaypalIncrementalStatus { /// Funds are held under the newly minted authorization — the success terminal state for a /// reauthorization. diff --git a/crates/types-traits/domain_types/src/types.rs b/crates/types-traits/domain_types/src/types.rs index 62973c3166..559a9a92a8 100644 --- a/crates/types-traits/domain_types/src/types.rs +++ b/crates/types-traits/domain_types/src/types.rs @@ -9309,9 +9309,20 @@ impl ForeignTryFrom .map(|metadata| serde_json::from_str(&metadata.expose())) .transpose() .change_context(IntegrationError::InvalidDataFormat { - field_name: "unknown", + field_name: "connector_feature_data", context: IntegrationErrorContext { - additional_context: Some("Failed to parse connector metadata".to_string()), + additional_context: Some( + "Failed to parse connector_feature_data on the IncrementalAuthorization \ + request as JSON. This field carries the connector-specific metadata \ + persisted by the original Authorize (for PayPal, the authorization id \ + the reauthorize call targets)." + .to_string(), + ), + suggested_action: Some( + "Send connector_feature_data exactly as it was returned by the Authorize \ + response for this payment, without re-encoding it." + .to_string(), + ), ..Default::default() }, })?;