Skip to content

feat(paysafe): support Apple Pay / Google Pay recurring (CIT→MIT) - #13410

Open
shuklatushar226 wants to merge 9 commits into
mainfrom
feat/paysafe-wallet-recurring-main
Open

feat(paysafe): support Apple Pay / Google Pay recurring (CIT→MIT)#13410
shuklatushar226 wants to merge 9 commits into
mainfrom
feat/paysafe-wallet-recurring-main

Conversation

@shuklatushar226

@shuklatushar226 shuklatushar226 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Wires the hyperswitch side of Paysafe wallet recurring so a wallet CIT vaults a reusable payment method that a later MIT can replay. Card recurring already worked; this closes the gaps for Apple Pay and Google Pay. Pairs with the UCS-side connector change (juspay/hyperswitch-prism#1986).

Changes

  • paysafe.rsshould_call_connector_customer fires for Apple Pay / Google Pay too (not just Card), so wallets get a Paysafe connector customer to vault under. New requires_wallet_vault_conversion capability opts Paysafe into the conversion leg.
  • ConnectorSpecifications — adds requires_wallet_vault_conversion (default false) + ConnectorEnum forwarding, so core stays connector-agnostic instead of hardcoding a connector name in the token gateway.
  • payment_method_token_create_gateway.rs — after leg-1 mints a single-use wallet handle, a second tokenize (in its own UCS logging wrapper) converts it into a customer-vaulted MULTI_USE handle via connector_feature_data. Fully fail-soft: any leg-2 failure keeps the single-use handle so the payment still succeeds (only a future MIT is not replayable). Requires Clone on GrpcHeadersUcs / UcsReferenceId / UcsResourceId.
  • payments.rs — runs connector tokenization for a one-shot PaymentCreate with confirm=true (not just standalone Confirm), via a new is_operation_create helper with a unit test. ⚠️ Behavior change: this now triggers connector tokenization on one-shot create+confirm for any connector that has a [tokenization] entry configured — a general fix, but worth noting for connectors beyond Paysafe.
  • tokenization.rs — under mock_locker (local dev only; deployment configs omit it → prod/CI use the real locker) the wallet vault call has no backend; degrade to a locker-less payment method so the mandate (connector_mandate_details) is still saved.
  • config — enable google_pay in the paysafe [tokenization] filter, add paysafe to the apple_pay/google_pay mandate connector lists, and route paysafe UCS-only (the native connector does not implement the wallet flows) — applied to development.toml and all deployment configs (integration_test/sandbox/production). If your org uses a separate deployment-config repo, those need the same edits.

Testing

Verified end-to-end (hyperswitch → UCS → Paysafe sandbox):

  • Google Pay real DIRECT-decrypt token: CIT succeeded with payment_method_id (mandate is a converted MULTI_USE handle) → MIT succeeded.
  • Apple Pay real Safari PKPaymentToken: CIT succeeded with payment_method_id → MIT ×2 succeeded.
  • Card CIT→MIT regression: green (confirms the capability flag correctly skips conversion for cards and the one-shot gate works).

🤖 Generated with Claude Code

Closes

Closes juspay/hyperswitch-cloud#22283

@shuklatushar226
shuklatushar226 requested review from a team as code owners July 21, 2026 16:33
@semanticdiff-com

semanticdiff-com Bot commented Jul 21, 2026

Copy link
Copy Markdown

@shuklatushar226
shuklatushar226 force-pushed the feat/paysafe-wallet-recurring-main branch 2 times, most recently from 12c0b54 to 3e349a1 Compare July 22, 2026 07:06

[grpc_client.unified_connector_service]
ucs_only_connectors = "imerchantsolutions, paytm, phonepe, hyperpg, revolv3, fiservcommercehub, absa_sanlam, interpayments, payconex, dlocal, barclaycard, tsys_transit, jpmorgan, datatrans" # Comma-separated list of connectors that use UCS only
ucs_only_connectors = "imerchantsolutions, paytm, phonepe, hyperpg, revolv3, fiservcommercehub, absa_sanlam, interpayments, payconex, dlocal, barclaycard, tsys_transit, jpmorgan, datatrans, paysafe" # Comma-separated list of connectors that use UCS only

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it confirmed that no merchant uses paysafe through HS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

@shuklatushar226
shuklatushar226 force-pushed the feat/paysafe-wallet-recurring-main branch from 3e349a1 to 84ae16f Compare July 22, 2026 10:31
@shuklatushar226 shuklatushar226 added the S-test-ready Status: This PR is ready for cypress-tests label Jul 22, 2026
@github-actions github-actions Bot removed the S-test-ready Status: This PR is ready for cypress-tests label Jul 22, 2026
JeevaRamu0104
JeevaRamu0104 previously approved these changes Jul 23, 2026
Comment on lines +1390 to +1392
// integration-test/sandbox/production. The catch is intentionally
// broad: under `mock_locker` every wallet vault attempt fails the
// same way, so there is no narrower error to match on.

@hrithikesh026 hrithikesh026 Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock locker behaviour must be changed to accommodate wallet use case as well. instead of handling here separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved the mock support into the vault layer instead of the call site. vault::call_to_vault now has a mock_locker branch that synthesizes the fingerprint / add-vault responses, mirroring the legacy card locker's add_card_to_vault mock branch. Since wallets, bank_debit and v2 all share call_to_vault, this fixes them together at one choke point, so the special-case wallet fallback (and the broad Err(_) if mock_locker catch) in tokenization.rs is removed entirely. Pushed in 01b77ac.

.merchant_reference_id(leg2_merchant_reference_id)
.resource_id(leg2_resource_id)
.lineage_ids(leg2_lineage_ids);
let leg2_outcome = Box::pin(unified_connector_service::ucs_logging_wrapper_granular(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are moving from a granular approach to composite. Should not be done. Core must handle this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the second (wallet vault-conversion) call is no longer in the gateway. execute() is a single granular UCS call again, matching every other granular gateway. Core now sequences the conversion: tokenization::add_payment_method_token runs a second execute_payment_gateway after leg-1, gated by the requires_wallet_vault_conversion capability flag, fully fail-soft. The leg-1 token is handed to the second call via a new connector_feature_data field on PaymentMethodTokenizationData (mapped in the tokenize request builder) instead of the gateway mutating the gRPC request — so each gateway call stays one gRPC call and core owns the sequence, mirroring the create_order_at_connectoradd_payment_method_token idiom. Pushed in 01b77ac.

Wires the hyperswitch side of Paysafe wallet recurring so a wallet CIT
vaults a reusable payment method that a later MIT can replay. Card
recurring already worked; this closes the gaps for Apple Pay and Google
Pay. Pairs with the UCS-side connector change (juspay/hyperswitch-prism#1986).

- paysafe.rs: `should_call_connector_customer` fires for Apple Pay /
  Google Pay too (not just Card); new `requires_wallet_vault_conversion`
  capability opts Paysafe into the wallet-vault-conversion leg.
- ConnectorSpecifications: add `requires_wallet_vault_conversion` (default
  false) + ConnectorEnum forwarding, so core stays connector-agnostic
  instead of hardcoding a connector name in the token gateway.
- payment_method_token_create_gateway.rs: after leg-1 mints a single-use
  wallet handle, a second tokenize (its own UCS logging wrapper) converts
  it into a customer-vaulted MULTI_USE handle via connector_feature_data.
  Fully fail-soft — any leg-2 failure keeps the single-use handle so the
  payment still succeeds (only a future MIT is not replayable). Needs Clone
  on GrpcHeadersUcs / UcsReferenceId / UcsResourceId to reuse the request.
- payments.rs: run connector tokenization for a one-shot PaymentCreate with
  confirm=true, not just the standalone Confirm operation, via a new
  `is_operation_create` helper (+ unit test guarding the Debug-name match).
  NOTE: this is a general behavior change — any connector with a
  [tokenization] entry now tokenizes on one-shot create+confirm.
- tokenization.rs: under mock_locker (local dev only) the wallet vault call
  has no backend; degrade to a locker-less payment method so the mandate
  (connector_mandate_details) is still saved.
- config: enable google_pay in the paysafe [tokenization] filter, add
  paysafe to the apple_pay/google_pay mandate connector lists, and route
  paysafe UCS-only (the native connector does not implement the wallet
  flows) — in development.toml and all deployment configs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// v2 vaulting work in local dev instead of failing on a live call to a vault
// that isn't running. Only the flows the tokenization path exercises are
// mocked; the rest fall through to the real call (unchanged behaviour).
if locker.mock_locker {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this again seems like a workaround style implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved the mock off inline synthesis onto the established DB-backed convention. The mock_locker branch in call_to_vault now delegates to a dedicated mock_call_to_vault that persists to / reads back from the locker_mock_up table via StorageInterface, mirroring the legacy card locker's mock_call_to_locker_hs. So an add actually inserts a row (genuinely retrievable and deletable) instead of returning throwaway ids, and all five vault flows are covered (add / get-fingerprint / retrieve / delete / entity-create).

Kept it central in call_to_vault rather than at the typed wrappers because ~15 call sites hit call_to_vault directly (cards.rs, payment_methods.rs, tokenization.rs, workflows) — the choke point is the only place that covers them uniformly.

Verified under mock_locker = true: a wallet CIT inserts a locker_mock_up row holding the decrypted vaulting data keyed by the vault id, and CIT→MIT still replays. Pushed in 231d213.

… instead of inline synthesis

The `mock_locker` branch in `call_to_vault` synthesized throwaway JSON inline for
only the add/get-fingerprint flows. Replace it with a dedicated `mock_call_to_vault`
that persists to / reads back from the `locker_mock_up` table via `StorageInterface`
— mirroring the legacy card locker's `mock_call_to_locker_hs` — so an add is
genuinely retrievable and deletable, and all vault flows (add, get-fingerprint,
retrieve, delete, entity-create) are covered. Kept central in `call_to_vault`
because ~15 call sites hit it directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hrithikesh026
hrithikesh026 previously approved these changes Jul 24, 2026
@shuklatushar226 shuklatushar226 self-assigned this Jul 24, 2026
@shuklatushar226 shuklatushar226 added the S-test-ready Status: This PR is ready for cypress-tests label Jul 24, 2026
…token stays under clippy::large_futures

The leg-2 (Paysafe wallet MULTI_USE conversion) block held an extra RouterData
clone plus a second execute_payment_gateway future inline, pushing
add_payment_method_token's future to 18176 bytes and tripping clippy::large_futures
(-D warnings under v2) in the four flows that await it (authorize / complete_authorize
/ setup_mandate / external_proxy). Box the cloned router data and the second gateway
future so leg-2's state is smaller than leg-1's, which was already under the limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ta literals in connector tests

The PR added `connector_feature_data` to `PaymentMethodTokenizationData` but the
square/stax/utils connector-test constructors still built it without the field,
failing compilation of the router test target (E0063). Default the field to `None`
in those 10 literals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@deepanshu-iiitu deepanshu-iiitu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connector changes look good

hrithikesh026
hrithikesh026 previously approved these changes Jul 24, 2026
@github-actions github-actions Bot removed the S-test-ready Status: This PR is ready for cypress-tests label Jul 27, 2026
@shuklatushar226 shuklatushar226 added the S-test-ready Status: This PR is ready for cypress-tests label Jul 28, 2026
Comment thread crates/router/src/core/payments.rs Outdated
Comment on lines +9257 to +9259
let is_confirm_leg = is_operation_confirm(&operation)
|| (is_operation_create(&operation) && payment_data.get_payment_attempt().confirm);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of Payment create with confirm = true / s2s flows the operation will be changed to confirm. Check if_not_create_change_operation in payment create get_trackers

Comment on lines +2004 to +2006
if locker.mock_locker {
return mock_call_to_vault::<V>(state, payload).await;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want to have mock_locker flow ?

Comment on lines +1860 to +1869
match Box::pin(gateway::execute_payment_gateway(
state,
conversion_integration,
&conversion_router_data,
payments::CallConnectorAction::Trigger,
None,
None,
gateway_context.clone(),
))
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking that any additional connector calls for setup_future_usage could be handled within the Payments Core itself. With this modular flow, these calls are expected to be handled by the Payments Core logic rather than by the Payment Methods layer.

@github-actions github-actions Bot removed the S-test-ready Status: This PR is ready for cypress-tests label Jul 28, 2026
Three review comments from @ShankarSinghC on #13410.

1. Remove the dead `is_operation_create` gate (payments.rs)

   For POST /payments with confirm=true the operation is already swapped to
   PaymentConfirm before this code runs: PaymentCreate::get_trackers calls
   if_not_create_change_operation and returns the swapped op in
   GetTrackerResponse, which payments_operation_core shadows its binding with.
   Independently, should_call_connector has no "PaymentCreate" arm, so a
   still-PaymentCreate operation would yield no connector and never reach this
   function. The disjunct was unreachable; the guard is back to
   is_operation_confirm and the helper is gone.

   Note: payments.rs:5465-5472 uses the same ("PaymentCreate", true) match and
   is dead for the same reason, but is already on main (12fd469, #13163).
   Left alone here; follow-up to be raised.

2. Drop the mock_locker vault work (payment_methods/vault.rs)

   Reverted to main. It is not needed for this feature in any real environment
   (integration_test/sandbox/production carry no [locker] section), and the
   guard sat at the shared call_to_vault, changing behaviour for ~16 call
   sites that previously always reached the real vault.

3. Sequence the wallet vault conversion from Payments Core (Feature step)

   The second tokenization pass no longer fires from inside
   tokenization::add_payment_method_token, which core only reaches through the
   Feature trait. It is now its own step declared on Feature with a no-op
   default, implemented for authorize and setup_mandate, and sequenced from
   call_connector_service (v1 and v2) alongside create_order_at_connector and
   the connector-customer call - the same shape as
   should_call_connector_customer, which is the sibling setup_future_usage
   connector call for this feature and already lived in core.
   add_payment_method_token returns to exactly what it was on main.

   The step runs before update_router_data_with_payment_method_token_result
   and takes/returns PaymentMethodTokenResult. Order matters:
   build_unified_connector_service_payment_method early-returns a bare Token
   payment method when payment_method_token is PaymentMethodToken::Token, so
   folding the first pass's handle in first would make the conversion request
   carry payment_method: Token instead of the wallet variant the connector
   matches on, silently degrading to the single-use endpoint.

   Also drops the Box::pin/Box::new large_futures workaround, which only
   existed because the call sat in a function generic over four flows, and
   adds a CONNECTOR_WALLET_VAULT_CONVERSION metric so the fail-soft
   conversion leg is observable.

No UCS-side change: juspay/hyperswitch-prism#1986 keys off request fields
only and is unaffected by where hyperswitch sequences the call.
@shuklatushar226 shuklatushar226 added the S-test-ready Status: This PR is ready for cypress-tests label Jul 28, 2026
@github-actions github-actions Bot removed the S-test-ready Status: This PR is ready for cypress-tests label Jul 28, 2026
@shuklatushar226

Copy link
Copy Markdown
Contributor Author

UCS follow-up is ready: juspay/hyperswitch-prism#2023

This HS branch now runs the Paysafe wallet conversion only after a successful CIT, persists only the validated MULTI_USE handle, and leaves every other connector on the default no-op path. The rebased branch passed cargo +nightly fmt --all -- --check and cargo check -p router; the paired Apple Pay and Google Pay CIT-to-MIT flows were validated locally.

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.

6 participants