diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b3f8b473..b1b72897e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to HyperSwitch will be documented here. - - - +## 2025.10.27.0 + +### Features + +- **webhooks:** Adding event search option in the webhooks page ([#9907](https://github.com/juspay/hyperswitch/pull/9907)) ([`b0d5a1b`](https://github.com/juspay/hyperswitch/commit/b0d5a1b0463048e4d90b60dcb9674d24e2c0179c)) + +### Bug Fixes + +- **connector:** [NOVALNET] Add Sepa Direct Debit Required Fields ([#9967](https://github.com/juspay/hyperswitch/pull/9967)) ([`910887d`](https://github.com/juspay/hyperswitch/commit/910887d35cda9137acace7ac1791969952f8ba59)) +- **revenue-recovery:** Stop retries on hard-decline and apply wait on retry-limit reached ([#9742](https://github.com/juspay/hyperswitch/pull/9742)) ([`d62eed7`](https://github.com/juspay/hyperswitch/commit/d62eed7c805262aab8edc0e2031829c171406977)) + +### Refactors + +- **payouts:** Add bankredirect for payout links ([#9943](https://github.com/juspay/hyperswitch/pull/9943)) ([`5b42bf8`](https://github.com/juspay/hyperswitch/commit/5b42bf8c98771bf1b3a1694d5e194232e13a03b2)) + +### Miscellaneous Tasks + +- Introduce placeholders for complete authorize and preprocessing through ucs ([#9915](https://github.com/juspay/hyperswitch/pull/9915)) ([`3872559`](https://github.com/juspay/hyperswitch/commit/38725599d9a12c0f9ae82aedeb2e6d3d33ddb7cb)) +- Update volume mount for Postgres 18 ([#9973](https://github.com/juspay/hyperswitch/pull/9973)) ([`7b70d24`](https://github.com/juspay/hyperswitch/commit/7b70d2454e88009554669c4a12b9052deb95b4ea)) + +**Full Changelog:** [`2025.10.24.0...2025.10.27.0`](https://github.com/juspay/hyperswitch/compare/2025.10.24.0...2025.10.27.0) + +- - - + ## 2025.10.24.0 ### Features diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index 8587ea8c93..a2bdc02f3e 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -7939,6 +7939,17 @@ "$ref": "#/components/schemas/BankRedirectAdditionalData" } } + }, + { + "type": "object", + "required": [ + "Passthrough" + ], + "properties": { + "Passthrough": { + "$ref": "#/components/schemas/PassthroughAddtionalData" + } + } } ], "description": "Masked payout method details for storing in db" @@ -12967,6 +12978,74 @@ } } }, + "ConfirmSubscriptionResponse": { + "type": "object", + "required": [ + "id", + "status", + "profile_id" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/SubscriptionId" + }, + "merchant_reference_id": { + "type": "string", + "description": "Merchant specific Unique identifier.", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/SubscriptionStatus" + }, + "plan_id": { + "type": "string", + "description": "Identifier for the associated subscription plan.", + "nullable": true + }, + "item_price_id": { + "type": "string", + "description": "Identifier for the associated item_price_id for the subscription.", + "nullable": true + }, + "coupon": { + "type": "string", + "description": "Optional coupon code applied to this subscription.", + "nullable": true + }, + "profile_id": { + "$ref": "#/components/schemas/ProfileId" + }, + "payment": { + "allOf": [ + { + "$ref": "#/components/schemas/PaymentResponseData" + } + ], + "nullable": true + }, + "customer_id": { + "allOf": [ + { + "$ref": "#/components/schemas/CustomerId" + } + ], + "nullable": true + }, + "invoice": { + "allOf": [ + { + "$ref": "#/components/schemas/Invoice" + } + ], + "nullable": true + }, + "billing_processor_subscription_id": { + "type": "string", + "description": "Billing Processor subscription ID.", + "nullable": true + } + } + }, "Connector": { "type": "string", "enum": [ @@ -16073,7 +16152,8 @@ "refunds", "disputes", "mandates", - "payouts" + "payouts", + "subscriptions" ] }, "EventListConstraints": { @@ -16270,7 +16350,8 @@ "payout_processing", "payout_cancelled", "payout_expired", - "payout_reversed" + "payout_reversed", + "invoice_paid" ] }, "ExtendedCardInfo": { @@ -18397,6 +18478,11 @@ }, "status": { "$ref": "#/components/schemas/InvoiceStatus" + }, + "billing_processor_invoice_id": { + "type": "string", + "description": "billing processor invoice id", + "nullable": true } } }, @@ -21645,6 +21731,25 @@ "$ref": "#/components/schemas/PayoutCreateResponse" } } + }, + { + "type": "object", + "title": "ConfirmSubscriptionResponse", + "required": [ + "type", + "object" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "subscription_details" + ] + }, + "object": { + "$ref": "#/components/schemas/ConfirmSubscriptionResponse" + } + } } ], "discriminator": { @@ -21745,6 +21850,41 @@ } } }, + "Passthrough": { + "type": "object", + "required": [ + "psp_token", + "token_type" + ], + "properties": { + "psp_token": { + "type": "string", + "description": "PSP token generated for the payout method", + "example": "token_12345" + }, + "token_type": { + "$ref": "#/components/schemas/PaymentMethodType" + } + } + }, + "PassthroughAddtionalData": { + "type": "object", + "description": "additional payout method details for passthrough payout method", + "required": [ + "psp_token", + "token_type" + ], + "properties": { + "psp_token": { + "type": "string", + "description": "Psp_token of the passthrough flow", + "example": "token_12345" + }, + "token_type": { + "$ref": "#/components/schemas/PaymentMethodType" + } + } + }, "PayLaterData": { "oneOf": [ { @@ -29008,6 +29148,17 @@ "$ref": "#/components/schemas/BankRedirect" } } + }, + { + "type": "object", + "required": [ + "passthrough" + ], + "properties": { + "passthrough": { + "$ref": "#/components/schemas/Passthrough" + } + } } ], "description": "The payout method information required for carrying out a payout" @@ -29057,6 +29208,17 @@ "$ref": "#/components/schemas/BankRedirectAdditionalData" } } + }, + { + "type": "object", + "required": [ + "passthrough" + ], + "properties": { + "passthrough": { + "$ref": "#/components/schemas/PassthroughAddtionalData" + } + } } ], "description": "The payout method information for response" diff --git a/api-reference/v2/openapi_spec_v2.json b/api-reference/v2/openapi_spec_v2.json index b9afba40a2..bd466e9570 100644 --- a/api-reference/v2/openapi_spec_v2.json +++ b/api-reference/v2/openapi_spec_v2.json @@ -4137,6 +4137,17 @@ "$ref": "#/components/schemas/BankRedirectAdditionalData" } } + }, + { + "type": "object", + "required": [ + "Passthrough" + ], + "properties": { + "Passthrough": { + "$ref": "#/components/schemas/PassthroughAddtionalData" + } + } } ], "description": "Masked payout method details for storing in db" @@ -11297,7 +11308,8 @@ "refunds", "disputes", "mandates", - "payouts" + "payouts", + "subscriptions" ] }, "EventListItemResponse": { @@ -11424,7 +11436,8 @@ "payout_processing", "payout_cancelled", "payout_expired", - "payout_reversed" + "payout_reversed", + "invoice_paid" ] }, "ExtendedCardInfo": { @@ -16607,6 +16620,41 @@ } } }, + "Passthrough": { + "type": "object", + "required": [ + "psp_token", + "token_type" + ], + "properties": { + "psp_token": { + "type": "string", + "description": "PSP token generated for the payout method", + "example": "token_12345" + }, + "token_type": { + "$ref": "#/components/schemas/PaymentMethodType" + } + } + }, + "PassthroughAddtionalData": { + "type": "object", + "description": "additional payout method details for passthrough payout method", + "required": [ + "psp_token", + "token_type" + ], + "properties": { + "psp_token": { + "type": "string", + "description": "Psp_token of the passthrough flow", + "example": "token_12345" + }, + "token_type": { + "$ref": "#/components/schemas/PaymentMethodType" + } + } + }, "PayLaterData": { "oneOf": [ { @@ -22009,6 +22057,17 @@ "$ref": "#/components/schemas/BankRedirect" } } + }, + { + "type": "object", + "required": [ + "passthrough" + ], + "properties": { + "passthrough": { + "$ref": "#/components/schemas/Passthrough" + } + } } ], "description": "The payout method information required for carrying out a payout" @@ -22058,6 +22117,17 @@ "$ref": "#/components/schemas/BankRedirectAdditionalData" } } + }, + { + "type": "object", + "required": [ + "passthrough" + ], + "properties": { + "passthrough": { + "$ref": "#/components/schemas/PassthroughAddtionalData" + } + } } ], "description": "The payout method information for response" diff --git a/crates/api_models/src/payouts.rs b/crates/api_models/src/payouts.rs index fc41273288..9a56ac4993 100644 --- a/crates/api_models/src/payouts.rs +++ b/crates/api_models/src/payouts.rs @@ -243,6 +243,7 @@ pub enum PayoutMethodData { Bank(Bank), Wallet(Wallet), BankRedirect(BankRedirect), + Passthrough(Passthrough), } impl Default for PayoutMethodData { @@ -393,6 +394,18 @@ pub struct Interac { pub email: Email, } +#[derive(Eq, PartialEq, Clone, Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub struct Passthrough { + /// PSP token generated for the payout method + #[schema(value_type = String, example = "token_12345")] + pub psp_token: Secret, + + /// Payout method type of the token + #[schema(value_type = PaymentMethodType, example = "paypal")] + pub token_type: api_enums::PaymentMethodType, +} + #[derive(Default, Eq, PartialEq, Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct Paypal { /// Email linked with paypal account @@ -638,6 +651,8 @@ pub enum PayoutMethodDataResponse { Wallet(Box), #[schema(value_type = BankRedirectAdditionalData)] BankRedirect(Box), + #[schema(value_type = PassthroughAddtionalData)] + Passthrough(Box), } #[derive( @@ -1048,6 +1063,15 @@ impl From for payout_method_utils::BankRedirectAdditionalData { } } +impl From for payout_method_utils::PassthroughAddtionalData { + fn from(passthrough_data: Passthrough) -> Self { + Self { + psp_token: passthrough_data.psp_token.into(), + token_type: passthrough_data.token_type, + } + } +} + impl From for PayoutMethodDataResponse { fn from(additional_data: payout_method_utils::AdditionalPayoutMethodData) -> Self { match additional_data { @@ -1063,6 +1087,9 @@ impl From for PayoutMethodDataR payout_method_utils::AdditionalPayoutMethodData::BankRedirect(bank_redirect) => { Self::BankRedirect(bank_redirect) } + payout_method_utils::AdditionalPayoutMethodData::Passthrough(passthrough) => { + Self::Passthrough(passthrough) + } } } } diff --git a/crates/api_models/src/subscription.rs b/crates/api_models/src/subscription.rs index 08f6781bcd..c5680757dc 100644 --- a/crates/api_models/src/subscription.rs +++ b/crates/api_models/src/subscription.rs @@ -1,4 +1,4 @@ -use common_enums::connector_enums::InvoiceStatus; +use common_enums::{connector_enums::InvoiceStatus, SubscriptionStatus}; use common_types::payments::CustomerAcceptance; use common_utils::{ errors::ValidationError, @@ -96,43 +96,6 @@ pub struct SubscriptionResponse { pub invoice: Option, } -/// Possible states of a subscription lifecycle. -/// -/// - `Created`: Subscription was created but not yet activated. -/// - `Active`: Subscription is currently active. -/// - `InActive`: Subscription is inactive. -/// - `Pending`: Subscription is pending activation. -/// - `Trial`: Subscription is in a trial period. -/// - `Paused`: Subscription is paused. -/// - `Unpaid`: Subscription is unpaid. -/// - `Onetime`: Subscription is a one-time payment. -/// - `Cancelled`: Subscription has been cancelled. -/// - `Failed`: Subscription has failed. -#[derive(Debug, Clone, serde::Serialize, strum::EnumString, strum::Display, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubscriptionStatus { - /// Subscription is active. - Active, - /// Subscription is created but not yet active. - Created, - /// Subscription is inactive. - InActive, - /// Subscription is in pending state. - Pending, - /// Subscription is in trial state. - Trial, - /// Subscription is paused. - Paused, - /// Subscription is unpaid. - Unpaid, - /// Subscription is a one-time payment. - Onetime, - /// Subscription is cancelled. - Cancelled, - /// Subscription has failed. - Failed, -} - impl SubscriptionResponse { /// Creates a new [`CreateSubscriptionResponse`] with the given identifiers. /// @@ -381,6 +344,12 @@ pub struct PaymentResponseData { pub payment_token: Option, } +impl PaymentResponseData { + pub fn get_billing_address(&self) -> Option
{ + self.billing.clone() + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] pub struct CreateMitPaymentRequestData { pub amount: MinorUnit, @@ -498,6 +467,18 @@ pub struct ConfirmSubscriptionResponse { pub billing_processor_subscription_id: Option, } +impl ConfirmSubscriptionResponse { + pub fn get_optional_invoice_id(&self) -> Option { + self.invoice.as_ref().map(|invoice| invoice.id.to_owned()) + } + + pub fn get_optional_payment_id(&self) -> Option { + self.payment + .as_ref() + .map(|payment| payment.payment_id.to_owned()) + } +} + #[derive(Debug, Clone, serde::Serialize, ToSchema)] pub struct Invoice { /// Unique identifier for the invoice. @@ -533,6 +514,9 @@ pub struct Invoice { /// Status of the invoice. pub status: InvoiceStatus, + + /// billing processor invoice id + pub billing_processor_invoice_id: Option, } impl ApiEventMetric for ConfirmSubscriptionResponse {} diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index a854f2a456..352386d28c 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -5,7 +5,7 @@ use utoipa::ToSchema; #[cfg(feature = "payouts")] use crate::payouts; -use crate::{disputes, enums as api_enums, mandates, payments, refunds}; +use crate::{disputes, enums as api_enums, mandates, payments, refunds, subscription}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Copy)] #[serde(rename_all = "snake_case")] @@ -446,6 +446,8 @@ pub enum OutgoingWebhookContent { #[cfg(feature = "payouts")] #[schema(value_type = PayoutCreateResponse, title = "PayoutCreateResponse")] PayoutDetails(Box), + #[schema(value_type = ConfirmSubscriptionResponse, title = "ConfirmSubscriptionResponse")] + SubscriptionDetails(Box), } #[derive(Debug, Clone, Serialize, ToSchema)] diff --git a/crates/common_enums/src/connector_enums.rs b/crates/common_enums/src/connector_enums.rs index 518d89b5e5..9490221d90 100644 --- a/crates/common_enums/src/connector_enums.rs +++ b/crates/common_enums/src/connector_enums.rs @@ -426,7 +426,10 @@ impl Connector { ) } pub fn requires_order_creation_before_payment(self, payment_method: PaymentMethod) -> bool { - matches!((self, payment_method), (Self::Razorpay, PaymentMethod::Upi)) + matches!( + (self, payment_method), + (Self::Razorpay, PaymentMethod::Upi) | (Self::Airwallex, PaymentMethod::Card) + ) } pub fn supports_file_storage_module(self) -> bool { matches!(self, Self::Stripe | Self::Checkout | Self::Worldpayvantiv) @@ -575,10 +578,6 @@ impl Connector { } } - pub fn is_pre_processing_required_before_authorize(self) -> bool { - matches!(self, Self::Airwallex) - } - pub fn get_payment_methods_supporting_extended_authorization(self) -> HashSet { HashSet::from([PaymentMethod::Card]) } diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index cd3650c077..445569c6b6 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -579,6 +579,7 @@ pub enum CallConnectorAction { error_message: Option, }, HandleResponse(Vec), + UCSConsumeResponse(Vec), UCSHandleResponse(Vec), } @@ -1503,6 +1504,29 @@ impl Currency { } } +#[derive( + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Deserialize, + serde::Serialize, + strum::Display, + strum::EnumString, +)] +#[router_derive::diesel_enum(storage_type = "db_enum")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum EventObjectType { + PaymentDetails, + RefundDetails, + DisputeDetails, + MandateDetails, + PayoutDetails, + SubscriptionDetails, +} + #[derive( Clone, Copy, @@ -1526,6 +1550,7 @@ pub enum EventClass { Mandates, #[cfg(feature = "payouts")] Payouts, + Subscriptions, } impl EventClass { @@ -1564,6 +1589,7 @@ impl EventClass { EventType::PayoutExpired, EventType::PayoutReversed, ]), + Self::Subscriptions => HashSet::from([EventType::InvoicePaid]), } } } @@ -1623,6 +1649,7 @@ pub enum EventType { PayoutExpired, #[cfg(feature = "payouts")] PayoutReversed, + InvoicePaid, } #[derive( @@ -9780,3 +9807,49 @@ impl From for InvoiceStatus { } } } + +/// Possible states of a subscription lifecycle. +/// +/// - `Created`: Subscription was created but not yet activated. +/// - `Active`: Subscription is currently active. +/// - `InActive`: Subscription is inactive. +/// - `Pending`: Subscription is pending activation. +/// - `Trial`: Subscription is in a trial period. +/// - `Paused`: Subscription is paused. +/// - `Unpaid`: Subscription is unpaid. +/// - `Onetime`: Subscription is a one-time payment. +/// - `Cancelled`: Subscription has been cancelled. +/// - `Failed`: Subscription has failed. +#[derive( + Debug, + Clone, + Copy, + serde::Serialize, + strum::EnumString, + strum::Display, + strum::EnumIter, + ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionStatus { + /// Subscription is active. + Active, + /// Subscription is created but not yet active. + Created, + /// Subscription is inactive. + InActive, + /// Subscription is in pending state. + Pending, + /// Subscription is in trial state. + Trial, + /// Subscription is paused. + Paused, + /// Subscription is unpaid. + Unpaid, + /// Subscription is a one-time payment. + Onetime, + /// Subscription is cancelled. + Cancelled, + /// Subscription has failed. + Failed, +} diff --git a/crates/common_enums/src/transformers.rs b/crates/common_enums/src/transformers.rs index 30f9f257ef..b4c326e333 100644 --- a/crates/common_enums/src/transformers.rs +++ b/crates/common_enums/src/transformers.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::enums::PayoutStatus; use crate::enums::{ AttemptStatus, Country, CountryAlpha2, CountryAlpha3, DisputeStatus, EventType, IntentStatus, - MandateStatus, PaymentMethod, PaymentMethodType, RefundStatus, + MandateStatus, PaymentMethod, PaymentMethodType, RefundStatus, SubscriptionStatus, }; impl Display for NumericCountryCodeParseError { @@ -2214,6 +2214,15 @@ impl From for Option { } } +impl From for Option { + fn from(value: SubscriptionStatus) -> Self { + match value { + SubscriptionStatus::Active => Some(EventType::InvoicePaid), + _ => None, + } + } +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] diff --git a/crates/common_utils/src/new_type.rs b/crates/common_utils/src/new_type.rs index 1bf673ecc4..2c9577975f 100644 --- a/crates/common_utils/src/new_type.rs +++ b/crates/common_utils/src/new_type.rs @@ -236,6 +236,21 @@ impl From> for MaskedPhoneNumber { } } +/// Masked Psp token +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MaskedPspToken(Secret); +impl From for MaskedPspToken { + fn from(src: String) -> Self { + let masked_value = apply_mask(src.as_ref(), 3, 3); + Self(Secret::from(masked_value)) + } +} +impl From> for MaskedPspToken { + fn from(secret: Secret) -> Self { + Self::from(secret.expose()) + } +} + #[cfg(test)] mod apply_mask_fn_test { use masking::PeekInterface; diff --git a/crates/common_utils/src/payout_method_utils.rs b/crates/common_utils/src/payout_method_utils.rs index 1becc711f4..7377096ee6 100644 --- a/crates/common_utils/src/payout_method_utils.rs +++ b/crates/common_utils/src/payout_method_utils.rs @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::new_type::{ - MaskedBankAccount, MaskedBic, MaskedEmail, MaskedIban, MaskedPhoneNumber, MaskedRoutingNumber, - MaskedSortCode, + MaskedBankAccount, MaskedBic, MaskedEmail, MaskedIban, MaskedPhoneNumber, MaskedPspToken, + MaskedRoutingNumber, MaskedSortCode, }; /// Masked payout method details for storing in db @@ -25,6 +25,8 @@ pub enum AdditionalPayoutMethodData { Wallet(Box), /// Additional data for Bank Redirect payout method BankRedirect(Box), + /// Additional data for Passthrough payout method + Passthrough(Box), } crate::impl_to_sql_from_sql_json!(AdditionalPayoutMethodData); @@ -283,3 +285,17 @@ pub struct InteracAdditionalData { #[schema(value_type = Option, example = "john.doe@example.com")] pub email: Option, } + +/// additional payout method details for passthrough payout method +#[derive( + Eq, PartialEq, Clone, Debug, Deserialize, Serialize, FromSqlRow, AsExpression, ToSchema, +)] +#[diesel(sql_type = Jsonb)] +pub struct PassthroughAddtionalData { + /// Psp_token of the passthrough flow + #[schema(value_type = String, example = "token_12345")] + pub psp_token: MaskedPspToken, + /// token_type of the passthrough flow + #[schema(value_type = PaymentMethodType, example = "paypal")] + pub token_type: common_enums::PaymentMethodType, +} diff --git a/crates/connector_configs/toml/development.toml b/crates/connector_configs/toml/development.toml index 3188959d3b..67369ea6e2 100644 --- a/crates/connector_configs/toml/development.toml +++ b/crates/connector_configs/toml/development.toml @@ -7600,6 +7600,52 @@ required=true type="Radio" options=["Hyperswitch"] +[[tesouro.metadata.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.metadata.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["PAN_ONLY", "CRYPTOGRAM_3DS"] + +[[tesouro.connector_wallets_details.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="public_key" +label="Google Pay Public Key" +placeholder="Enter Google Pay Public Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="private_key" +label="Google Pay Private Key" +placeholder="Enter Google Pay Private Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="recipient_id" +label="Recipient Id" +placeholder="Enter Recipient Id" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["CRYPTOGRAM_3DS"] + [payjustnow] [payjustnow.connector_auth.HeaderKey] api_key = "API Key" diff --git a/crates/connector_configs/toml/production.toml b/crates/connector_configs/toml/production.toml index 27fe1aa62f..d3c8a8cc49 100644 --- a/crates/connector_configs/toml/production.toml +++ b/crates/connector_configs/toml/production.toml @@ -6277,7 +6277,7 @@ payment_method_type = "Maestro" payment_method_type = "apple_pay" [[tesouro.wallet]] payment_method_type = "google_pay" - + [tesouro.connector_auth.SignatureKey] api_key = "Client ID" api_secret = "Client Secret" @@ -6335,6 +6335,52 @@ required=true type="Radio" options=["Hyperswitch"] +[[tesouro.metadata.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.metadata.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["PAN_ONLY", "CRYPTOGRAM_3DS"] + +[[tesouro.connector_wallets_details.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="public_key" +label="Google Pay Public Key" +placeholder="Enter Google Pay Public Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="private_key" +label="Google Pay Private Key" +placeholder="Enter Google Pay Private Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="recipient_id" +label="Recipient Id" +placeholder="Enter Recipient Id" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["CRYPTOGRAM_3DS"] + [payjustnow] [payjustnow.connector_auth.HeaderKey] api_key = "API Key" diff --git a/crates/connector_configs/toml/sandbox.toml b/crates/connector_configs/toml/sandbox.toml index 793f6c87b1..1fce874a71 100644 --- a/crates/connector_configs/toml/sandbox.toml +++ b/crates/connector_configs/toml/sandbox.toml @@ -7515,7 +7515,7 @@ payment_method_type = "Maestro" [[tesouro.wallet]] payment_method_type = "apple_pay" [[tesouro.wallet]] - payment_method_type = "google_pay" +payment_method_type = "google_pay" [tesouro.connector_auth.SignatureKey] api_key = "Client ID" @@ -7574,6 +7574,52 @@ required=true type="Radio" options=["Hyperswitch"] +[[tesouro.metadata.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.metadata.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["PAN_ONLY", "CRYPTOGRAM_3DS"] + +[[tesouro.connector_wallets_details.google_pay]] +name="merchant_name" +label="Google Pay Merchant Name" +placeholder="Enter Google Pay Merchant Name" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="public_key" +label="Google Pay Public Key" +placeholder="Enter Google Pay Public Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="private_key" +label="Google Pay Private Key" +placeholder="Enter Google Pay Private Key" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="recipient_id" +label="Recipient Id" +placeholder="Enter Recipient Id" +required=true +type="Text" +[[tesouro.connector_wallets_details.google_pay]] +name="allowed_auth_methods" +label="Allowed Auth Methods" +placeholder="Enter Allowed Auth Methods" +required=true +type="MultiSelect" +options=["CRYPTOGRAM_3DS"] + [payjustnow] [payjustnow.connector_auth.HeaderKey] api_key = "API Key" diff --git a/crates/diesel_models/src/enums.rs b/crates/diesel_models/src/enums.rs index f7c84368b4..79e7a05695 100644 --- a/crates/diesel_models/src/enums.rs +++ b/crates/diesel_models/src/enums.rs @@ -61,28 +61,6 @@ pub enum RoutingAlgorithmKind { ThreeDsDecisionRule, } -#[derive( - Clone, - Copy, - Debug, - Eq, - PartialEq, - serde::Deserialize, - serde::Serialize, - strum::Display, - strum::EnumString, -)] -#[diesel_enum(storage_type = "db_enum")] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum EventObjectType { - PaymentDetails, - RefundDetails, - DisputeDetails, - MandateDetails, - PayoutDetails, -} - // Refund #[derive( Clone, diff --git a/crates/diesel_models/src/events.rs b/crates/diesel_models/src/events.rs index bc9f078811..588cad3779 100644 --- a/crates/diesel_models/src/events.rs +++ b/crates/diesel_models/src/events.rs @@ -102,6 +102,11 @@ pub enum EventMetadata { payment_method_id: String, mandate_id: String, }, + Subscription { + subscription_id: common_utils::id_type::SubscriptionId, + invoice_id: Option, + payment_id: Option, + }, } common_utils::impl_to_sql_from_sql_json!(EventMetadata); diff --git a/crates/euclid_wasm/src/lib.rs b/crates/euclid_wasm/src/lib.rs index 2aaee43f7e..e4d41b86a0 100644 --- a/crates/euclid_wasm/src/lib.rs +++ b/crates/euclid_wasm/src/lib.rs @@ -38,7 +38,7 @@ use api_models::payment_methods::CountryCodeWithName; use common_enums::PayoutStatus; use common_enums::{ CountryAlpha2, DisputeStatus, EventClass, EventType, IntentStatus, MandateStatus, - MerchantCategoryCode, MerchantCategoryCodeWithName, RefundStatus, + MerchantCategoryCode, MerchantCategoryCodeWithName, RefundStatus, SubscriptionStatus, }; use strum::IntoEnumIterator; @@ -515,5 +515,11 @@ pub fn get_valid_webhook_status(key: &str) -> JsResult { .collect(); Ok(serde_wasm_bindgen::to_value(&statuses)?) } + EventClass::Subscriptions => { + let statuses: Vec = SubscriptionStatus::iter() + .filter(|status| Into::>::into(*status).is_some()) + .collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } } } diff --git a/crates/hyperswitch_connectors/src/connectors/adyen/transformers.rs b/crates/hyperswitch_connectors/src/connectors/adyen/transformers.rs index 2666a77dd2..eacc400a46 100644 --- a/crates/hyperswitch_connectors/src/connectors/adyen/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/adyen/transformers.rs @@ -5871,6 +5871,10 @@ impl TryFrom<&AdyenRouterData<&PayoutsRouterData>> for AdyenPayoutCreateRe message: "Bank redirect payout creation is not supported".to_string(), connector: "Adyen", })?, + PayoutMethodData::Passthrough(_) => Err(errors::ConnectorError::NotSupported { + message: "Passthrough payout creation is not supported".to_string(), + connector: "Adyen", + })?, } } } diff --git a/crates/hyperswitch_connectors/src/connectors/adyenplatform/transformers/payouts.rs b/crates/hyperswitch_connectors/src/connectors/adyenplatform/transformers/payouts.rs index 713e55e8db..a3c6d3779e 100644 --- a/crates/hyperswitch_connectors/src/connectors/adyenplatform/transformers/payouts.rs +++ b/crates/hyperswitch_connectors/src/connectors/adyenplatform/transformers/payouts.rs @@ -459,11 +459,11 @@ impl TryFrom> let request = &raw_payment.item.router_data.request; match raw_payment.raw_payout_method_data { - payouts::PayoutMethodData::Wallet(_) | payouts::PayoutMethodData::BankRedirect(_) => { - Err(ConnectorError::NotImplemented( - utils::get_unimplemented_payment_method_error_message("Adyenplatform"), - ))? - } + payouts::PayoutMethodData::Wallet(_) + | payouts::PayoutMethodData::BankRedirect(_) + | payouts::PayoutMethodData::Passthrough(_) => Err(ConnectorError::NotImplemented( + utils::get_unimplemented_payment_method_error_message("Adyenplatform"), + ))?, payouts::PayoutMethodData::Card(c) => { let card_holder: AdyenAccountHolder = (raw_payment.item.router_data, &c).try_into()?; diff --git a/crates/hyperswitch_connectors/src/connectors/airwallex.rs b/crates/hyperswitch_connectors/src/connectors/airwallex.rs index 750ebdf84a..7b1e14b035 100644 --- a/crates/hyperswitch_connectors/src/connectors/airwallex.rs +++ b/crates/hyperswitch_connectors/src/connectors/airwallex.rs @@ -19,23 +19,26 @@ use hyperswitch_domain_models::{ router_data::{AccessToken, ErrorResponse, RouterData}, router_flow_types::{ access_token_auth::AccessTokenAuth, - payments::{Authorize, Capture, PSync, PaymentMethodToken, Session, SetupMandate, Void}, + payments::{ + Authorize, Capture, CreateOrder, PSync, PaymentMethodToken, Session, SetupMandate, Void, + }, refunds::{Execute, RSync}, - CompleteAuthorize, PreProcessing, + CompleteAuthorize, }, router_request_types::{ - AccessTokenRequestData, CompleteAuthorizeData, PaymentMethodTokenizationData, - PaymentsAuthorizeData, PaymentsCancelData, PaymentsCaptureData, PaymentsPreProcessingData, - PaymentsSessionData, PaymentsSyncData, RefundsData, SetupMandateRequestData, + AccessTokenRequestData, CompleteAuthorizeData, CreateOrderRequestData, + PaymentMethodTokenizationData, PaymentsAuthorizeData, PaymentsCancelData, + PaymentsCaptureData, PaymentsSessionData, PaymentsSyncData, RefundsData, + SetupMandateRequestData, }, router_response_types::{ ConnectorInfo, PaymentMethodDetails, PaymentsResponseData, RefundsResponseData, SupportedPaymentMethods, SupportedPaymentMethodsExt, }, types::{ - PaymentsAuthorizeRouterData, PaymentsCancelRouterData, PaymentsCaptureRouterData, - PaymentsCompleteAuthorizeRouterData, PaymentsPreProcessingRouterData, - PaymentsSyncRouterData, RefundSyncRouterData, RefundsRouterData, SetupMandateRouterData, + CreateOrderRouterData, PaymentsAuthorizeRouterData, PaymentsCancelRouterData, + PaymentsCaptureRouterData, PaymentsCompleteAuthorizeRouterData, PaymentsSyncRouterData, + RefundSyncRouterData, RefundsRouterData, SetupMandateRouterData, }, }; use hyperswitch_interfaces::{ @@ -47,7 +50,7 @@ use hyperswitch_interfaces::{ disputes::DisputePayload, errors, events::connector_api_logs::ConnectorEvent, - types::{self, Response}, + types::{self, CreateOrderType, Response}, webhooks::{IncomingWebhook, IncomingWebhookRequestDetails}, }; use masking::{Mask, PeekInterface}; @@ -58,7 +61,10 @@ use crate::{ connectors::airwallex::transformers::AirwallexAuthorizeResponse, constants::headers, types::{RefreshTokenRouterData, ResponseRouterData}, - utils::{convert_amount, AccessTokenRequestInfo, ForeignTryFrom, RefundsRequestData}, + utils::{ + convert_amount, AccessTokenRequestInfo, ForeignTryFrom, PaymentsAuthorizeRequestData, + RefundsRequestData, + }, }; #[derive(Clone)] @@ -154,8 +160,8 @@ impl ConnectorCommon for Airwallex { impl ConnectorValidation for Airwallex {} impl api::Payment for Airwallex {} -impl api::PaymentsPreProcessing for Airwallex {} impl api::PaymentsCompleteAuthorize for Airwallex {} +impl api::PaymentsCreateOrder for Airwallex {} impl api::MandateSetup for Airwallex {} impl ConnectorIntegration for Airwallex @@ -262,12 +268,10 @@ impl ConnectorIntegration } } -impl ConnectorIntegration - for Airwallex -{ +impl ConnectorIntegration for Airwallex { fn get_headers( &self, - req: &PaymentsPreProcessingRouterData, + req: &CreateOrderRouterData, connectors: &Connectors, ) -> CustomResult)>, errors::ConnectorError> { self.build_headers(req, connectors) @@ -279,7 +283,7 @@ impl ConnectorIntegration CustomResult { Ok(format!( @@ -291,22 +295,13 @@ impl ConnectorIntegration CustomResult { - let amount_in_minor_unit = MinorUnit::new(req.request.amount.ok_or( - errors::ConnectorError::MissingRequiredField { - field_name: "amount", - }, - )?); let amount = convert_amount( self.amount_converter, - amount_in_minor_unit, - req.request - .currency - .ok_or(errors::ConnectorError::MissingRequiredField { - field_name: "currency", - })?, + req.request.minor_amount, + req.request.currency, )?; let connector_router_data = airwallex::AirwallexRouterData::try_from((amount, req))?; let connector_req = airwallex::AirwallexIntentRequest::try_from(&connector_router_data)?; @@ -315,35 +310,29 @@ impl ConnectorIntegration CustomResult, errors::ConnectorError> { Ok(Some( RequestBuilder::new() .method(Method::Post) - .url(&types::PaymentsPreProcessingType::get_url( - self, req, connectors, - )?) + .url(&CreateOrderType::get_url(self, req, connectors)?) .attach_default_headers() - .headers(types::PaymentsPreProcessingType::get_headers( - self, req, connectors, - )?) - .set_body(types::PaymentsPreProcessingType::get_request_body( - self, req, connectors, - )?) + .headers(CreateOrderType::get_headers(self, req, connectors)?) + .set_body(CreateOrderType::get_request_body(self, req, connectors)?) .build(), )) } fn handle_response( &self, - data: &PaymentsPreProcessingRouterData, + data: &CreateOrderRouterData, event_builder: Option<&mut ConnectorEvent>, res: Response, - ) -> CustomResult { - let response: airwallex::AirwallexPaymentsResponse = res + ) -> CustomResult { + let response: airwallex::AirwallexOrderResponse = res .response - .parse_struct("airwallex AirwallexPaymentsResponse") + .parse_struct("airwallex AirwallexOrderResponse") .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; event_builder.map(|i| i.set_response_body(&response)); @@ -391,9 +380,7 @@ impl ConnectorIntegration, } -impl TryFrom<&AirwallexRouterData<&types::PaymentsPreProcessingRouterData>> - for AirwallexIntentRequest -{ +impl TryFrom<&AirwallexRouterData<&types::CreateOrderRouterData>> for AirwallexIntentRequest { type Error = error_stack::Report; fn try_from( - item: &AirwallexRouterData<&types::PaymentsPreProcessingRouterData>, + item: &AirwallexRouterData<&types::CreateOrderRouterData>, ) -> Result { let referrer_data = ReferrerData { r_type: "hyperswitch".to_string(), version: "1.0.0".to_string(), }; let amount = item.amount.clone(); - let currency = item.router_data.request.currency.ok_or( - errors::ConnectorError::MissingRequiredField { - field_name: "currency", - }, - )?; + let currency = item.router_data.request.currency; let order = match item.router_data.request.payment_method_data { Some(PaymentMethodData::PayLater(_)) => Some( @@ -137,6 +131,30 @@ impl TryFrom<&AirwallexRouterData<&types::PaymentsPreProcessingRouterData>> } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AirwallexOrderResponse { + pub status: AirwallexPaymentStatus, + pub id: String, + pub payment_consent_id: Option>, + pub next_action: Option, +} + +impl TryFrom> + for types::CreateOrderRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: CreateOrderResponseRouterData, + ) -> Result { + Ok(Self { + response: Ok(PaymentsResponseData::PaymentsCreateOrderResponse { + order_id: item.response.id.clone(), + }), + ..item.data + }) + } +} + #[derive(Debug, Serialize)] pub struct AirwallexRouterData { pub amount: StringMajorUnit, diff --git a/crates/hyperswitch_connectors/src/connectors/cybersource/transformers.rs b/crates/hyperswitch_connectors/src/connectors/cybersource/transformers.rs index 2d34c19837..2c0ac8192c 100644 --- a/crates/hyperswitch_connectors/src/connectors/cybersource/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/cybersource/transformers.rs @@ -5036,7 +5036,8 @@ impl TryFrom for PaymentInformation { } PayoutMethodData::Bank(_) | PayoutMethodData::Wallet(_) - | PayoutMethodData::BankRedirect(_) => Err(errors::ConnectorError::NotSupported { + | PayoutMethodData::BankRedirect(_) + | PayoutMethodData::Passthrough(_) => Err(errors::ConnectorError::NotSupported { message: "PayoutMethod is not supported".to_string(), connector: "Cybersource", })?, diff --git a/crates/hyperswitch_connectors/src/connectors/ebanx/transformers.rs b/crates/hyperswitch_connectors/src/connectors/ebanx/transformers.rs index 353f1bd885..2d00ee7e48 100644 --- a/crates/hyperswitch_connectors/src/connectors/ebanx/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/ebanx/transformers.rs @@ -142,7 +142,8 @@ impl TryFrom<&EbanxRouterData<&PayoutsRouterData>> for EbanxPayoutCrea PayoutMethodData::Card(_) | PayoutMethodData::Bank(_) | PayoutMethodData::Wallet(_) - | PayoutMethodData::BankRedirect(_) => Err(ConnectorError::NotSupported { + | PayoutMethodData::BankRedirect(_) + | PayoutMethodData::Passthrough(_) => Err(ConnectorError::NotSupported { message: "Payment Method Not Supported".to_string(), connector: "Ebanx", })?, diff --git a/crates/hyperswitch_connectors/src/connectors/gigadat/transformers.rs b/crates/hyperswitch_connectors/src/connectors/gigadat/transformers.rs index bd84e8d24c..72180348a1 100644 --- a/crates/hyperswitch_connectors/src/connectors/gigadat/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/gigadat/transformers.rs @@ -421,12 +421,13 @@ impl TryFrom<&GigadatRouterData<&PayoutsRouterData>> for GigadatPayoutQ sandbox, }) } - PayoutMethodData::Card(_) | PayoutMethodData::Bank(_) | PayoutMethodData::Wallet(_) => { - Err(errors::ConnectorError::NotSupported { - message: "Payment Method Not Supported".to_string(), - connector: "Gigadat", - })? - } + PayoutMethodData::Card(_) + | PayoutMethodData::Bank(_) + | PayoutMethodData::Wallet(_) + | PayoutMethodData::Passthrough(_) => Err(errors::ConnectorError::NotSupported { + message: "Payment Method Not Supported".to_string(), + connector: "Gigadat", + })?, } } } diff --git a/crates/hyperswitch_connectors/src/connectors/loonio/transformers.rs b/crates/hyperswitch_connectors/src/connectors/loonio/transformers.rs index 824957f4a9..23ae229e30 100644 --- a/crates/hyperswitch_connectors/src/connectors/loonio/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/loonio/transformers.rs @@ -463,12 +463,13 @@ impl TryFrom<&LoonioRouterData<&PayoutsRouterData>> for LoonioPayoutF webhook_url: item.router_data.request.webhook_url.clone(), }) } - PayoutMethodData::Card(_) | PayoutMethodData::Bank(_) | PayoutMethodData::Wallet(_) => { - Err(errors::ConnectorError::NotSupported { - message: "Payment Method Not Supported".to_string(), - connector: "Loonio", - })? - } + PayoutMethodData::Card(_) + | PayoutMethodData::Bank(_) + | PayoutMethodData::Wallet(_) + | PayoutMethodData::Passthrough(_) => Err(errors::ConnectorError::NotSupported { + message: "Payment Method Not Supported".to_string(), + connector: "Loonio", + })?, } } } diff --git a/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs b/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs index e556a14c59..724d6f59bf 100644 --- a/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs @@ -2470,7 +2470,8 @@ impl TryFrom for NuveiPayoutCardData { }), api_models::payouts::PayoutMethodData::Bank(_) | api_models::payouts::PayoutMethodData::Wallet(_) - | api_models::payouts::PayoutMethodData::BankRedirect(_) => { + | api_models::payouts::PayoutMethodData::BankRedirect(_) + | api_models::payouts::PayoutMethodData::Passthrough(_) => { Err(errors::ConnectorError::NotImplemented( "Selected Payout Method is not implemented for Nuvei".to_string(), ) diff --git a/crates/hyperswitch_connectors/src/connectors/payone/transformers.rs b/crates/hyperswitch_connectors/src/connectors/payone/transformers.rs index a523687c84..e2a6c1677d 100644 --- a/crates/hyperswitch_connectors/src/connectors/payone/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/payone/transformers.rs @@ -139,35 +139,34 @@ impl TryFrom>> for PayonePayoutFu amount: item.amount, currency_code: item.router_data.request.destination_currency.to_string(), }; - let card_payout_method_specific_input = match item - .router_data - .get_payout_method_data()? - { - PayoutMethodData::Card(card_data) => CardPayoutMethodSpecificInput { - card: Card { - card_number: card_data.card_number.clone(), - card_holder_name: card_data - .card_holder_name - .clone() - .get_required_value("card_holder_name") - .change_context(ConnectorError::MissingRequiredField { - field_name: "payout_method_data.card.holder_name", - })?, - expiry_date: card_data - .get_card_expiry_month_year_2_digit_with_delimiter( - "".to_string(), - )?, + let card_payout_method_specific_input = + match item.router_data.get_payout_method_data()? { + PayoutMethodData::Card(card_data) => CardPayoutMethodSpecificInput { + card: Card { + card_number: card_data.card_number.clone(), + card_holder_name: card_data + .card_holder_name + .clone() + .get_required_value("card_holder_name") + .change_context(ConnectorError::MissingRequiredField { + field_name: "payout_method_data.card.holder_name", + })?, + expiry_date: card_data + .get_card_expiry_month_year_2_digit_with_delimiter( + "".to_string(), + )?, + }, + payment_product_id: PaymentProductId::try_from( + card_data.get_card_issuer()?, + )?, }, - payment_product_id: PaymentProductId::try_from( - card_data.get_card_issuer()?, - )?, - }, - PayoutMethodData::Bank(_) - | PayoutMethodData::Wallet(_) - | PayoutMethodData::BankRedirect(_) => Err(ConnectorError::NotImplemented( - get_unimplemented_payment_method_error_message("Payone"), - ))?, - }; + PayoutMethodData::Bank(_) + | PayoutMethodData::Wallet(_) + | PayoutMethodData::BankRedirect(_) + | PayoutMethodData::Passthrough(_) => Err(ConnectorError::NotImplemented( + get_unimplemented_payment_method_error_message("Payone"), + ))?, + }; Ok(Self { amount_of_money, card_payout_method_specific_input, diff --git a/crates/hyperswitch_connectors/src/connectors/stripe/transformers/connect.rs b/crates/hyperswitch_connectors/src/connectors/stripe/transformers/connect.rs index 786abbaaca..145d21ddaf 100644 --- a/crates/hyperswitch_connectors/src/connectors/stripe/transformers/connect.rs +++ b/crates/hyperswitch_connectors/src/connectors/stripe/transformers/connect.rs @@ -456,6 +456,13 @@ impl TryFrom<&PayoutsRouterData> for StripeConnectRecipientAccountCreateRe } .into()) } + api_models::payouts::PayoutMethodData::Passthrough(_) => { + Err(errors::ConnectorError::NotSupported { + message: "Payouts via Passthrough are not supported".to_string(), + connector: "stripe", + } + .into()) + } } } } diff --git a/crates/hyperswitch_connectors/src/connectors/tesouro/transformers.rs b/crates/hyperswitch_connectors/src/connectors/tesouro/transformers.rs index f7bdc0fedc..a5052d43a0 100644 --- a/crates/hyperswitch_connectors/src/connectors/tesouro/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/tesouro/transformers.rs @@ -351,7 +351,7 @@ pub struct TesouroCardWithPanDetails { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TesouroNetworkTokenPassThroughDetails { - pub cryptogram: Secret, + pub cryptogram: Option>, pub expiration_month: Secret, pub expiration_year: Secret, pub token_value: cards::CardNumber, @@ -495,7 +495,7 @@ impl TryFrom<(&ApplePayWalletData, Option<&PaymentMethodToken>)> for TesouroPaym let network_token_details = TesouroNetworkTokenPassThroughDetails { expiration_year: apple_pay_data.get_four_digit_expiry_year(), - cryptogram: apple_pay_data.payment_data.online_payment_cryptogram, + cryptogram: Some(apple_pay_data.payment_data.online_payment_cryptogram), token_value: apple_pay_data.application_primary_account_number, expiration_month: apple_pay_data.application_expiration_month, ecommerce_indicator: apple_pay_data.payment_data.eci_indicator, @@ -519,11 +519,7 @@ impl TryFrom<(&GooglePayWalletData, Option<&PaymentMethodToken>)> for TesouroPay .change_context(errors::ConnectorError::InvalidWalletToken { wallet_name: "Google Pay".to_string(), })?, - cryptogram: google_pay_data.cryptogram.ok_or( - errors::ConnectorError::MissingRequiredField { - field_name: "google pay data cryptogram", - }, - )?, + cryptogram: google_pay_data.cryptogram, token_value: google_pay_data.application_primary_account_number, expiration_month: google_pay_data.card_exp_month, ecommerce_indicator: google_pay_data.eci_indicator, diff --git a/crates/hyperswitch_connectors/src/connectors/worldpay/payout_transformers.rs b/crates/hyperswitch_connectors/src/connectors/worldpay/payout_transformers.rs index ed81aadc30..3f209553df 100644 --- a/crates/hyperswitch_connectors/src/connectors/worldpay/payout_transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/worldpay/payout_transformers.rs @@ -151,7 +151,8 @@ impl TryFrom for PayoutInstrument { api_models::payouts::PayoutMethodData::Card(_) | api_models::payouts::PayoutMethodData::Bank(_) | api_models::payouts::PayoutMethodData::Wallet(_) - | api_models::payouts::PayoutMethodData::BankRedirect(_) => { + | api_models::payouts::PayoutMethodData::BankRedirect(_) + | api_models::payouts::PayoutMethodData::Passthrough(_) => { Err(errors::ConnectorError::NotImplemented( "Selected Payout Method is not implemented for Worldpay".to_string(), ) diff --git a/crates/hyperswitch_connectors/src/default_implementations.rs b/crates/hyperswitch_connectors/src/default_implementations.rs index 2ea8e3faec..fc6a2ac246 100644 --- a/crates/hyperswitch_connectors/src/default_implementations.rs +++ b/crates/hyperswitch_connectors/src/default_implementations.rs @@ -768,7 +768,6 @@ default_imp_for_create_order!( connectors::Adyen, connectors::Adyenplatform, connectors::Affirm, - connectors::Airwallex, connectors::Amazonpay, connectors::Archipel, connectors::Authipay, @@ -2359,6 +2358,7 @@ default_imp_for_pre_processing_steps!( connectors::Aci, connectors::Adyenplatform, connectors::Affirm, + connectors::Airwallex, connectors::Amazonpay, connectors::Archipel, connectors::Authipay, diff --git a/crates/hyperswitch_domain_models/src/router_request_types.rs b/crates/hyperswitch_domain_models/src/router_request_types.rs index 063868b034..9a6271f6f6 100644 --- a/crates/hyperswitch_domain_models/src/router_request_types.rs +++ b/crates/hyperswitch_domain_models/src/router_request_types.rs @@ -517,6 +517,8 @@ impl TryFrom for PaymentMethodTokenizationData { pub struct CreateOrderRequestData { pub minor_amount: MinorUnit, pub currency: storage_enums::Currency, + pub payment_method_data: Option, + pub order_details: Option>, } impl TryFrom for CreateOrderRequestData { @@ -526,6 +528,8 @@ impl TryFrom for CreateOrderRequestData { Ok(Self { minor_amount: data.minor_amount, currency: data.currency, + payment_method_data: Some(data.payment_method_data), + order_details: data.order_details, }) } } @@ -537,6 +541,8 @@ impl TryFrom for CreateOrderRequestData { Ok(Self { minor_amount: data.minor_amount, currency: data.currency, + payment_method_data: None, + order_details: data.order_details, }) } } diff --git a/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs b/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs index 4e5f58a890..dd6a9ff73a 100644 --- a/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs +++ b/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs @@ -36,7 +36,7 @@ pub enum SubscriptionStatus { Created, } -impl From for api_models::subscription::SubscriptionStatus { +impl From for common_enums::SubscriptionStatus { fn from(status: SubscriptionStatus) -> Self { match status { SubscriptionStatus::Pending => Self::Pending, diff --git a/crates/hyperswitch_interfaces/src/api_client.rs b/crates/hyperswitch_interfaces/src/api_client.rs index b1aedabcbc..5c47dac6a5 100644 --- a/crates/hyperswitch_interfaces/src/api_client.rs +++ b/crates/hyperswitch_interfaces/src/api_client.rs @@ -19,7 +19,6 @@ use masking::Maskable; use reqwest::multipart::Form; use router_env::{instrument, logger, tracing, tracing_actix_web::RequestId}; use serde_json::json; -use unified_connector_service_masking::ExposeInterface; use crate::{ configs, @@ -32,7 +31,6 @@ use crate::{ events::connector_api_logs::ConnectorEvent, metrics, types, types::Proxy, - unified_connector_service, }; /// A trait representing a converter for connector names to their corresponding enum variants. @@ -166,8 +164,14 @@ where }; connector_integration.handle_response(req, None, response) } - common_enums::CallConnectorAction::UCSHandleResponse(transform_data_bytes) => { - handle_ucs_response(router_data, transform_data_bytes) + common_enums::CallConnectorAction::UCSConsumeResponse(_) + | common_enums::CallConnectorAction::UCSHandleResponse(_) => { + Err(ConnectorError::ProcessingStepFailed(Some( + "CallConnectorAction UCSHandleResponse/UCSConsumeResponse used in Direct gateway system flow. These actions are only valid in UCS gateway system" + .to_string() + .into(), + )) + .into()) } common_enums::CallConnectorAction::Avoid => Ok(router_data), common_enums::CallConnectorAction::StatusUpdate { @@ -445,69 +449,6 @@ where } } -/// Handle UCS webhook response processing -pub fn handle_ucs_response( - router_data: RouterData, - transform_data_bytes: Vec, -) -> CustomResult, ConnectorError> -where - T: Clone + Debug + 'static, - Req: Debug + Clone + 'static, - Resp: Debug + Clone + 'static, -{ - let webhook_transform_data: unified_connector_service::WebhookTransformData = - serde_json::from_slice(&transform_data_bytes) - .change_context(ConnectorError::ResponseDeserializationFailed) - .attach_printable("Failed to deserialize UCS webhook transform data")?; - - let webhook_content = webhook_transform_data - .webhook_content - .ok_or(ConnectorError::ResponseDeserializationFailed) - .attach_printable("UCS webhook transform data missing webhook_content")?; - - let payment_get_response = match webhook_content.content { - Some(unified_connector_service_client::payments::webhook_response_content::Content::PaymentsResponse(payments_response)) => { - Ok(payments_response) - }, - Some(unified_connector_service_client::payments::webhook_response_content::Content::RefundsResponse(_)) => { - Err(ConnectorError::ProcessingStepFailed(Some("UCS webhook contains refund response but payment processing was expected".to_string().into())).into()) - }, - Some(unified_connector_service_client::payments::webhook_response_content::Content::DisputesResponse(_)) => { - Err(ConnectorError::ProcessingStepFailed(Some("UCS webhook contains dispute response but payment processing was expected".to_string().into())).into()) - }, - Some(unified_connector_service_client::payments::webhook_response_content::Content::IncompleteTransformation(_)) => { - Err(ConnectorError::ProcessingStepFailed(Some("UCS webhook contains incomplete transformation but payment processing was expected".to_string().into())).into()) - }, - None => { - Err(ConnectorError::ResponseDeserializationFailed) - .attach_printable("UCS webhook content missing payments_response") - } - }?; - - let (router_data_response, status_code) = - unified_connector_service::handle_unified_connector_service_response_for_payment_get( - payment_get_response.clone(), - ) - .change_context(ConnectorError::ProcessingStepFailed(None)) - .attach_printable("Failed to process UCS webhook response using PSync handler")?; - - let mut updated_router_data = router_data; - let router_data_response = router_data_response.map(|(response, status)| { - updated_router_data.status = status; - response - }); - - let _ = router_data_response.map_err(|error_response| { - updated_router_data.response = Err(error_response); - }); - updated_router_data.raw_connector_response = payment_get_response - .raw_connector_response - .map(|raw_connector_response| raw_connector_response.expose().into()); - updated_router_data.connector_http_status_code = Some(status_code); - - Ok(updated_router_data) -} - /// Calls the connector API and handles the response #[instrument(skip_all)] pub async fn call_connector_api( diff --git a/crates/hyperswitch_interfaces/src/unified_connector_service.rs b/crates/hyperswitch_interfaces/src/unified_connector_service.rs index be8d6cc7c8..0547bd9980 100644 --- a/crates/hyperswitch_interfaces/src/unified_connector_service.rs +++ b/crates/hyperswitch_interfaces/src/unified_connector_service.rs @@ -1,5 +1,6 @@ use common_enums::AttemptStatus; use common_utils::errors::CustomResult; +use error_stack::ResultExt; use hyperswitch_domain_models::{ router_data::ErrorResponse, router_response_types::PaymentsResponseData, }; @@ -10,7 +11,9 @@ use crate::helpers::ForeignTryFrom; /// Unified Connector Service (UCS) related transformers pub mod transformers; -pub use transformers::WebhookTransformData; +pub use transformers::{ + UnifiedConnectorServiceError, WebhookTransformData, WebhookTransformationStatus, +}; /// Type alias for return type used by unified connector service response handlers type UnifiedConnectorServiceResult = CustomResult< @@ -18,7 +21,7 @@ type UnifiedConnectorServiceResult = CustomResult< Result<(PaymentsResponseData, AttemptStatus), ErrorResponse>, u16, ), - transformers::UnifiedConnectorServiceError, + UnifiedConnectorServiceError, >; #[allow(missing_docs)] @@ -32,3 +35,30 @@ pub fn handle_unified_connector_service_response_for_payment_get( Ok((router_data_response, status_code)) } + +/// Extracts the payments response from UCS webhook content +pub fn get_payments_response_from_ucs_webhook_content( + webhook_content: payments_grpc::WebhookResponseContent, +) -> CustomResult { + match webhook_content.content { + Some(unified_connector_service_client::payments::webhook_response_content::Content::PaymentsResponse(payments_response)) => { + Ok(payments_response) + }, + Some(unified_connector_service_client::payments::webhook_response_content::Content::RefundsResponse(_)) => { + Err(UnifiedConnectorServiceError::WebhookProcessingFailure) + .attach_printable("UCS webhook contains refunds response but payments response was expected")? + }, + Some(unified_connector_service_client::payments::webhook_response_content::Content::DisputesResponse(_)) => { + Err(UnifiedConnectorServiceError::WebhookProcessingFailure) + .attach_printable("UCS webhook contains disputes response but payments response was expected")? + }, + Some(unified_connector_service_client::payments::webhook_response_content::Content::IncompleteTransformation(_)) => { + Err(UnifiedConnectorServiceError::WebhookProcessingFailure) + .attach_printable("UCS webhook contains incomplete transformation but payments response was expected")? + }, + None => { + Err(UnifiedConnectorServiceError::WebhookProcessingFailure) + .attach_printable("Missing payments response in UCS webhook content")? + } + } +} diff --git a/crates/hyperswitch_interfaces/src/unified_connector_service/transformers.rs b/crates/hyperswitch_interfaces/src/unified_connector_service/transformers.rs index 128814f05a..1d9768fd53 100644 --- a/crates/hyperswitch_interfaces/src/unified_connector_service/transformers.rs +++ b/crates/hyperswitch_interfaces/src/unified_connector_service/transformers.rs @@ -16,6 +16,10 @@ pub enum UnifiedConnectorServiceError { #[error("Failed to encode unified connector service request")] RequestEncodingFailed, + /// Failed to process webhook from unified connector service. + #[error("Failed to process webhook from unified connector service")] + WebhookProcessingFailure, + /// Request encoding failed due to a specific reason. #[error("Request encoding failed : {0}")] RequestEncodingFailedWithReason(String), @@ -90,6 +94,15 @@ pub enum UnifiedConnectorServiceError { WebhookTransformFailure, } +/// UCS Webhook transformation status +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum WebhookTransformationStatus { + /// Transformation completed successfully, no further action needed + Complete, + /// Transformation incomplete, requires second call for final status + Incomplete, +} + #[allow(missing_docs)] /// Webhook transform data structure containing UCS response information #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -98,6 +111,7 @@ pub struct WebhookTransformData { pub source_verified: bool, pub webhook_content: Option, pub response_ref_id: Option, + pub webhook_transformation_status: WebhookTransformationStatus, } impl ForeignTryFrom diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 7253d5be1b..da616baa43 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -252,6 +252,7 @@ Never share your secret api keys. Keep them guarded and secure. common_utils::payout_method_utils::InteracAdditionalData, common_utils::payout_method_utils::VenmoAdditionalData, common_utils::payout_method_utils::ApplePayDecryptAdditionalData, + common_utils::payout_method_utils::PassthroughAddtionalData, common_types::payments::SplitPaymentsRequest, common_types::payments::GpayTokenizationData, common_types::payments::GPayPredecryptData, @@ -699,6 +700,7 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payouts::PayoutListResponse, api_models::payouts::PayoutRetrieveBody, api_models::payouts::PayoutMethodData, + api_models::payouts::Passthrough, api_models::payouts::PayoutMethodDataResponse, api_models::payouts::PayoutLinkResponse, api_models::payouts::Bank, @@ -902,6 +904,7 @@ Never share your secret api keys. Keep them guarded and secure. api_models::subscription::EstimateSubscriptionResponse, api_models::subscription::GetPlansQuery, api_models::subscription::EstimateSubscriptionQuery, + api_models::subscription::ConfirmSubscriptionResponse, api_models::subscription::ConfirmSubscriptionPaymentDetails, api_models::subscription::PaymentDetails, api_models::subscription::CreateSubscriptionPaymentDetails, @@ -909,7 +912,7 @@ Never share your secret api keys. Keep them guarded and secure. api_models::subscription::SubscriptionPlanPrices, api_models::subscription::PaymentResponseData, api_models::subscription::Invoice, - api_models::subscription::SubscriptionStatus, + api_models::enums::SubscriptionStatus, api_models::subscription::PeriodUnit, )), modifiers(&SecurityAddon) diff --git a/crates/openapi/src/openapi_v2.rs b/crates/openapi/src/openapi_v2.rs index e5f44d5d33..d5dce59d44 100644 --- a/crates/openapi/src/openapi_v2.rs +++ b/crates/openapi/src/openapi_v2.rs @@ -184,6 +184,7 @@ Never share your secret api keys. Keep them guarded and secure. common_utils::payout_method_utils::InteracAdditionalData, common_utils::payout_method_utils::VenmoAdditionalData, common_utils::payout_method_utils::ApplePayDecryptAdditionalData, + common_utils::payout_method_utils::PassthroughAddtionalData, common_types::payments::SplitPaymentsRequest, common_types::payments::GpayTokenizationData, common_types::payments::GPayPredecryptData, @@ -660,6 +661,7 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payouts::PayoutRetrieveBody, api_models::payouts::PayoutRetrieveRequest, api_models::payouts::PayoutMethodData, + api_models::payouts::Passthrough, api_models::payouts::PayoutMethodDataResponse, api_models::payouts::PayoutLinkResponse, api_models::payouts::Bank, diff --git a/crates/router/src/compatibility/stripe/webhooks.rs b/crates/router/src/compatibility/stripe/webhooks.rs index 14a254f397..29942ef3c5 100644 --- a/crates/router/src/compatibility/stripe/webhooks.rs +++ b/crates/router/src/compatibility/stripe/webhooks.rs @@ -90,6 +90,7 @@ pub enum StripeWebhookObject { Mandate(StripeMandateResponse), #[cfg(feature = "payouts")] Payout(StripePayoutResponse), + Subscriptions, } #[derive(Serialize, Debug)] @@ -302,6 +303,7 @@ fn get_stripe_event_type(event_type: api_models::enums::EventType) -> &'static s api_models::enums::EventType::PayoutProcessing => "payout.created", api_models::enums::EventType::PayoutExpired => "payout.failed", api_models::enums::EventType::PayoutReversed => "payout.reconciliation_completed", + api_models::enums::EventType::InvoicePaid => "invoice.paid", } } @@ -344,6 +346,9 @@ impl From for StripeWebhookObject { } #[cfg(feature = "payouts")] api::OutgoingWebhookContent::PayoutDetails(payout) => Self::Payout((*payout).into()), + api_models::webhooks::OutgoingWebhookContent::SubscriptionDetails(_) => { + Self::Subscriptions + } } } } diff --git a/crates/router/src/configs/defaults/payout_required_fields.rs b/crates/router/src/configs/defaults/payout_required_fields.rs index 1e4ca2107c..97e4be9662 100644 --- a/crates/router/src/configs/defaults/payout_required_fields.rs +++ b/crates/router/src/configs/defaults/payout_required_fields.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use api_models::{ enums::{ CountryAlpha2, FieldType, - PaymentMethod::{BankTransfer, Card, Wallet}, + PaymentMethod::{BankRedirect, BankTransfer, Card, Wallet}, PaymentMethodType, PayoutConnectors, }, payment_methods::RequiredFieldInfo, @@ -62,6 +62,25 @@ impl Default for PayoutRequiredFields { ), ])), ), + ( + // TODO: Refactor to support multiple connectors, each having its own set of required fields. + BankRedirect, + PaymentMethodTypeInfo(HashMap::from([{ + let (pmt, mut gidadat_fields) = get_connector_payment_method_type_fields( + PayoutConnectors::Gigadat, + PaymentMethodType::Interac, + ); + + let (_, loonio_fields) = get_connector_payment_method_type_fields( + PayoutConnectors::Loonio, + PaymentMethodType::Interac, + ); + + gidadat_fields.fields.extend(loonio_fields.fields); + + (pmt, gidadat_fields) + }])), + ), ])) } } @@ -246,7 +265,7 @@ fn get_connector_payment_method_type_fields( // Bank Redirect PaymentMethodType::Interac => { - common_fields.extend(get_interac_fields(connector)); + common_fields.extend(get_interac_fields()); ( payment_method_type, ConnectorFields { @@ -393,86 +412,16 @@ fn get_paypal_fields() -> HashMap { )]) } -fn get_interac_fields(connector: PayoutConnectors) -> HashMap { - match connector { - PayoutConnectors::Loonio => HashMap::from([ - ( - "payout_method_data.bank_redirect.interac.email".to_string(), - RequiredFieldInfo { - required_field: "payout_method_data.bank_redirect.interac.email".to_string(), - display_name: "email".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ( - "billing.address.first_name".to_string(), - RequiredFieldInfo { - required_field: "billing.address.first_name".to_string(), - display_name: "billing_address_first_name".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ( - "billing.address.last_name".to_string(), - RequiredFieldInfo { - required_field: "billing.address.last_name".to_string(), - display_name: "billing_address_last_name".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ]), - PayoutConnectors::Gigadat => HashMap::from([ - ( - "payout_method_data.bank_redirect.interac.email".to_string(), - RequiredFieldInfo { - required_field: "payout_method_data.bank_redirect.interac.email".to_string(), - display_name: "email".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ( - "billing.address.first_name".to_string(), - RequiredFieldInfo { - required_field: "billing.address.first_name".to_string(), - display_name: "billing_address_first_name".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ( - "billing.address.last_name".to_string(), - RequiredFieldInfo { - required_field: "billing.address.last_name".to_string(), - display_name: "billing_address_last_name".to_string(), - field_type: FieldType::Text, - value: None, - }, - ), - ( - "billing.phone.number".to_string(), - RequiredFieldInfo { - required_field: "payment_method_data.billing.phone.number".to_string(), - display_name: "phone".to_string(), - field_type: FieldType::UserPhoneNumber, - value: None, - }, - ), - ( - "billing.phone.country_code".to_string(), - RequiredFieldInfo { - required_field: "payment_method_data.billing.phone.country_code".to_string(), - display_name: "dialing_code".to_string(), - field_type: FieldType::UserPhoneNumberCountryCode, - value: None, - }, - ), - ]), - _ => HashMap::from([]), - } +fn get_interac_fields() -> HashMap { + HashMap::from([( + "payout_method_data.bank_redirect.interac.email".to_string(), + RequiredFieldInfo { + required_field: "payout_method_data.bank_redirect.interac.email".to_string(), + display_name: "email".to_string(), + field_type: FieldType::Text, + value: None, + }, + )]) } fn get_countries_for_connector(connector: PayoutConnectors) -> Vec { @@ -639,6 +588,64 @@ fn get_billing_details(connector: PayoutConnectors) -> HashMap HashMap::from([ + ( + "billing.address.first_name".to_string(), + RequiredFieldInfo { + required_field: "billing.address.first_name".to_string(), + display_name: "billing_address_first_name".to_string(), + field_type: FieldType::Text, + value: None, + }, + ), + ( + "billing.address.last_name".to_string(), + RequiredFieldInfo { + required_field: "billing.address.last_name".to_string(), + display_name: "billing_address_last_name".to_string(), + field_type: FieldType::Text, + value: None, + }, + ), + ]), + PayoutConnectors::Gigadat => HashMap::from([ + ( + "billing.address.first_name".to_string(), + RequiredFieldInfo { + required_field: "billing.address.first_name".to_string(), + display_name: "billing_address_first_name".to_string(), + field_type: FieldType::Text, + value: None, + }, + ), + ( + "billing.address.last_name".to_string(), + RequiredFieldInfo { + required_field: "billing.address.last_name".to_string(), + display_name: "billing_address_last_name".to_string(), + field_type: FieldType::Text, + value: None, + }, + ), + ( + "billing.phone.number".to_string(), + RequiredFieldInfo { + required_field: "payment_method_data.billing.phone.number".to_string(), + display_name: "phone".to_string(), + field_type: FieldType::UserPhoneNumber, + value: None, + }, + ), + ( + "billing.phone.country_code".to_string(), + RequiredFieldInfo { + required_field: "payment_method_data.billing.phone.country_code".to_string(), + display_name: "dialing_code".to_string(), + field_type: FieldType::UserPhoneNumberCountryCode, + value: None, + }, + ), + ]), _ => HashMap::from([]), } } diff --git a/crates/router/src/core/payment_methods/cards.rs b/crates/router/src/core/payment_methods/cards.rs index bb1e52d9f5..e61c269f9d 100644 --- a/crates/router/src/core/payment_methods/cards.rs +++ b/crates/router/src/core/payment_methods/cards.rs @@ -4824,6 +4824,12 @@ pub async fn get_bank_from_hs_locker( } .into()) } + api::PayoutMethodData::Passthrough(_) => { + Err(errors::ApiErrorResponse::InvalidRequestData { + message: "Expected bank details, found passthrough details instead".to_string(), + } + .into()) + } } } diff --git a/crates/router/src/core/payment_methods/vault.rs b/crates/router/src/core/payment_methods/vault.rs index 81a3cfe849..d4153c12fe 100644 --- a/crates/router/src/core/payment_methods/vault.rs +++ b/crates/router/src/core/payment_methods/vault.rs @@ -860,6 +860,7 @@ pub enum VaultPayoutMethod { Bank(String), Wallet(String), BankRedirect(String), + Passthrough(String), } #[cfg(feature = "payouts")] @@ -875,6 +876,9 @@ impl Vaultable for api::PayoutMethodData { Self::BankRedirect(bank_redirect) => { VaultPayoutMethod::BankRedirect(bank_redirect.get_value1(customer_id)?) } + Self::Passthrough(passthrough) => { + VaultPayoutMethod::Passthrough(passthrough.get_value1(customer_id)?) + } }; value1 @@ -894,6 +898,9 @@ impl Vaultable for api::PayoutMethodData { Self::BankRedirect(bank_redirect) => { VaultPayoutMethod::BankRedirect(bank_redirect.get_value2(customer_id)?) } + Self::Passthrough(passthrough) => { + VaultPayoutMethod::Passthrough(passthrough.get_value2(customer_id)?) + } }; value2 @@ -937,6 +944,11 @@ impl Vaultable for api::PayoutMethodData { api::BankRedirectPayout::from_values(mvalue1, mvalue2)?; Ok((Self::BankRedirect(bank_redirect), supp_data)) } + (VaultPayoutMethod::Passthrough(mvalue1), VaultPayoutMethod::Passthrough(mvalue2)) => { + let (passthrough, supp_data) = + api::PassthroughPayout::from_values(mvalue1, mvalue2)?; + Ok((Self::Passthrough(passthrough), supp_data)) + } _ => Err(errors::VaultError::PayoutMethodNotSupported) .attach_printable("Payout method not supported"), } @@ -1007,6 +1019,67 @@ impl Vaultable for api::BankRedirectPayout { } } +#[cfg(feature = "payouts")] +impl Vaultable for api::PassthroughPayout { + fn get_value1( + &self, + _customer_id: Option, + ) -> CustomResult { + let value1 = TokenizedPassthroughSensitiveValues { + psp_token: self.psp_token.clone(), + }; + + value1 + .encode_to_string_of_json() + .change_context(errors::VaultError::RequestEncodingFailed) + .attach_printable( + "Failed to encode passthrough data - TokenizedPassthroughSensitiveValues", + ) + } + + fn get_value2( + &self, + customer_id: Option, + ) -> CustomResult { + let value2 = TokenizedPassthroughInsensitiveValues { + customer_id, + token_type: self.token_type, + }; + + value2 + .encode_to_string_of_json() + .change_context(errors::VaultError::RequestEncodingFailed) + .attach_printable("Failed to encode passthrough data value2") + } + + fn from_values( + value1: String, + value2: String, + ) -> CustomResult<(Self, SupplementaryVaultData), errors::VaultError> { + let value1: TokenizedPassthroughSensitiveValues = value1 + .parse_struct("TokenizedPassthroughSensitiveValues") + .change_context(errors::VaultError::ResponseDeserializationFailed) + .attach_printable("Could not deserialize into connector token data value1")?; + + let value2: TokenizedPassthroughInsensitiveValues = value2 + .parse_struct("TokenizedPassthroughInsensitiveValues") + .change_context(errors::VaultError::ResponseDeserializationFailed) + .attach_printable("Could not deserialize into connector token data value2")?; + + let passthrough = Self { + psp_token: value1.psp_token, + token_type: value2.token_type, + }; + + let supp_data = SupplementaryVaultData { + customer_id: value2.customer_id, + payment_method_id: None, + }; + + Ok((passthrough, supp_data)) + } +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct TokenizedBankRedirectSensitiveValues { pub email: Email, @@ -1018,6 +1091,17 @@ pub struct TokenizedBankRedirectInsensitiveValues { pub customer_id: Option, } +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct TokenizedPassthroughSensitiveValues { + pub psp_token: masking::Secret, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct TokenizedPassthroughInsensitiveValues { + pub customer_id: Option, + pub token_type: PaymentMethodType, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MockTokenizeDBValue { pub value1: String, diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 813075ead3..4059d315a9 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -4285,18 +4285,14 @@ where merchant_context, &router_data, Some(payment_data), + call_connector_action.clone(), ) .await?; - let is_handle_response_action = matches!( - call_connector_action, - CallConnectorAction::UCSHandleResponse(_) | CallConnectorAction::HandleResponse(_) - ); - record_time_taken_with(|| async { - match (execution_path, is_handle_response_action) { - // Process through UCS when system is UCS and not handling response - (ExecutionPath::UnifiedConnectorService, false) => { + match execution_path { + // Process through UCS when system is UCS and not handling response or if it is a UCS webhook action + ExecutionPath::UnifiedConnectorService => { process_through_ucs( state, req_state, @@ -4304,6 +4300,7 @@ where operation, payment_data, customer, + call_connector_action, validate_result, schedule_time, header_payload, @@ -4317,7 +4314,7 @@ where } // Process through Direct with Shadow UCS - (ExecutionPath::ShadowUnifiedConnectorService, false) => { + ExecutionPath::ShadowUnifiedConnectorService => { process_through_direct_with_shadow_unified_connector_service( state, req_state, @@ -4342,9 +4339,7 @@ where } // Process through Direct gateway - (ExecutionPath::Direct, _) - | (ExecutionPath::UnifiedConnectorService, true) - | (ExecutionPath::ShadowUnifiedConnectorService, true) => { + ExecutionPath::Direct => { process_through_direct( state, req_state, @@ -4821,6 +4816,7 @@ where merchant_context, &router_data, Some(payment_data), + call_connector_action.clone(), ) .await?; @@ -4906,6 +4902,7 @@ where &connector, ExecutionMode::Primary, // UCS is called in primary mode merchant_order_reference_id, + call_connector_action, ) .await?; @@ -4962,6 +4959,7 @@ where merchant_context, &router_data, Some(payment_data), + call_connector_action.clone(), ) .await?; if matches!(execution_path, ExecutionPath::UnifiedConnectorService) { @@ -5018,6 +5016,7 @@ where &connector, ExecutionMode::Primary, //UCS is called in primary mode merchant_order_reference_id, + call_connector_action, ) .await?; @@ -6656,14 +6655,6 @@ where dyn api::Connector: services::api::ConnectorIntegration, { - if !is_operation_complete_authorize(&operation) - && connector - .connector_name - .is_pre_processing_required_before_authorize() - { - router_data = router_data.preprocessing_steps(state, connector).await?; - return Ok((router_data, should_continue_payment)); - } //TODO: For ACH transfers, if preprocessing_step is not required for connectors encountered in future, add the check let router_data_and_should_continue_payment = match payment_data.get_payment_method_data() { Some(domain::PaymentMethodData::BankTransfer(_)) => (router_data, should_continue_payment), diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 2a7fb594fc..d2575306c7 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -249,6 +249,7 @@ pub trait Feature { _connector_data: &api::ConnectorData, _unified_connector_service_execution_mode: ExecutionMode, _merchant_order_reference_id: Option, + _call_connector_action: common_enums::CallConnectorAction, ) -> RouterResult<()> where F: Clone, diff --git a/crates/router/src/core/payments/flows/authorize_flow.rs b/crates/router/src/core/payments/flows/authorize_flow.rs index e47b17a3bc..6e790c9c57 100644 --- a/crates/router/src/core/payments/flows/authorize_flow.rs +++ b/crates/router/src/core/payments/flows/authorize_flow.rs @@ -542,6 +542,7 @@ impl Feature for types::PaymentsAu connector_data: &api::ConnectorData, unified_connector_service_execution_mode: enums::ExecutionMode, merchant_order_reference_id: Option, + _call_connector_action: common_enums::CallConnectorAction, ) -> RouterResult<()> { if self.request.mandate_id.is_some() { Box::pin(call_unified_connector_service_repeat_payment( @@ -710,9 +711,7 @@ pub async fn authorize_preprocessing_steps( router_data.request.to_owned(), resp.response.clone(), ); - if connector.connector_name == api_models::enums::Connector::Airwallex { - authorize_router_data.reference_id = resp.reference_id; - } else if connector.connector_name == api_models::enums::Connector::Nuvei { + if connector.connector_name == api_models::enums::Connector::Nuvei { let (enrolled_for_3ds, related_transaction_id) = match &authorize_router_data.response { Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse { enrolled_v2, diff --git a/crates/router/src/core/payments/flows/complete_authorize_flow.rs b/crates/router/src/core/payments/flows/complete_authorize_flow.rs index 944eb2bccf..6f72dfbc96 100644 --- a/crates/router/src/core/payments/flows/complete_authorize_flow.rs +++ b/crates/router/src/core/payments/flows/complete_authorize_flow.rs @@ -283,6 +283,7 @@ impl Feature _connector_data: &api::ConnectorData, _unified_connector_service_execution_mode: common_enums::ExecutionMode, _merchant_order_reference_id: Option, + _call_connector_action: common_enums::CallConnectorAction, ) -> RouterResult<()> { // Call UCS for Authorize flow Ok(()) diff --git a/crates/router/src/core/payments/flows/psync_flow.rs b/crates/router/src/core/payments/flows/psync_flow.rs index 661e980e10..9605457546 100644 --- a/crates/router/src/core/payments/flows/psync_flow.rs +++ b/crates/router/src/core/payments/flows/psync_flow.rs @@ -2,11 +2,14 @@ use std::{collections::HashMap, str::FromStr}; use async_trait::async_trait; use common_enums::{self, enums}; -use common_utils::{id_type, ucs_types}; +use common_utils::{id_type, types::MinorUnit, ucs_types}; use error_stack::ResultExt; use external_services::grpc_client; use hyperswitch_domain_models::payments as domain_payments; -use hyperswitch_interfaces::unified_connector_service::handle_unified_connector_service_response_for_payment_get; +use hyperswitch_interfaces::unified_connector_service::{ + get_payments_response_from_ucs_webhook_content, + handle_unified_connector_service_response_for_payment_get, +}; use unified_connector_service_client::payments as payments_grpc; use unified_connector_service_masking::ExposeInterface; @@ -237,75 +240,21 @@ impl Feature _connector_data: &api::ConnectorData, unified_connector_service_execution_mode: enums::ExecutionMode, merchant_order_reference_id: Option, + call_connector_action: common_enums::CallConnectorAction, ) -> RouterResult<()> { - let connector_name = self.connector.clone(); - let connector_enum = common_enums::connector_enums::Connector::from_str(&connector_name) - .change_context(ApiErrorResponse::IncorrectConnectorNameGiven)?; + match call_connector_action { + common_enums::CallConnectorAction::UCSConsumeResponse(transform_data_bytes) => { + let webhook_content: payments_grpc::WebhookResponseContent = + serde_json::from_slice(&transform_data_bytes) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to deserialize UCS webhook transform data")?; - let is_ucs_psync_disabled = state - .conf - .grpc_client - .unified_connector_service - .as_ref() - .is_some_and(|config| { - config - .ucs_psync_disabled_connectors - .contains(&connector_enum) - }); - - if is_ucs_psync_disabled { - logger::info!( - "UCS PSync call disabled for connector: {}, skipping UCS call", - connector_name - ); - return Ok(()); - } - - let client = state - .grpc_client - .unified_connector_service_client - .clone() - .ok_or(ApiErrorResponse::InternalServerError) - .attach_printable("Failed to fetch Unified Connector Service client")?; - - let payment_get_request = payments_grpc::PaymentServiceGetRequest::foreign_try_from(&*self) - .change_context(ApiErrorResponse::InternalServerError) - .attach_printable("Failed to construct Payment Get Request")?; - - let connector_auth_metadata = build_unified_connector_service_auth_metadata( - merchant_connector_account, - merchant_context, - ) - .change_context(ApiErrorResponse::InternalServerError) - .attach_printable("Failed to construct request metadata")?; - let merchant_reference_id = header_payload - .x_reference_id - .clone() - .or(merchant_order_reference_id) - .map(|id| id_type::PaymentReferenceId::from_str(id.as_str())) - .transpose() - .inspect_err(|err| logger::warn!(error=?err, "Invalid Merchant ReferenceId found")) - .ok() - .flatten() - .map(ucs_types::UcsReferenceId::Payment); - let header_payload = state - .get_grpc_headers_ucs(unified_connector_service_execution_mode) - .external_vault_proxy_metadata(None) - .merchant_reference_id(merchant_reference_id) - .lineage_ids(lineage_ids); - let updated_router_data = Box::pin(ucs_logging_wrapper( - self.clone(), - state, - payment_get_request, - header_payload, - |mut router_data, payment_get_request, grpc_headers| async move { - let response = client - .payment_get(payment_get_request, connector_auth_metadata, grpc_headers) - .await - .change_context(ApiErrorResponse::InternalServerError) - .attach_printable("Failed to get payment")?; - - let payment_get_response = response.into_inner(); + let payment_get_response = + get_payments_response_from_ucs_webhook_content(webhook_content) + .change_context(ApiErrorResponse::WebhookProcessingFailure) + .attach_printable( + "Failed to construct payments response from UCS webhook content", + )?; let (router_data_response, status_code) = handle_unified_connector_service_response_for_payment_get( @@ -315,23 +264,137 @@ impl Feature .attach_printable("Failed to deserialize UCS response")?; let router_data_response = router_data_response.map(|(response, status)| { - router_data.status = status; + self.status = status; response }); - router_data.response = router_data_response; - router_data.raw_connector_response = payment_get_response + self.response = router_data_response; + self.amount_captured = payment_get_response.captured_amount; + self.minor_amount_captured = payment_get_response + .minor_captured_amount + .map(MinorUnit::new); + self.raw_connector_response = payment_get_response .raw_connector_response .clone() .map(|raw_connector_response| raw_connector_response.expose().into()); - router_data.connector_http_status_code = Some(status_code); + self.connector_http_status_code = Some(status_code); + } + common_enums::CallConnectorAction::UCSHandleResponse(_) + | common_enums::CallConnectorAction::Trigger => { + let connector_name = self.connector.clone(); + let connector_enum = + common_enums::connector_enums::Connector::from_str(&connector_name) + .change_context(ApiErrorResponse::IncorrectConnectorNameGiven)?; - Ok((router_data, payment_get_response)) - }, - )) - .await?; + let is_ucs_psync_disabled = state + .conf + .grpc_client + .unified_connector_service + .as_ref() + .is_some_and(|config| { + config + .ucs_psync_disabled_connectors + .contains(&connector_enum) + }); - // Copy back the updated data - *self = updated_router_data; + if is_ucs_psync_disabled { + logger::info!( + "UCS PSync call disabled for connector: {}, skipping UCS call", + connector_name + ); + return Ok(()); + } + + let client = state + .grpc_client + .unified_connector_service_client + .clone() + .ok_or(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch Unified Connector Service client")?; + + let payment_get_request = + payments_grpc::PaymentServiceGetRequest::foreign_try_from(( + &*self, + call_connector_action, + )) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to construct Payment Get Request")?; + + let connector_auth_metadata = build_unified_connector_service_auth_metadata( + merchant_connector_account, + merchant_context, + ) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to construct request metadata")?; + let merchant_reference_id = header_payload + .x_reference_id + .clone() + .or(merchant_order_reference_id) + .map(|id| id_type::PaymentReferenceId::from_str(id.as_str())) + .transpose() + .inspect_err( + |err| logger::warn!(error=?err, "Invalid Merchant ReferenceId found"), + ) + .ok() + .flatten() + .map(ucs_types::UcsReferenceId::Payment); + let header_payload = state + .get_grpc_headers_ucs(unified_connector_service_execution_mode) + .external_vault_proxy_metadata(None) + .merchant_reference_id(merchant_reference_id) + .lineage_ids(lineage_ids); + let updated_router_data = Box::pin(ucs_logging_wrapper( + self.clone(), + state, + payment_get_request, + header_payload, + |mut router_data, payment_get_request, grpc_headers| async move { + let response = client + .payment_get(payment_get_request, connector_auth_metadata, grpc_headers) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to get payment")?; + + let payment_get_response = response.into_inner(); + + let (router_data_response, status_code) = + handle_unified_connector_service_response_for_payment_get( + payment_get_response.clone(), + ) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to deserialize UCS response")?; + + let router_data_response = + router_data_response.map(|(response, status)| { + router_data.status = status; + response + }); + router_data.response = router_data_response; + router_data.amount_captured = payment_get_response.captured_amount; + router_data.minor_amount_captured = payment_get_response + .minor_captured_amount + .map(MinorUnit::new); + router_data.raw_connector_response = payment_get_response + .raw_connector_response + .clone() + .map(|raw_connector_response| raw_connector_response.expose().into()); + router_data.connector_http_status_code = Some(status_code); + + Ok((router_data, payment_get_response)) + }, + )) + .await?; + + // Copy back the updated data + *self = updated_router_data; + } + common_enums::CallConnectorAction::HandleResponse(_) + | common_enums::CallConnectorAction::Avoid + | common_enums::CallConnectorAction::StatusUpdate { .. } => { + Err(ApiErrorResponse::InternalServerError).attach_printable( + "Invalid CallConnectorAction for payment sync via UCS Gateway system", + )? + } + } Ok(()) } } diff --git a/crates/router/src/core/payments/flows/setup_mandate_flow.rs b/crates/router/src/core/payments/flows/setup_mandate_flow.rs index 601cf14540..752441bc44 100644 --- a/crates/router/src/core/payments/flows/setup_mandate_flow.rs +++ b/crates/router/src/core/payments/flows/setup_mandate_flow.rs @@ -289,6 +289,7 @@ impl Feature for types::Setup _connector_data: &api::ConnectorData, unified_connector_service_execution_mode: enums::ExecutionMode, merchant_order_reference_id: Option, + _call_connector_action: common_enums::CallConnectorAction, ) -> RouterResult<()> { let client = state .grpc_client diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index 29bb45be48..bc3ef871b0 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -7867,6 +7867,7 @@ pub async fn process_through_ucs<'a, F, RouterDReq, ApiRequest, D>( operation: &'a BoxedOperation<'a, F, ApiRequest, D>, payment_data: &'a mut D, customer: &Option, + call_connector_action: CallConnectorAction, validate_result: &'a OperationsValidateResult, schedule_time: Option, header_payload: domain_payments::HeaderPayload, @@ -7968,6 +7969,7 @@ where connector_data, ExecutionMode::Primary, // UCS is called in primary mode merchant_order_reference_id, + call_connector_action, ) .await?; } @@ -8125,7 +8127,7 @@ where operation, payment_data, customer, - call_connector_action, + call_connector_action.clone(), validate_result, schedule_time, header_payload, @@ -8152,6 +8154,7 @@ where &connector, unified_connector_service_merchant_context, unified_connector_service_merchant_order_reference_id, + call_connector_action, ) .await }); @@ -8173,6 +8176,7 @@ pub async fn execute_shadow_unified_connector_service_call( connector_data: &api::ConnectorData, merchant_context: domain::MerchantContext, merchant_order_reference_id: Option, + call_connector_action: CallConnectorAction, ) where F: Send + Clone + Sync + 'static, RouterDReq: Send + Sync + Clone + 'static + Serialize, @@ -8191,6 +8195,7 @@ pub async fn execute_shadow_unified_connector_service_call( connector_data, ExecutionMode::Shadow, // Shadow mode for UCS merchant_order_reference_id, + call_connector_action, ) .await .map_err(|e| router_env::logger::debug!("Shadow UCS call failed: {:?}", e)); diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs index e96e1229e4..ba18f0111b 100644 --- a/crates/router/src/core/payout_link.rs +++ b/crates/router/src/core/payout_link.rs @@ -360,6 +360,7 @@ pub async fn filter_payout_methods( let mut bank_transfer_hash_set: HashSet = HashSet::new(); let mut card_hash_set: HashSet = HashSet::new(); let mut wallet_hash_set: HashSet = HashSet::new(); + let mut bank_redirect_hash_set: HashSet = HashSet::new(); let payout_filter_config = &state.conf.payout_method_filters.clone(); for mca in &filtered_mcas { let payout_methods = match &mca.payment_methods_enabled { @@ -408,9 +409,14 @@ pub async fn filter_payout_methods( payment_method_list_hm .insert(payment_method, bank_transfer_hash_set.clone()); } + common_enums::PaymentMethod::BankRedirect => { + bank_redirect_hash_set + .insert(request_payout_method_type.payment_method_type); + payment_method_list_hm + .insert(payment_method, bank_redirect_hash_set.clone()); + } common_enums::PaymentMethod::CardRedirect | common_enums::PaymentMethod::PayLater - | common_enums::PaymentMethod::BankRedirect | common_enums::PaymentMethod::Crypto | common_enums::PaymentMethod::BankDebit | common_enums::PaymentMethod::Reward diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index 6246278223..c65bd0d795 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -2778,7 +2778,7 @@ pub async fn payout_create_db_entries( payment_method .as_ref() .map(|pm| pm.payment_method_id.clone()), - Some(api_enums::PayoutType::foreign_from(payout_method_data)), + Some(api_enums::PayoutType::foreign_try_from(payout_method_data)?), ), None => { ( diff --git a/crates/router/src/core/payouts/helpers.rs b/crates/router/src/core/payouts/helpers.rs index a9164a2336..757fb163dd 100644 --- a/crates/router/src/core/payouts/helpers.rs +++ b/crates/router/src/core/payouts/helpers.rs @@ -379,7 +379,8 @@ pub async fn save_payout_data_to_locker( api_enums::PaymentMethodType::foreign_from(wallet), ), payouts::PayoutMethodData::Card(_) - | payouts::PayoutMethodData::BankRedirect(_) => { + | payouts::PayoutMethodData::BankRedirect(_) + | payouts::PayoutMethodData::Passthrough(_) => { Err(errors::ApiErrorResponse::InternalServerError)? } } @@ -1540,6 +1541,11 @@ pub async fn get_additional_payout_data( Box::new(bank_redirect_data.to_owned().into()), )) } + api::PayoutMethodData::Passthrough(passthrough) => { + Some(payout_additional::AdditionalPayoutMethodData::Passthrough( + Box::new(passthrough.to_owned().into()), + )) + } } } diff --git a/crates/router/src/core/revenue_recovery.rs b/crates/router/src/core/revenue_recovery.rs index dee8a6ee9f..5a9f32ab9d 100644 --- a/crates/router/src/core/revenue_recovery.rs +++ b/crates/router/src/core/revenue_recovery.rs @@ -529,7 +529,7 @@ pub async fn perform_calculate_workflow( .await?; // 2. Get best available token - let best_time_to_schedule = + let payment_processor_token_response = match revenue_recovery_workflow::get_token_with_schedule_time_based_on_retry_algorithm_type( state, &connector_customer_id, @@ -546,12 +546,14 @@ pub async fn perform_calculate_workflow( connector_customer_id = %connector_customer_id, "Failed to get best PSP token" ); - None + revenue_recovery_workflow::PaymentProcessorTokenResponse::None } }; - match best_time_to_schedule { - Some(scheduled_time) => { + match payment_processor_token_response { + revenue_recovery_workflow::PaymentProcessorTokenResponse::ScheduledTime { + scheduled_time, + } => { logger::info!( process_id = %process.id, connector_customer_id = %connector_customer_id, @@ -602,113 +604,86 @@ pub async fn perform_calculate_workflow( ); } - None => { - let scheduled_token = match storage::revenue_recovery_redis_operation:: - RedisTokenManager::get_payment_processor_token_with_schedule_time(state, &connector_customer_id) - .await { - Ok(scheduled_token_opt) => scheduled_token_opt, - Err(e) => { - logger::error!( - error = ?e, - connector_customer_id = %connector_customer_id, - "Failed to get PSP token status" - ); - None - } - }; + revenue_recovery_workflow::PaymentProcessorTokenResponse::NextAvailableTime { + next_available_time, + } => { + // Update scheduled time to next_available_time + Buffer + // here next_available_time is the wait time + logger::info!( + process_id = %process.id, + connector_customer_id = %connector_customer_id, + "No token but time available, rescheduling for scheduled time " + ); - match scheduled_token { - Some(scheduled_token) => { - // Update scheduled time to scheduled time + 15 minutes - // here scheduled_time is the wait time 15 minutes is a buffer time that we are adding - logger::info!( + update_calculate_job_schedule_time( + db, + process, + time::Duration::seconds( + state + .conf + .revenue_recovery + .recovery_timestamp + .job_schedule_buffer_time_in_seconds, + ), + Some(next_available_time), + &connector_customer_id, + retry_algorithm_type, + ) + .await?; + } + revenue_recovery_workflow::PaymentProcessorTokenResponse::None => { + logger::info!( + process_id = %process.id, + connector_customer_id = %connector_customer_id, + "Hard decline flag is false, rescheduling for scheduled time + 15 mins" + ); + + update_calculate_job_schedule_time( + db, + process, + time::Duration::seconds( + state + .conf + .revenue_recovery + .recovery_timestamp + .job_schedule_buffer_time_in_seconds, + ), + Some(common_utils::date_time::now()), + &connector_customer_id, + retry_algorithm_type, + ) + .await?; + } + revenue_recovery_workflow::PaymentProcessorTokenResponse::HardDecline => { + // Finish calculate workflow with CALCULATE_WORKFLOW_FINISH + logger::info!( + process_id = %process.id, + connector_customer_id = %connector_customer_id, + "Token/Tokens is/are Hard decline, finishing CALCULATE_WORKFLOW" + ); + + db.as_scheduler() + .finish_process_with_business_status( + process.clone(), + business_status::CALCULATE_WORKFLOW_FINISH, + ) + .await + .map_err(|e| { + logger::error!( process_id = %process.id, - connector_customer_id = %connector_customer_id, - "No token but time available, rescheduling for scheduled time + 15 mins" + error = ?e, + "Failed to finish CALCULATE_WORKFLOW" ); + sch_errors::ProcessTrackerError::ProcessUpdateFailed + })?; - update_calculate_job_schedule_time( - db, - process, - time::Duration::seconds( - state - .conf - .revenue_recovery - .recovery_timestamp - .job_schedule_buffer_time_in_seconds, - ), - scheduled_token.scheduled_at, - &connector_customer_id, - ) - .await?; - } - None => { - let hard_decline_flag = storage::revenue_recovery_redis_operation:: - RedisTokenManager::are_all_tokens_hard_declined( - state, - &connector_customer_id - ) - .await - .ok() - .unwrap_or(false); + event_type = Some(common_enums::EventType::PaymentFailed); - match hard_decline_flag { - false => { - logger::info!( - process_id = %process.id, - connector_customer_id = %connector_customer_id, - "Hard decline flag is false, rescheduling for scheduled time + 15 mins" - ); - - update_calculate_job_schedule_time( - db, - process, - time::Duration::seconds( - state - .conf - .revenue_recovery - .recovery_timestamp - .job_schedule_buffer_time_in_seconds, - ), - Some(common_utils::date_time::now()), - &connector_customer_id, - ) - .await?; - } - true => { - // Finish calculate workflow with CALCULATE_WORKFLOW_FINISH - logger::info!( - process_id = %process.id, - connector_customer_id = %connector_customer_id, - "No token available, finishing CALCULATE_WORKFLOW" - ); - - db.as_scheduler() - .finish_process_with_business_status( - process.clone(), - business_status::CALCULATE_WORKFLOW_FINISH, - ) - .await - .map_err(|e| { - logger::error!( - process_id = %process.id, - error = ?e, - "Failed to finish CALCULATE_WORKFLOW" - ); - sch_errors::ProcessTrackerError::ProcessUpdateFailed - })?; - - event_type = Some(common_enums::EventType::PaymentFailed); - - logger::info!( - process_id = %process.id, - connector_customer_id = %connector_customer_id, - "CALCULATE_WORKFLOW finished successfully" - ); - } - } - } - } + logger::info!( + process_id = %process.id, + connector_customer_id = %connector_customer_id, + "CALCULATE_WORKFLOW finished successfully" + ); } } @@ -749,6 +724,7 @@ async fn update_calculate_job_schedule_time( additional_time: time::Duration, base_time: Option, connector_customer_id: &str, + retry_algorithm_type: common_enums::RevenueRecoveryAlgorithmType, ) -> Result<(), sch_errors::ProcessTrackerError> { let now = common_utils::date_time::now(); @@ -759,11 +735,22 @@ async fn update_calculate_job_schedule_time( connector_customer_id = %connector_customer_id, "Rescheduling Calculate Job at " ); + let mut old_tracking_data: pcr::RevenueRecoveryWorkflowTrackingData = + serde_json::from_value(process.tracking_data.clone()) + .change_context(errors::RecoveryError::ValueNotFound) + .attach_printable("Failed to deserialize the tracking data from process tracker")?; + + old_tracking_data.revenue_recovery_retry = retry_algorithm_type; + + let tracking_data = serde_json::to_value(old_tracking_data) + .change_context(errors::RecoveryError::ValueNotFound) + .attach_printable("Failed to serialize the tracking data for process tracker")?; + let pt_update = storage::ProcessTrackerUpdate::Update { name: Some("CALCULATE_WORKFLOW".to_string()), retry_count: Some(process.clone().retry_count), schedule_time: Some(new_schedule_time), - tracking_data: Some(process.clone().tracking_data), + tracking_data: Some(tracking_data), business_status: Some(String::from(business_status::PENDING)), status: Some(common_enums::ProcessTrackerStatus::Pending), updated_at: Some(common_utils::date_time::now()), diff --git a/crates/router/src/core/revenue_recovery/types.rs b/crates/router/src/core/revenue_recovery/types.rs index bf90eb0d8c..6b0a267f66 100644 --- a/crates/router/src/core/revenue_recovery/types.rs +++ b/crates/router/src/core/revenue_recovery/types.rs @@ -176,8 +176,7 @@ impl RevenueRecoveryPaymentsAttemptStatus { state, &connector_customer_id, &None, - // Since this is succeeded payment attempt, 'is_hard_decine' will be false. - &Some(false), + &None, used_token.as_deref(), ) .await; @@ -493,19 +492,12 @@ impl Action { ); }; - let is_hard_decline = revenue_recovery::check_hard_decline( - state, - &payment_data.payment_attempt, - ) - .await - .ok(); - // update the status of token in redis let _update_error_code = storage::revenue_recovery_redis_operation::RedisTokenManager::update_payment_processor_token_error_code_from_process_tracker( state, &connector_customer_id, &None, - &is_hard_decline, + &None, Some(&scheduled_token.payment_processor_token_details.payment_processor_token), ) .await; @@ -659,7 +651,7 @@ impl Action { logger::info!( process_id = %process.id, connector_customer_id = %connector_customer_id, - "No token available, finishing CALCULATE_WORKFLOW" + "No token available, finishing EXECUTE_WORKFLOW" ); state @@ -671,12 +663,12 @@ impl Action { ) .await .change_context(errors::RecoveryError::ProcessTrackerFailure) - .attach_printable("Failed to finish CALCULATE_WORKFLOW")?; + .attach_printable("Failed to finish EXECUTE_WORKFLOW")?; logger::info!( process_id = %process.id, connector_customer_id = %connector_customer_id, - "CALCULATE_WORKFLOW finished successfully" + "EXECUTE_WORKFLOW finished successfully" ); Ok(Self::TerminalFailure(payment_attempt.clone())) } @@ -854,8 +846,7 @@ impl Action { state, &connector_customer_id, &None, - // Since this is succeeded, 'hard_decine' will be false. - &Some(false), + &None, used_token.as_deref(), ) .await; @@ -1144,9 +1135,19 @@ pub async fn reopen_calculate_workflow_on_payment_failure( .change_context(errors::RecoveryError::ValueNotFound) .attach_printable("Failed to deserialize the tracking data from process tracker")?; + let retry_algorithm_type = profile + .revenue_recovery_retry_algorithm_type + .filter(|retry_type| *retry_type != common_enums::RevenueRecoveryAlgorithmType::Monitoring) // ignore Monitoring + .unwrap_or(old_tracking_data.revenue_recovery_retry); + let new_tracking_data = pcr::RevenueRecoveryWorkflowTrackingData { payment_attempt_id: latest_attempt_id.clone(), - ..old_tracking_data + revenue_recovery_retry: retry_algorithm_type, + merchant_id: old_tracking_data.merchant_id.clone(), + profile_id: old_tracking_data.profile_id.clone(), + global_payment_id: old_tracking_data.global_payment_id.clone(), + billing_mca_id: old_tracking_data.billing_mca_id.clone(), + invoice_scheduled_time: old_tracking_data.invoice_scheduled_time, }; let tracking_data = serde_json::to_value(new_tracking_data) @@ -1457,7 +1458,7 @@ impl RevenueRecoveryOutgoingWebhook { event_status, event_class, payment_attempt_id, - enums::EventObjectType::PaymentDetails, + common_enums::EventObjectType::PaymentDetails, outgoing_webhook_content, payment_intent.created_at, ) diff --git a/crates/router/src/core/unified_connector_service.rs b/crates/router/src/core/unified_connector_service.rs index f964df75c1..32155400fd 100644 --- a/crates/router/src/core/unified_connector_service.rs +++ b/crates/router/src/core/unified_connector_service.rs @@ -4,8 +4,9 @@ use api_models::admin; #[cfg(feature = "v2")] use base64::Engine; use common_enums::{ - connector_enums::Connector, AttemptStatus, ConnectorIntegrationType, ExecutionMode, - ExecutionPath, GatewaySystem, PaymentMethodType, ShadowRolloutAvailability, UcsAvailability, + connector_enums::Connector, AttemptStatus, CallConnectorAction, ConnectorIntegrationType, + ExecutionMode, ExecutionPath, GatewaySystem, PaymentMethodType, ShadowRolloutAvailability, + UcsAvailability, }; #[cfg(feature = "v2")] use common_utils::consts::BASE64_ENGINE; @@ -65,7 +66,7 @@ use crate::{ pub mod transformers; // Re-export webhook transformer types for easier access -pub use transformers::WebhookTransformData; +pub use transformers::{WebhookTransformData, WebhookTransformationStatus}; /// Type alias for return type used by unified connector service response handlers type UnifiedConnectorServiceResult = CustomResult< @@ -144,6 +145,7 @@ pub async fn should_call_unified_connector_service( merchant_context: &MerchantContext, router_data: &RouterData, payment_data: Option<&D>, + call_connector_action: CallConnectorAction, ) -> RouterResult where D: OperationSessionGetters, @@ -192,15 +194,47 @@ where // Single decision point using pattern matching let (gateway_system, execution_path) = if ucs_availability == UcsAvailability::Disabled { - router_env::logger::debug!("UCS is disabled, using Direct gateway"); - (GatewaySystem::Direct, ExecutionPath::Direct) + match call_connector_action { + CallConnectorAction::UCSConsumeResponse(_) + | CallConnectorAction::UCSHandleResponse(_) => { + Err(errors::ApiErrorResponse::InternalServerError) + .attach_printable("CallConnectorAction UCSHandleResponse/UCSConsumeResponse received but UCS is disabled. These actions are only valid in UCS gateway")? + } + CallConnectorAction::Avoid + | CallConnectorAction::Trigger + | CallConnectorAction::HandleResponse(_) + | CallConnectorAction::StatusUpdate { .. } => { + router_env::logger::debug!("UCS is disabled, using Direct gateway"); + (GatewaySystem::Direct, ExecutionPath::Direct) + } + } } else { - // UCS is enabled, call decide function - decide_execution_path( - connector_integration_type, - previous_gateway, - shadow_rollout_availability, - )? + match call_connector_action { + CallConnectorAction::UCSConsumeResponse(_) + | CallConnectorAction::UCSHandleResponse(_) => { + router_env::logger::info!("CallConnectorAction UCSHandleResponse/UCSConsumeResponse received, using UCS gateway"); + ( + GatewaySystem::UnifiedConnectorService, + ExecutionPath::UnifiedConnectorService, + ) + } + CallConnectorAction::HandleResponse(_) => { + router_env::logger::info!( + "CallConnectorAction HandleResponse received, using Direct gateway" + ); + (GatewaySystem::Direct, ExecutionPath::Direct) + } + CallConnectorAction::Trigger + | CallConnectorAction::Avoid + | CallConnectorAction::StatusUpdate { .. } => { + // UCS is enabled, call decide function + decide_execution_path( + connector_integration_type, + previous_gateway, + shadow_rollout_availability, + )? + } + } }; router_env::logger::info!( diff --git a/crates/router/src/core/unified_connector_service/transformers.rs b/crates/router/src/core/unified_connector_service/transformers.rs index 5957c3cc08..becd2b1a42 100644 --- a/crates/router/src/core/unified_connector_service/transformers.rs +++ b/crates/router/src/core/unified_connector_service/transformers.rs @@ -21,6 +21,7 @@ pub use hyperswitch_interfaces::{ helpers::ForeignTryFrom, unified_connector_service::{ transformers::convert_connector_service_status_code, WebhookTransformData, + WebhookTransformationStatus, }, }; use masking::{ExposeInterface, PeekInterface}; @@ -35,13 +36,19 @@ use crate::{ core::{errors, unified_connector_service}, types::transformers, }; -impl transformers::ForeignTryFrom<&RouterData> - for payments_grpc::PaymentServiceGetRequest +impl + transformers::ForeignTryFrom<( + &RouterData, + common_enums::CallConnectorAction, + )> for payments_grpc::PaymentServiceGetRequest { type Error = error_stack::Report; fn foreign_try_from( - router_data: &RouterData, + (router_data, call_connector_action): ( + &RouterData, + common_enums::CallConnectorAction, + ), ) -> Result { let connector_transaction_id = router_data .request @@ -79,12 +86,32 @@ impl transformers::ForeignTryFrom<&RouterData Some(res), + common_enums::CallConnectorAction::Trigger => None, + common_enums::CallConnectorAction::HandleResponse(_) + | common_enums::CallConnectorAction::UCSConsumeResponse(_) + | common_enums::CallConnectorAction::Avoid + | common_enums::CallConnectorAction::StatusUpdate { .. } => Err( + UnifiedConnectorServiceError::RequestEncodingFailedWithReason( + "Invalid CallConnectorAction for payment sync call via UCS Gateway system" + .to_string(), + ), + )?, + }; + + let capture_method = router_data + .request + .capture_method + .map(payments_grpc::CaptureMethod::foreign_try_from) + .transpose()?; + Ok(Self { transaction_id: connector_transaction_id.or(encoded_data), request_ref_id: connector_ref_id, + capture_method: capture_method.map(|capture_method| capture_method.into()), + handle_response, access_token: None, - capture_method: None, - handle_response: None, amount: router_data.request.amount.get_amount_as_i64(), currency: currency.into(), }) @@ -206,7 +233,7 @@ impl .collect::>() }) .unwrap_or_default(), - test_mode: None, + test_mode: router_data.test_mode, connector_customer_id: router_data.connector_customer.clone(), }) } @@ -1165,6 +1192,15 @@ pub fn transform_ucs_webhook_response( let event_type = api_models::webhooks::IncomingWebhookEvent::from_ucs_event_type(response.event_type); + let webhook_transformation_status = if matches!( + response.transformation_status(), + payments_grpc::WebhookTransformationStatus::Incomplete + ) { + WebhookTransformationStatus::Incomplete + } else { + WebhookTransformationStatus::Complete + }; + Ok(WebhookTransformData { event_type, source_verified: response.source_verified, @@ -1176,6 +1212,7 @@ pub fn transform_ucs_webhook_response( payments_grpc::identifier::IdType::NoResponseIdMarker(_) => None, }) }), + webhook_transformation_status, }) } diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs index bc2e702867..65abf6b2f8 100644 --- a/crates/router/src/core/webhooks/incoming.rs +++ b/crates/router/src/core/webhooks/incoming.rs @@ -25,6 +25,7 @@ use hyperswitch_domain_models::{ use hyperswitch_interfaces::webhooks::{IncomingWebhookFlowError, IncomingWebhookRequestDetails}; use masking::{ExposeInterface, PeekInterface}; use router_env::{instrument, tracing, tracing_actix_web::RequestId}; +use unified_connector_service_client::payments as payments_grpc; use super::{types, utils, MERCHANT_ID}; use crate::{ @@ -492,25 +493,50 @@ async fn process_non_ucs_webhook( } } +/// Extract resource object from UCS WebhookResponseContent +fn get_ucs_webhook_resource_object( + webhook_response_content: &payments_grpc::WebhookResponseContent, +) -> errors::RouterResult> { + let resource_object = match &webhook_response_content.content { + Some(payments_grpc::webhook_response_content::Content::IncompleteTransformation( + incomplete_transformation_response, + )) => { + // Deserialize resource object + serde_json::from_slice::( + &incomplete_transformation_response.resource_object, + ) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to deserialize resource object from UCS webhook response")? + } + _ => { + // Convert UCS webhook content to appropriate format + serde_json::to_value(webhook_response_content) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize UCS webhook content")? + } + }; + Ok(Box::new(resource_object)) +} + /// Extract webhook event object based on transform data availability fn extract_webhook_event_object( - transform_data: &Option>, + webhook_transform_data: &Option>, connector: &ConnectorEnum, request_details: &IncomingWebhookRequestDetails<'_>, ) -> errors::RouterResult> { - match transform_data { - Some(transform_data) => match &transform_data.webhook_content { - Some(webhook_content) => { - let serialized_value = serde_json::to_value(webhook_content) - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Failed to serialize UCS webhook content")?; - Ok(Box::new(serialized_value)) - } - None => connector - .get_webhook_resource_object(request_details) - .switch() - .attach_printable("Could not find resource object in incoming webhook body"), - }, + match webhook_transform_data { + Some(webhook_transform_data) => webhook_transform_data + .webhook_content + .as_ref() + .map(|webhook_response_content| { + get_ucs_webhook_resource_object(webhook_response_content) + }) + .unwrap_or_else(|| { + connector + .get_webhook_resource_object(request_details) + .switch() + .attach_printable("Could not find resource object in incoming webhook body") + }), None => connector .get_webhook_resource_object(request_details) .switch() @@ -633,30 +659,31 @@ async fn process_webhook_business_logic( logger::info!(source_verified=?source_verified); - let event_object: Box = - if let Some(transform_data) = webhook_transform_data { - // Use UCS transform data if available - if let Some(webhook_content) = &transform_data.webhook_content { - // Convert UCS webhook content to appropriate format - Box::new( - serde_json::to_value(webhook_content) - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Failed to serialize UCS webhook content")?, - ) - } else { - // Fall back to connector extraction - connector - .get_webhook_resource_object(request_details) - .switch() - .attach_printable("Could not find resource object in incoming webhook body")? - } - } else { + let event_object: Box = match webhook_transform_data { + Some(webhook_transform_data) => { + // Extract resource_object from UCS webhook content + webhook_transform_data + .webhook_content + .as_ref() + .map(|webhook_response_content| { + get_ucs_webhook_resource_object(webhook_response_content) + }) + .unwrap_or_else(|| { + // Fall back to connector extraction + connector + .get_webhook_resource_object(request_details) + .switch() + .attach_printable("Could not find resource object in incoming webhook body") + })? + } + None => { // Use traditional connector extraction connector .get_webhook_resource_object(request_details) .switch() .attach_printable("Could not find resource object in incoming webhook body")? - }; + } + }; let webhook_details = api::IncomingWebhookDetails { object_reference_id: object_ref_id.clone(), @@ -1006,11 +1033,16 @@ async fn payments_incoming_webhook_flow( match webhook_transform_data.as_ref() { Some(transform_data) => { - // Serialize the transform data to pass to UCS handler - let transform_data_bytes = serde_json::to_vec(transform_data.as_ref()) - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Failed to serialize UCS webhook transform data")?; - payments::CallConnectorAction::UCSHandleResponse(transform_data_bytes) + match transform_data.webhook_transformation_status { + unified_connector_service::WebhookTransformationStatus::Complete => { + // Consume response from UCS + payments::CallConnectorAction::UCSConsumeResponse(resource_object) + } + unified_connector_service::WebhookTransformationStatus::Incomplete => { + // Make a second call to UCS + payments::CallConnectorAction::UCSHandleResponse(resource_object) + } + } } None => payments::CallConnectorAction::HandleResponse(resource_object), } diff --git a/crates/router/src/core/webhooks/outgoing.rs b/crates/router/src/core/webhooks/outgoing.rs index adc54b09b9..c5ad48d3f7 100644 --- a/crates/router/src/core/webhooks/outgoing.rs +++ b/crates/router/src/core/webhooks/outgoing.rs @@ -1026,6 +1026,13 @@ impl ForeignFrom<&api::OutgoingWebhookContent> for storage::EventMetadata { webhooks::OutgoingWebhookContent::PayoutDetails(payout_response) => Self::Payout { payout_id: payout_response.payout_id.clone(), }, + webhooks::OutgoingWebhookContent::SubscriptionDetails(subscription) => { + Self::Subscription { + subscription_id: subscription.id.clone(), + invoice_id: subscription.get_optional_invoice_id(), + payment_id: subscription.get_optional_payment_id(), + } + } } } } @@ -1070,5 +1077,15 @@ fn get_outgoing_webhook_event_content_from_event_metadata( mandate_id, content: serde_json::Value::Null, }, + diesel_models::EventMetadata::Subscription { + subscription_id, + invoice_id, + payment_id, + } => OutgoingWebhookEventContent::Subscription { + subscription_id, + invoice_id, + payment_id, + content: serde_json::Value::Null, + }, }) } diff --git a/crates/router/src/core/webhooks/outgoing_v2.rs b/crates/router/src/core/webhooks/outgoing_v2.rs index d0e0ecaa69..cc93f4a385 100644 --- a/crates/router/src/core/webhooks/outgoing_v2.rs +++ b/crates/router/src/core/webhooks/outgoing_v2.rs @@ -659,6 +659,16 @@ impl ForeignFrom for outgoing_webhook_logs::OutgoingWebh mandate_id, content: serde_json::Value::Null, }, + diesel_models::EventMetadata::Subscription { + subscription_id, + invoice_id, + payment_id, + } => Self::Subscription { + subscription_id, + invoice_id, + payment_id, + content: serde_json::Value::Null, + }, } } } diff --git a/crates/router/src/core/webhooks/recovery_incoming.rs b/crates/router/src/core/webhooks/recovery_incoming.rs index ef11627f2b..3157faca38 100644 --- a/crates/router/src/core/webhooks/recovery_incoming.rs +++ b/crates/router/src/core/webhooks/recovery_incoming.rs @@ -908,7 +908,7 @@ impl RevenueRecoveryAttempt { payment_connector_name: Option, ) -> CustomResult<(), errors::RevenueRecoveryError> { let revenue_recovery_attempt_data = &self.0; - let error_code = revenue_recovery_attempt_data.error_code.clone(); + let error_code = recovery_attempt.error_code.clone(); let error_message = revenue_recovery_attempt_data.error_message.clone(); let connector_name = payment_connector_name .ok_or(errors::RevenueRecoveryError::TransactionWebhookProcessingFailed) @@ -939,6 +939,7 @@ impl RevenueRecoveryAttempt { daily_retry_history: HashMap::from([(recovery_attempt.created_at.date(), 1)]), scheduled_at: None, is_hard_decline: Some(is_hard_decline), + modified_at: Some(recovery_attempt.created_at), payment_processor_token_details: PaymentProcessorTokenDetails { payment_processor_token: revenue_recovery_attempt_data .processor_payment_method_token diff --git a/crates/router/src/events/outgoing_webhook_logs.rs b/crates/router/src/events/outgoing_webhook_logs.rs index 43f2ae54be..2d625a733b 100644 --- a/crates/router/src/events/outgoing_webhook_logs.rs +++ b/crates/router/src/events/outgoing_webhook_logs.rs @@ -72,6 +72,12 @@ pub enum OutgoingWebhookEventContent { mandate_id: String, content: Value, }, + Subscription { + subscription_id: common_utils::id_type::SubscriptionId, + invoice_id: Option, + payment_id: Option, + content: Value, + }, } pub trait OutgoingWebhookEventMetric { fn get_outgoing_webhook_event_content(&self) -> Option; @@ -111,6 +117,15 @@ impl OutgoingWebhookEventMetric for OutgoingWebhookContent { content: masking::masked_serialize(&payout_payload) .unwrap_or(serde_json::json!({"error":"failed to serialize"})), }), + Self::SubscriptionDetails(subscription) => { + Some(OutgoingWebhookEventContent::Subscription { + subscription_id: subscription.id.clone(), + invoice_id: subscription.get_optional_invoice_id(), + payment_id: subscription.get_optional_payment_id(), + content: masking::masked_serialize(&subscription) + .unwrap_or(serde_json::json!({"error":"failed to serialize"})), + }) + } } } } diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 50c7c6471f..c4cbb1ccdb 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -42,7 +42,7 @@ pub use hyperswitch_interfaces::{ }, api_client::{ call_connector_api, execute_connector_processing_step, handle_response, - handle_ucs_response, store_raw_connector_response_if_required, + store_raw_connector_response_if_required, }, connector_integration_v2::{ BoxedConnectorIntegrationV2, ConnectorIntegrationAnyV2, ConnectorIntegrationV2, diff --git a/crates/router/src/types/api/payouts.rs b/crates/router/src/types/api/payouts.rs index 2652c14ab8..1c8a318457 100644 --- a/crates/router/src/types/api/payouts.rs +++ b/crates/router/src/types/api/payouts.rs @@ -1,11 +1,11 @@ pub use api_models::payouts::{ AchBankTransfer, BacsBankTransfer, Bank as BankPayout, BankRedirect as BankRedirectPayout, - CardPayout, PaymentMethodTypeInfo, PayoutActionRequest, PayoutAttemptResponse, - PayoutCreateRequest, PayoutCreateResponse, PayoutEnabledPaymentMethodsInfo, PayoutLinkResponse, - PayoutListConstraints, PayoutListFilterConstraints, PayoutListFilters, PayoutListResponse, - PayoutMethodData, PayoutMethodDataResponse, PayoutRequest, PayoutRetrieveBody, - PayoutRetrieveRequest, PixBankTransfer, RequiredFieldsOverrideRequest, SepaBankTransfer, - Wallet as WalletPayout, + CardPayout, Passthrough as PassthroughPayout, PaymentMethodTypeInfo, PayoutActionRequest, + PayoutAttemptResponse, PayoutCreateRequest, PayoutCreateResponse, + PayoutEnabledPaymentMethodsInfo, PayoutLinkResponse, PayoutListConstraints, + PayoutListFilterConstraints, PayoutListFilters, PayoutListResponse, PayoutMethodData, + PayoutMethodDataResponse, PayoutRequest, PayoutRetrieveBody, PayoutRetrieveRequest, + PixBankTransfer, RequiredFieldsOverrideRequest, SepaBankTransfer, Wallet as WalletPayout, }; pub use hyperswitch_domain_models::router_flow_types::payouts::{ PoCancel, PoCreate, PoEligibility, PoFulfill, PoQuote, PoRecipient, PoRecipientAccount, PoSync, diff --git a/crates/router/src/types/storage/revenue_recovery_redis_operation.rs b/crates/router/src/types/storage/revenue_recovery_redis_operation.rs index 96b0a43835..d732d9baa7 100644 --- a/crates/router/src/types/storage/revenue_recovery_redis_operation.rs +++ b/crates/router/src/types/storage/revenue_recovery_redis_operation.rs @@ -43,6 +43,8 @@ pub struct PaymentProcessorTokenStatus { pub scheduled_at: Option, /// Indicates if the token is a hard decline (no retries allowed) pub is_hard_decline: Option, + /// Timestamp of the last modification to this token status + pub modified_at: Option, } /// Token retry availability information with detailed wait times @@ -415,11 +417,19 @@ impl RedisTokenManager { let monthly_wait_hours = if total_30_day_retries >= card_network_config.max_retry_count_for_thirty_day { + let mut accumulated_retries = 0; + + // Iterate from most recent to oldest (0..RETRY_WINDOW_DAYS) - .rev() - .map(|i| today - Duration::days(i.into())) - .find(|date| token.daily_retry_history.get(date).copied().unwrap_or(0) > 0) - .map(|date| Self::calculate_wait_hours(date + Duration::days(31), now)) + .map(|days_ago| today - Duration::days(days_ago.into())) + .find(|date| { + let retries = token.daily_retry_history.get(date).copied().unwrap_or(0); + accumulated_retries += retries; + accumulated_retries >= card_network_config.max_retry_count_for_thirty_day + }) + .map(|breach_date| { + Self::calculate_wait_hours(breach_date + Duration::days(31), now) + }) .unwrap_or(0) } else { 0 @@ -463,13 +473,14 @@ impl RedisTokenManager { let was_existing = token_map.contains_key(&token_id); let error_code = token_data.error_code.clone(); + + let modified_at = token_data.modified_at; + let today = OffsetDateTime::now_utc().date(); token_map .get_mut(&token_id) .map(|existing_token| { - error_code.map(|err| existing_token.error_code = Some(err)); - Self::normalize_retry_window(existing_token, today); for (date, &value) in &token_data.daily_retry_history { @@ -479,6 +490,12 @@ impl RedisTokenManager { .and_modify(|v| *v += value) .or_insert(value); } + + (existing_token.modified_at < modified_at).then(|| { + existing_token.modified_at = modified_at; + error_code.map(|err| existing_token.error_code = Some(err)); + existing_token.is_hard_decline = token_data.is_hard_decline; + }); }) .or_else(|| { token_map.insert(token_id.clone(), token_data); @@ -529,6 +546,10 @@ impl RedisTokenManager { daily_retry_history: status.daily_retry_history.clone(), scheduled_at: None, is_hard_decline: *is_hard_decline, + modified_at: Some(PrimitiveDateTime::new( + OffsetDateTime::now_utc().date(), + OffsetDateTime::now_utc().time(), + )), }) } None => None, @@ -606,6 +627,10 @@ impl RedisTokenManager { daily_retry_history: status.daily_retry_history.clone(), scheduled_at: schedule_time, is_hard_decline: status.is_hard_decline, + modified_at: Some(PrimitiveDateTime::new( + OffsetDateTime::now_utc().date(), + OffsetDateTime::now_utc().time(), + )), }); match updated_token { @@ -905,6 +930,11 @@ impl RedisTokenManager { .unwrap_or(Some(existing_scheduled_at)) // No cutoff provided, keep existing value }); + existing_token.modified_at = Some(PrimitiveDateTime::new( + OffsetDateTime::now_utc().date(), + OffsetDateTime::now_utc().time(), + )); + // Save the updated token map back to Redis Self::update_or_add_connector_customer_payment_processor_tokens( state, @@ -920,4 +950,16 @@ impl RedisTokenManager { Ok(()) } + pub async fn get_payment_processor_metadata_for_connector_customer( + state: &SessionState, + customer_id: &str, + ) -> CustomResult, errors::StorageError> + { + let token_map = + Self::get_connector_customer_payment_processor_tokens(state, customer_id).await?; + + let token_data = Self::get_tokens_with_retry_metadata(state, &token_map); + + Ok(token_data) + } } diff --git a/crates/router/src/types/transformers.rs b/crates/router/src/types/transformers.rs index 579dbb3eeb..720b6f5d57 100644 --- a/crates/router/src/types/transformers.rs +++ b/crates/router/src/types/transformers.rs @@ -1199,6 +1199,9 @@ impl ForeignFrom<&api_models::payouts::PayoutMethodData> for api_enums::PaymentM api_models::payouts::PayoutMethodData::BankRedirect(bank_redirect) => { Self::foreign_from(bank_redirect) } + api_models::payouts::PayoutMethodData::Passthrough(passthrough) => { + passthrough.token_type + } } } } @@ -1243,18 +1246,27 @@ impl ForeignFrom<&api_models::payouts::PayoutMethodData> for api_enums::PaymentM api_models::payouts::PayoutMethodData::Card(_) => Self::Card, api_models::payouts::PayoutMethodData::Wallet(_) => Self::Wallet, api_models::payouts::PayoutMethodData::BankRedirect(_) => Self::BankRedirect, + api_models::payouts::PayoutMethodData::Passthrough(passthrough) => { + Self::from(passthrough.token_type) + } } } } #[cfg(feature = "payouts")] -impl ForeignFrom<&api_models::payouts::PayoutMethodData> for api_models::enums::PayoutType { - fn foreign_from(value: &api_models::payouts::PayoutMethodData) -> Self { +impl ForeignTryFrom<&api_models::payouts::PayoutMethodData> for api_models::enums::PayoutType { + type Error = error_stack::Report; + fn foreign_try_from( + value: &api_models::payouts::PayoutMethodData, + ) -> Result { match value { - api_models::payouts::PayoutMethodData::Bank(_) => Self::Bank, - api_models::payouts::PayoutMethodData::Card(_) => Self::Card, - api_models::payouts::PayoutMethodData::Wallet(_) => Self::Wallet, - api_models::payouts::PayoutMethodData::BankRedirect(_) => Self::BankRedirect, + api_models::payouts::PayoutMethodData::Bank(_) => Ok(Self::Bank), + api_models::payouts::PayoutMethodData::Card(_) => Ok(Self::Card), + api_models::payouts::PayoutMethodData::Wallet(_) => Ok(Self::Wallet), + api_models::payouts::PayoutMethodData::BankRedirect(_) => Ok(Self::BankRedirect), + api_models::payouts::PayoutMethodData::Passthrough(passthrough) => { + Self::foreign_try_from(api_enums::PaymentMethod::from(passthrough.token_type)) + } } } } @@ -1280,6 +1292,7 @@ impl ForeignTryFrom for api_models::enums::PayoutType api_enums::PaymentMethod::Card => Ok(Self::Card), api_enums::PaymentMethod::BankTransfer => Ok(Self::Bank), api_enums::PaymentMethod::Wallet => Ok(Self::Wallet), + api_enums::PaymentMethod::BankRedirect => Ok(Self::BankRedirect), _ => Err(errors::ApiErrorResponse::InvalidRequestData { message: format!("PaymentMethod {value:?} is not supported for payouts"), }) diff --git a/crates/router/src/utils.rs b/crates/router/src/utils.rs index 5e1a8c7c77..abc17ce0d3 100644 --- a/crates/router/src/utils.rs +++ b/crates/router/src/utils.rs @@ -17,7 +17,7 @@ use std::fmt::Debug; use api_models::{ enums, payments::{self}, - webhooks, + subscription as subscription_types, webhooks, }; use common_utils::types::keymanager::KeyManagerState; pub use common_utils::{ @@ -42,7 +42,7 @@ use nanoid::nanoid; use serde::de::DeserializeOwned; use serde_json::Value; #[cfg(feature = "v1")] -use subscriptions::subscription_handler::SubscriptionHandler; +use subscriptions::{subscription_handler::SubscriptionHandler, workflows::InvoiceSyncHandler}; use tracing_futures::Instrument; pub use self::ext_traits::{OptionExt, ValidateCall}; @@ -1225,7 +1225,7 @@ where event_type, diesel_models::enums::EventClass::Payments, payment_id.get_string_repr().to_owned(), - diesel_models::enums::EventObjectType::PaymentDetails, + common_enums::EventObjectType::PaymentDetails, webhooks::OutgoingWebhookContent::PaymentDetails(Box::new( payments_response_json, )), @@ -1301,7 +1301,7 @@ pub async fn trigger_refund_outgoing_webhook( outgoing_event_type, diesel_models::enums::EventClass::Refunds, refund_id.to_string(), - diesel_models::enums::EventObjectType::RefundDetails, + common_enums::EventObjectType::RefundDetails, webhooks::OutgoingWebhookContent::RefundDetails(Box::new(refund_response)), primary_object_created_at, )) @@ -1380,7 +1380,7 @@ pub async fn trigger_payouts_webhook( event_type, diesel_models::enums::EventClass::Payouts, cloned_response.payout_id.get_string_repr().to_owned(), - diesel_models::enums::EventObjectType::PayoutDetails, + common_enums::EventObjectType::PayoutDetails, webhooks::OutgoingWebhookContent::PayoutDetails(Box::new(cloned_response)), primary_object_created_at, )) @@ -1403,3 +1403,47 @@ pub async fn trigger_payouts_webhook( ) -> RouterResult<()> { todo!() } + +#[cfg(feature = "v1")] +pub async fn trigger_subscriptions_outgoing_webhook( + state: &SessionState, + payment_response: subscription_types::PaymentResponseData, + invoice: &hyperswitch_domain_models::invoice::Invoice, + subscription: &hyperswitch_domain_models::subscription::Subscription, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + profile: &domain::Profile, +) -> RouterResult<()> { + if invoice.status != common_enums::enums::InvoiceStatus::InvoicePaid { + logger::info!("Invoice not paid, skipping outgoing webhook trigger"); + return Ok(()); + } + let response = InvoiceSyncHandler::generate_response(subscription, invoice, &payment_response) + .attach_printable("Subscriptions: Failed to generate response for outgoing webhook")?; + + let merchant_context = domain::merchant_context::MerchantContext::NormalMerchant(Box::new( + domain::merchant_context::Context(merchant_account.clone(), key_store.clone()), + )); + + let cloned_state = state.clone(); + let cloned_profile = profile.clone(); + let invoice_id = invoice.id.get_string_repr().to_owned(); + let created_at = subscription.created_at; + + tokio::spawn(async move { + Box::pin(webhooks_core::create_event_and_trigger_outgoing_webhook( + cloned_state, + merchant_context, + cloned_profile, + common_enums::enums::EventType::InvoicePaid, + common_enums::enums::EventClass::Subscriptions, + invoice_id, + common_enums::EventObjectType::SubscriptionDetails, + webhooks::OutgoingWebhookContent::SubscriptionDetails(Box::new(response)), + Some(created_at), + )) + .await + }); + + Ok(()) +} diff --git a/crates/router/src/workflows/invoice_sync.rs b/crates/router/src/workflows/invoice_sync.rs index b1fcfe5cb4..7cbd80e545 100644 --- a/crates/router/src/workflows/invoice_sync.rs +++ b/crates/router/src/workflows/invoice_sync.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use common_enums::connector_enums::InvoiceStatus; use common_utils::{errors::CustomResult, ext_traits::ValueExt}; use router_env::logger; use scheduler::{ @@ -6,7 +7,7 @@ use scheduler::{ errors, }; -use crate::{routes::SessionState, types::storage}; +use crate::{routes::SessionState, types::storage, utils}; const INVOICE_SYNC_WORKFLOW: &str = "INVOICE_SYNC"; @@ -29,12 +30,37 @@ impl ProcessTrackerWorkflow for InvoiceSyncWorkflow { let subscription_state = state.clone().into(); match process.name.as_deref() { Some(INVOICE_SYNC_WORKFLOW) => { - Box::pin(subscriptions::workflows::perform_subscription_invoice_sync( - &subscription_state, - process, - tracking_data, - )) - .await + let (handler, payments_response) = + Box::pin(subscriptions::workflows::perform_subscription_invoice_sync( + &subscription_state, + process, + tracking_data, + )) + .await?; + + if handler.invoice.status == InvoiceStatus::InvoicePaid + || handler.invoice.status == InvoiceStatus::PaymentSucceeded + || handler.invoice.status == InvoiceStatus::PaymentFailed + { + let _ = utils::trigger_subscriptions_outgoing_webhook( + state, + payments_response, + &handler.invoice, + &handler.subscription, + &handler.merchant_account, + &handler.key_store, + &handler.profile, + ) + .await + .map_err(|e| { + logger::error!("Failed to trigger subscriptions outgoing webhook: {e:?}"); + errors::ProcessTrackerError::FlowExecutionError { + flow: "Trigger Subscriptions Outgoing Webhook", + } + })?; + } + + Ok(()) } _ => Err(errors::ProcessTrackerError::JobNotFound), } diff --git a/crates/router/src/workflows/outgoing_webhook_retry.rs b/crates/router/src/workflows/outgoing_webhook_retry.rs index d84f0b26dc..17c1062e24 100644 --- a/crates/router/src/workflows/outgoing_webhook_retry.rs +++ b/crates/router/src/workflows/outgoing_webhook_retry.rs @@ -19,6 +19,8 @@ use scheduler::{ types::process_data, utils as scheduler_utils, }; +#[cfg(feature = "v1")] +use subscriptions::workflows::invoice_sync; #[cfg(feature = "payouts")] use crate::core::payouts; @@ -383,6 +385,7 @@ async fn get_outgoing_webhook_content_and_event_type( merchant_account.clone(), key_store.clone(), ))); + match tracking_data.event_class { diesel_models::enums::EventClass::Payments => { let payment_id = tracking_data.primary_object_id.clone(); @@ -573,5 +576,30 @@ async fn get_outgoing_webhook_content_and_event_type( event_type, )) } + diesel_models::enums::EventClass::Subscriptions => { + let invoice_id = tracking_data.primary_object_id.clone(); + let profile_id = &tracking_data.business_profile_id; + + let response = Box::pin( + invoice_sync::InvoiceSyncHandler::form_response_for_retry_outgoing_webhook_task( + state.clone().into(), + &key_store, + invoice_id, + profile_id, + &merchant_account, + ), + ) + .await + .inspect_err(|e| { + logger::error!( + "Failed to generate response for subscription outgoing webhook: {e:?}" + ); + })?; + + Ok(( + OutgoingWebhookContent::SubscriptionDetails(Box::new(response)), + Some(EventType::InvoicePaid), + )) + } } } diff --git a/crates/router/src/workflows/payment_sync.rs b/crates/router/src/workflows/payment_sync.rs index f0bd7e3619..a927e8a869 100644 --- a/crates/router/src/workflows/payment_sync.rs +++ b/crates/router/src/workflows/payment_sync.rs @@ -329,7 +329,7 @@ pub async fn recovery_retry_sync_task( connector_customer_id .async_map(|id| async move { - let _ = update_token_expiry_based_on_schedule_time(state, &id, Some(s_time)) + let _ = update_token_expiry_based_on_schedule_time(state, &id, s_time) .await .map_err(|e| { logger::error!( diff --git a/crates/router/src/workflows/revenue_recovery.rs b/crates/router/src/workflows/revenue_recovery.rs index e87b26e4c1..d11645ed43 100644 --- a/crates/router/src/workflows/revenue_recovery.rs +++ b/crates/router/src/workflows/revenue_recovery.rs @@ -525,7 +525,7 @@ pub fn calculate_difference_in_seconds(scheduled_time: time::PrimitiveDateTime) pub async fn update_token_expiry_based_on_schedule_time( state: &SessionState, connector_customer_id: &str, - delayed_schedule_time: Option, + delayed_schedule_time: time::PrimitiveDateTime, ) -> CustomResult<(), errors::ProcessTrackerError> { let expiry_buffer = state .conf @@ -533,25 +533,40 @@ pub async fn update_token_expiry_based_on_schedule_time( .recovery_timestamp .redis_ttl_buffer_in_seconds; - delayed_schedule_time - .async_map(|t| async move { - let expiry_time = calculate_difference_in_seconds(t) + expiry_buffer; - RedisTokenManager::update_connector_customer_lock_ttl( - state, - connector_customer_id, - expiry_time, - ) - .await - .change_context(errors::ProcessTrackerError::ERedisError( - errors::RedisError::RedisConnectionError.into(), - )) - }) - .await - .transpose()?; + let expiry_time = calculate_difference_in_seconds(delayed_schedule_time) + expiry_buffer; + RedisTokenManager::update_connector_customer_lock_ttl( + state, + connector_customer_id, + expiry_time, + ) + .await + .change_context(errors::ProcessTrackerError::ERedisError( + errors::RedisError::RedisConnectionError.into(), + )); Ok(()) } +#[cfg(feature = "v2")] +#[derive(Debug)] +pub enum PaymentProcessorTokenResponse { + /// Token HardDecline + HardDecline, + + /// Token can be retried at this specific time + ScheduledTime { + scheduled_time: time::PrimitiveDateTime, + }, + + /// Token locked or unavailable, next attempt possible + NextAvailableTime { + next_available_time: time::PrimitiveDateTime, + }, + + /// No retry info available / nothing to do yet + None, +} + #[cfg(feature = "v2")] pub async fn get_token_with_schedule_time_based_on_retry_algorithm_type( state: &SessionState, @@ -559,9 +574,8 @@ pub async fn get_token_with_schedule_time_based_on_retry_algorithm_type( payment_intent: &PaymentIntent, retry_algorithm_type: RevenueRecoveryAlgorithmType, retry_count: i32, -) -> CustomResult, errors::ProcessTrackerError> { - let mut scheduled_time = None; - +) -> CustomResult { + let mut payment_processor_token_response = PaymentProcessorTokenResponse::None; match retry_algorithm_type { RevenueRecoveryAlgorithmType::Monitoring => { logger::error!("Monitoring type found for Revenue Recovery retry payment"); @@ -576,11 +590,67 @@ pub async fn get_token_with_schedule_time_based_on_retry_algorithm_type( .await .ok_or(errors::ProcessTrackerError::EApiErrorResponse)?; - scheduled_time = Some(time); + let payment_processor_token = payment_intent + .feature_metadata + .as_ref() + .and_then(|metadata| metadata.payment_revenue_recovery_metadata.as_ref()) + .map(|recovery_metadata| { + recovery_metadata + .billing_connector_payment_details + .payment_processor_token + .clone() + }); + + let payment_processor_tokens_details = + RedisTokenManager::get_payment_processor_metadata_for_connector_customer( + state, + connector_customer_id, + ) + .await + .change_context(errors::ProcessTrackerError::ERedisError( + errors::RedisError::RedisConnectionError.into(), + ))?; + + // Get the token info from redis + let payment_processor_tokens_details_with_retry_info = payment_processor_token + .as_ref() + .and_then(|t| payment_processor_tokens_details.get(t)); + + // If payment_processor_tokens_details_with_retry_info is None, then no schedule time + match payment_processor_tokens_details_with_retry_info { + None => { + payment_processor_token_response = PaymentProcessorTokenResponse::None; + logger::debug!("No payment processor token found for cascading retry"); + } + Some(payment_token) => { + if payment_token.token_status.is_hard_decline.unwrap_or(false) { + payment_processor_token_response = + PaymentProcessorTokenResponse::HardDecline; + } else if payment_token.retry_wait_time_hours > 0 { + let utc_schedule_time: time::OffsetDateTime = + time::OffsetDateTime::now_utc() + + time::Duration::hours(payment_token.retry_wait_time_hours); + let next_available_time = time::PrimitiveDateTime::new( + utc_schedule_time.date(), + utc_schedule_time.time(), + ); + + payment_processor_token_response = + PaymentProcessorTokenResponse::NextAvailableTime { + next_available_time, + }; + } else { + payment_processor_token_response = + PaymentProcessorTokenResponse::ScheduledTime { + scheduled_time: time, + }; + } + } + } } RevenueRecoveryAlgorithmType::Smart => { - scheduled_time = get_best_psp_token_available_for_smart_retry( + payment_processor_token_response = get_best_psp_token_available_for_smart_retry( state, connector_customer_id, payment_intent, @@ -589,17 +659,40 @@ pub async fn get_token_with_schedule_time_based_on_retry_algorithm_type( .change_context(errors::ProcessTrackerError::EApiErrorResponse)?; } } - let delayed_schedule_time = - scheduled_time.map(|time| add_random_delay_to_schedule_time(state, time)); - let _ = update_token_expiry_based_on_schedule_time( - state, - connector_customer_id, - delayed_schedule_time, - ) - .await; + match &mut payment_processor_token_response { + PaymentProcessorTokenResponse::HardDecline => { + logger::debug!("Token is hard declined"); + } - Ok(delayed_schedule_time) + PaymentProcessorTokenResponse::ScheduledTime { scheduled_time } => { + // Add random delay to schedule time + *scheduled_time = add_random_delay_to_schedule_time(state, *scheduled_time); + + // Log the scheduled retry time at debug level + logger::info!("Retry scheduled at {:?}", scheduled_time); + + // Update token expiry based on schedule time + update_token_expiry_based_on_schedule_time( + state, + connector_customer_id, + *scheduled_time, + ) + .await; + } + + PaymentProcessorTokenResponse::NextAvailableTime { + next_available_time, + } => { + logger::info!("Next available retry at {:?}", next_available_time); + } + + PaymentProcessorTokenResponse::None => { + logger::debug!("No retry info available"); + } + } + + Ok(payment_processor_token_response) } #[cfg(feature = "v2")] @@ -607,7 +700,7 @@ pub async fn get_best_psp_token_available_for_smart_retry( state: &SessionState, connector_customer_id: &str, payment_intent: &PaymentIntent, -) -> CustomResult, errors::ProcessTrackerError> { +) -> CustomResult { // Lock using payment_id let locked = RedisTokenManager::lock_connector_customer_status( state, @@ -619,10 +712,48 @@ pub async fn get_best_psp_token_available_for_smart_retry( errors::RedisError::RedisConnectionError.into(), ))?; - match !locked { - true => Ok(None), - + match locked { false => { + let token_details = + RedisTokenManager::get_payment_processor_metadata_for_connector_customer( + state, + connector_customer_id, + ) + .await + .change_context(errors::ProcessTrackerError::ERedisError( + errors::RedisError::RedisConnectionError.into(), + ))?; + + // Check token with schedule time in Redis + let token_info_with_schedule_time = token_details + .values() + .find(|info| info.token_status.scheduled_at.is_some()); + + // Check for hard decline if info is none + let hard_decline_status = token_details + .values() + .all(|token| token.token_status.is_hard_decline.unwrap_or(false)); + + let mut payment_processor_token_response = PaymentProcessorTokenResponse::None; + + if hard_decline_status { + payment_processor_token_response = PaymentProcessorTokenResponse::HardDecline; + } else { + payment_processor_token_response = match token_info_with_schedule_time + .as_ref() + .and_then(|t| t.token_status.scheduled_at) + { + Some(scheduled_time) => PaymentProcessorTokenResponse::NextAvailableTime { + next_available_time: scheduled_time, + }, + None => PaymentProcessorTokenResponse::None, + }; + } + + Ok(payment_processor_token_response) + } + + true => { // Get existing tokens from Redis let existing_tokens = RedisTokenManager::get_connector_customer_payment_processor_tokens( @@ -634,20 +765,19 @@ pub async fn get_best_psp_token_available_for_smart_retry( errors::RedisError::RedisConnectionError.into(), ))?; - // TODO: Insert into payment_intent_feature_metadata (DB operation) - let result = RedisTokenManager::get_tokens_with_retry_metadata(state, &existing_tokens); - let best_token_time = call_decider_for_payment_processor_tokens_select_closet_time( - state, - &result, - payment_intent, - connector_customer_id, - ) - .await - .change_context(errors::ProcessTrackerError::EApiErrorResponse)?; + let payment_processor_token_response = + call_decider_for_payment_processor_tokens_select_closest_time( + state, + &result, + payment_intent, + connector_customer_id, + ) + .await + .change_context(errors::ProcessTrackerError::EApiErrorResponse)?; - Ok(best_token_time) + Ok(payment_processor_token_response) } } } @@ -709,40 +839,42 @@ async fn process_token_for_retry( #[cfg(feature = "v2")] #[allow(clippy::too_many_arguments)] -pub async fn call_decider_for_payment_processor_tokens_select_closet_time( +pub async fn call_decider_for_payment_processor_tokens_select_closest_time( state: &SessionState, processor_tokens: &HashMap, payment_intent: &PaymentIntent, connector_customer_id: &str, -) -> CustomResult, errors::ProcessTrackerError> { - tracing::debug!("Filtered payment attempts based on payment tokens",); +) -> CustomResult { let mut tokens_with_schedule_time: Vec = Vec::new(); - for token_with_retry_info in processor_tokens.values() { - let token_details = &token_with_retry_info - .token_status - .payment_processor_token_details; - let error_code = token_with_retry_info.token_status.error_code.clone(); + // Check for successful token + let mut token_with_none_error_code = processor_tokens.values().find(|token| { + token.token_status.error_code.is_none() + && !token.token_status.is_hard_decline.unwrap_or(false) + }); - match error_code { - None => { - let utc_schedule_time = - time::OffsetDateTime::now_utc() + time::Duration::minutes(1); + match token_with_none_error_code { + Some(token_with_retry_info) => { + let token_details = &token_with_retry_info + .token_status + .payment_processor_token_details; - let schedule_time = time::PrimitiveDateTime::new( - utc_schedule_time.date(), - utc_schedule_time.time(), - ); - tokens_with_schedule_time = vec![ScheduledToken { - token_details: token_details.clone(), - schedule_time, - }]; - tracing::debug!( - "Found payment processor token with no error code scheduling it for {schedule_time}", - ); - break; - } - Some(_) => { + let utc_schedule_time = time::OffsetDateTime::now_utc() + time::Duration::minutes(1); + let schedule_time = + time::PrimitiveDateTime::new(utc_schedule_time.date(), utc_schedule_time.time()); + + tokens_with_schedule_time = vec![ScheduledToken { + token_details: token_details.clone(), + schedule_time, + }]; + + tracing::debug!( + "Found payment processor token with no error code, scheduling it for {schedule_time}", + ); + } + + None => { + for token_with_retry_info in processor_tokens.values() { process_token_for_retry(state, token_with_retry_info, payment_intent) .await? .map(|token_with_schedule_time| { @@ -757,13 +889,27 @@ pub async fn call_decider_for_payment_processor_tokens_select_closet_time( .min_by_key(|token| token.schedule_time) .cloned(); + let mut payment_processor_token_response; match best_token { None => { + // No tokens available for scheduling, unlock the connector customer status + + // Check if all tokens are hard declined + let hard_decline_status = processor_tokens + .values() + .all(|token| token.token_status.is_hard_decline.unwrap_or(false)); + RedisTokenManager::unlock_connector_customer_status(state, connector_customer_id) .await .change_context(errors::ProcessTrackerError::EApiErrorResponse)?; + tracing::debug!("No payment processor tokens available for scheduling"); - Ok(None) + + if hard_decline_status { + payment_processor_token_response = PaymentProcessorTokenResponse::HardDecline; + } else { + payment_processor_token_response = PaymentProcessorTokenResponse::None; + } } Some(token) => { @@ -778,9 +924,12 @@ pub async fn call_decider_for_payment_processor_tokens_select_closet_time( .await .change_context(errors::ProcessTrackerError::EApiErrorResponse)?; - Ok(Some(token.schedule_time)) + payment_processor_token_response = PaymentProcessorTokenResponse::ScheduledTime { + scheduled_time: token.schedule_time, + }; } } + Ok(payment_processor_token_response) } #[cfg(feature = "v2")] diff --git a/crates/subscriptions/src/core.rs b/crates/subscriptions/src/core.rs index dd2c7df85d..3193dedc68 100644 --- a/crates/subscriptions/src/core.rs +++ b/crates/subscriptions/src/core.rs @@ -1,6 +1,4 @@ -use api_models::subscription::{ - self as subscription_types, SubscriptionResponse, SubscriptionStatus, -}; +use api_models::subscription::{self as subscription_types, SubscriptionResponse}; use common_enums::connector_enums; use common_utils::id_type::GenerateId; use error_stack::ResultExt; @@ -293,7 +291,10 @@ pub async fn create_and_confirm_subscription( .to_string(), ), payment_response.payment_method_id.clone(), - Some(SubscriptionStatus::from(subscription_create_response.status).to_string()), + Some( + common_enums::SubscriptionStatus::from(subscription_create_response.status) + .to_string(), + ), request.plan_id, Some(request.item_price_id), ), @@ -336,14 +337,6 @@ pub async fn confirm_subscription( }; let mut subscription_entry = handler.find_subscription(subscription_id).await?; - let customer = SubscriptionHandler::find_customer( - &state, - &merchant_context, - &subscription_entry.subscription.customer_id, - ) - .await - .attach_printable("subscriptions: failed to find customer")?; - let invoice_handler = subscription_entry.get_invoice_handler(profile.clone()); let invoice = invoice_handler .get_latest_invoice(&state) @@ -368,6 +361,13 @@ pub async fn confirm_subscription( profile.clone(), ) .await?; + let customer = SubscriptionHandler::find_customer( + &state, + &merchant_context, + &subscription_entry.subscription.customer_id, + ) + .await + .attach_printable("subscriptions: failed to find customer")?; let invoice_handler = subscription_entry.get_invoice_handler(profile); let subscription = subscription_entry.subscription.clone(); @@ -376,7 +376,7 @@ pub async fn confirm_subscription( &state, customer.clone(), subscription.customer_id.clone(), - request.get_billing_address(), + payment_response.get_billing_address(), request .payment_details .payment_method_data @@ -399,7 +399,7 @@ pub async fn confirm_subscription( &state, subscription.clone(), subscription.item_price_id.clone(), - request.get_billing_address(), + payment_response.get_billing_address(), ) .await?; @@ -436,7 +436,10 @@ pub async fn confirm_subscription( .to_string(), ), payment_response.payment_method_id.clone(), - Some(SubscriptionStatus::from(subscription_create_response.status).to_string()), + Some( + common_enums::SubscriptionStatus::from(subscription_create_response.status) + .to_string(), + ), subscription.plan_id.clone(), subscription.item_price_id.clone(), ), diff --git a/crates/subscriptions/src/core/subscription_handler.rs b/crates/subscriptions/src/core/subscription_handler.rs index 071fa70397..a00e8d3615 100644 --- a/crates/subscriptions/src/core/subscription_handler.rs +++ b/crates/subscriptions/src/core/subscription_handler.rs @@ -295,7 +295,7 @@ impl SubscriptionWithHandler<'_> { Ok(subscription_types::ConfirmSubscriptionResponse { id: self.subscription.id.clone(), merchant_reference_id: self.subscription.merchant_reference_id.clone(), - status: subscription_types::SubscriptionStatus::from(status), + status: common_enums::SubscriptionStatus::from(status), plan_id: self.subscription.plan_id.clone(), profile_id: self.subscription.profile_id.to_owned(), payment: Some(payment_response.clone()), @@ -315,8 +315,8 @@ impl SubscriptionWithHandler<'_> { Ok(SubscriptionResponse::new( self.subscription.id.clone(), self.subscription.merchant_reference_id.clone(), - subscription_types::SubscriptionStatus::from_str(&self.subscription.status) - .unwrap_or(subscription_types::SubscriptionStatus::Created), + common_enums::SubscriptionStatus::from_str(&self.subscription.status) + .unwrap_or(common_enums::SubscriptionStatus::Created), self.subscription.plan_id.clone(), self.subscription.item_price_id.clone(), self.subscription.profile_id.to_owned(), @@ -455,6 +455,10 @@ impl ForeignTryFrom<&hyperswitch_domain_models::invoice::Invoice> for subscripti currency = invoice.currency ))?, status: invoice.status.clone(), + billing_processor_invoice_id: invoice + .connector_invoice_id + .as_ref() + .map(|id| id.get_string_repr().to_string()), }) } } diff --git a/crates/subscriptions/src/state.rs b/crates/subscriptions/src/state.rs index 363dc78c71..0bfe80857d 100644 --- a/crates/subscriptions/src/state.rs +++ b/crates/subscriptions/src/state.rs @@ -12,6 +12,7 @@ use storage_impl::{errors, kv_router_store::KVRouterStore, DatabaseStore, MockDb pub trait SubscriptionStorageInterface: Send + Sync + + std::any::Any + dyn_clone::DynClone + master_key::MasterKeyInterface + scheduler::SchedulerInterface diff --git a/crates/subscriptions/src/workflows/invoice_sync.rs b/crates/subscriptions/src/workflows/invoice_sync.rs index 082b3b532e..6ed3cb96ca 100644 --- a/crates/subscriptions/src/workflows/invoice_sync.rs +++ b/crates/subscriptions/src/workflows/invoice_sync.rs @@ -1,8 +1,10 @@ +use std::str::FromStr; + #[cfg(feature = "v1")] use api_models::subscription as subscription_types; use common_utils::{errors::CustomResult, ext_traits::StringExt}; use error_stack::ResultExt; -use hyperswitch_domain_models::invoice::InvoiceUpdateRequest; +use hyperswitch_domain_models::{self as domain, invoice::InvoiceUpdateRequest}; use router_env::logger; use scheduler::{ errors, @@ -17,6 +19,7 @@ use crate::{ billing_processor_handler as billing, errors as router_errors, invoice_handler, payments_api_client, }, + helpers::ForeignTryFrom, state::{SubscriptionState as SessionState, SubscriptionStorageInterface as StorageInterface}, types::storage, }; @@ -132,24 +135,21 @@ impl<'a> InvoiceSyncHandler<'a> { } pub async fn perform_payments_sync( - &self, + state: &SessionState, + payment_intent_id: Option<&common_utils::id_type::PaymentId>, + profile_id: &common_utils::id_type::ProfileId, + merchant_id: &common_utils::id_type::MerchantId, ) -> CustomResult { - logger::info!( - "perform_payments_sync called for invoice_id: {:?} and payment_id: {:?}", - self.invoice.id, - self.invoice.payment_intent_id - ); - let payment_id = self.invoice.payment_intent_id.clone().ok_or( - router_errors::ApiErrorResponse::SubscriptionError { + let payment_id = + payment_intent_id.ok_or(router_errors::ApiErrorResponse::SubscriptionError { operation: "Invoice_sync: Missing Payment Intent ID in Invoice".to_string(), - }, - )?; + })?; let payments_response = payments_api_client::PaymentsApiClient::sync_payment( - self.state, + state, payment_id.get_string_repr().to_string(), - self.merchant_account.get_id().get_string_repr(), - self.profile.get_id().get_string_repr(), + merchant_id.get_string_repr(), + profile_id.get_string_repr(), ) .await .change_context(router_errors::ApiErrorResponse::SubscriptionError { @@ -157,17 +157,101 @@ impl<'a> InvoiceSyncHandler<'a> { .to_string(), }) .attach_printable("Failed to sync payment status from payments microservice")?; - Ok(payments_response) } + pub fn generate_response( + subscription: &hyperswitch_domain_models::subscription::Subscription, + invoice: &hyperswitch_domain_models::invoice::Invoice, + payment_response: &subscription_types::PaymentResponseData, + ) -> CustomResult< + subscription_types::ConfirmSubscriptionResponse, + router_errors::ApiErrorResponse, + > { + subscription_types::ConfirmSubscriptionResponse::foreign_try_from(( + subscription, + invoice, + payment_response, + )) + } + + pub async fn form_response_for_retry_outgoing_webhook_task( + state: SessionState, + key_store: &domain::merchant_key_store::MerchantKeyStore, + invoice_id: String, + profile_id: &common_utils::id_type::ProfileId, + merchant_account: &domain::merchant_account::MerchantAccount, + ) -> Result { + let key_manager_state = &(&state).into(); + + let invoice = state + .store + .find_invoice_by_invoice_id(key_manager_state, key_store, invoice_id.clone()) + .await + .map_err(|err| { + logger::error!( + ?err, + "invoices: unable to get latest invoice with id {invoice_id} from database" + ); + errors::ProcessTrackerError::ResourceFetchingFailed { + resource_name: "Invoice".to_string(), + } + })?; + + let subscription = state + .store + .find_by_merchant_id_subscription_id( + key_manager_state, + key_store, + merchant_account.get_id(), + invoice.subscription_id.get_string_repr().to_string(), + ) + .await + .map_err(|err| { + logger::error!( + ?err, + "subscription: unable to get subscription from database" + ); + errors::ProcessTrackerError::ResourceFetchingFailed { + resource_name: "Subscription".to_string(), + } + })?; + + let payments_response = InvoiceSyncHandler::perform_payments_sync( + &state, + invoice.payment_intent_id.as_ref(), + profile_id, + merchant_account.get_id(), + ) + .await + .map_err(|err| { + logger::error!( + ?err, + "subscription: unable to make PSync Call to payments microservice" + ); + errors::ProcessTrackerError::EApiErrorResponse + })?; + + let response = Self::generate_response(&subscription, &invoice, &payments_response) + .map_err(|err| { + logger::error!( + ?err, + "subscription: unable to form ConfirmSubscriptionResponse from foreign types" + ); + errors::ProcessTrackerError::DeserializationFailed + })?; + + Ok(response) + } + pub async fn perform_billing_processor_record_back_if_possible( &self, payment_response: subscription_types::PaymentResponseData, payment_status: common_enums::AttemptStatus, connector_invoice_id: Option, invoice_sync_status: storage::invoice_sync::InvoiceSyncPaymentStatus, - ) -> CustomResult<(), router_errors::ApiErrorResponse> { + ) -> CustomResult + { if let Some(connector_invoice_id) = connector_invoice_id { Box::pin(self.perform_billing_processor_record_back( payment_response, @@ -176,9 +260,10 @@ impl<'a> InvoiceSyncHandler<'a> { invoice_sync_status, )) .await - .attach_printable("Failed to record back to billing processor")?; + .attach_printable("Failed to record back to billing processor") + } else { + Ok(self.invoice.clone()) } - Ok(()) } pub async fn perform_billing_processor_record_back( @@ -187,7 +272,8 @@ impl<'a> InvoiceSyncHandler<'a> { payment_status: common_enums::AttemptStatus, connector_invoice_id: common_utils::id_type::InvoiceId, invoice_sync_status: storage::invoice_sync::InvoiceSyncPaymentStatus, - ) -> CustomResult<(), router_errors::ApiErrorResponse> { + ) -> CustomResult + { logger::info!("perform_billing_processor_record_back"); let billing_handler = billing::BillingHandler::create( @@ -228,9 +314,7 @@ impl<'a> InvoiceSyncHandler<'a> { invoice_handler .update_invoice(self.state, self.invoice.id.to_owned(), update_request) .await - .attach_printable("Failed to update invoice in DB")?; - - Ok(()) + .attach_printable("Failed to update invoice in DB") } pub async fn transition_workflow_state( @@ -238,7 +322,8 @@ impl<'a> InvoiceSyncHandler<'a> { process: ProcessTracker, payment_response: subscription_types::PaymentResponseData, connector_invoice_id: Option, - ) -> CustomResult<(), router_errors::ApiErrorResponse> { + ) -> CustomResult + { logger::info!( "transition_workflow_state called with status: {:?}", payment_response.status @@ -256,7 +341,7 @@ impl<'a> InvoiceSyncHandler<'a> { )?, }; logger::info!("Performing billing processor record back for status: {status}"); - Box::pin(self.perform_billing_processor_record_back_if_possible( + let invoice = Box::pin(self.perform_billing_processor_record_back_if_possible( payment_response.clone(), payment_status, connector_invoice_id, @@ -272,7 +357,8 @@ impl<'a> InvoiceSyncHandler<'a> { .change_context(router_errors::ApiErrorResponse::SubscriptionError { operation: "Invoice_sync process_tracker task completion".to_string(), }) - .attach_printable("Failed to update process tracker status") + .attach_printable("Failed to update process tracker status")?; + Ok(invoice) } } @@ -281,33 +367,49 @@ pub async fn perform_subscription_invoice_sync( state: &SessionState, process: ProcessTracker, tracking_data: storage::invoice_sync::InvoiceSyncTrackingData, -) -> Result<(), errors::ProcessTrackerError> { - let handler = InvoiceSyncHandler::create(state, tracking_data).await?; +) -> Result< + ( + InvoiceSyncHandler<'_>, + subscription_types::PaymentResponseData, + ), + errors::ProcessTrackerError, +> { + let mut handler = InvoiceSyncHandler::create(state, tracking_data).await?; - let payment_status = handler.perform_payments_sync().await?; + let payments_response = InvoiceSyncHandler::perform_payments_sync( + handler.state, + handler.invoice.payment_intent_id.as_ref(), + handler.profile.get_id(), + handler.merchant_account.get_id(), + ) + .await?; - if let Err(e) = Box::pin(handler.transition_workflow_state( + match Box::pin(handler.transition_workflow_state( process.clone(), - payment_status, + payments_response.clone(), handler.tracking_data.connector_invoice_id.clone(), )) .await { - logger::error!(?e, "Error in transitioning workflow state"); - retry_subscription_invoice_sync_task( - &*handler.state.store, - handler.tracking_data.connector_name.to_string().clone(), - handler.merchant_account.get_id().to_owned(), - process, - ) - .await - .change_context(router_errors::ApiErrorResponse::SubscriptionError { - operation: "Invoice_sync process_tracker task retry".to_string(), - }) - .attach_printable("Failed to update process tracker status")?; - }; - - Ok(()) + Err(e) => { + logger::error!(?e, "Error in transitioning workflow state"); + retry_subscription_invoice_sync_task( + &*handler.state.store, + handler.tracking_data.connector_name.to_string().clone(), + handler.merchant_account.get_id().to_owned(), + process, + ) + .await + .change_context(router_errors::ApiErrorResponse::SubscriptionError { + operation: "Invoice_sync process_tracker task retry".to_string(), + }) + .attach_printable("Failed to update process tracker status")?; + } + Ok(invoice) => { + handler.invoice = invoice.clone(); + } + } + Ok((handler, payments_response)) } pub async fn create_invoice_sync_job( @@ -403,3 +505,42 @@ pub async fn retry_subscription_invoice_sync_task( Ok(()) } + +impl + ForeignTryFrom<( + &domain::subscription::Subscription, + &domain::invoice::Invoice, + &subscription_types::PaymentResponseData, + )> for subscription_types::ConfirmSubscriptionResponse +{ + type Error = error_stack::Report; + + fn foreign_try_from( + value: ( + &domain::subscription::Subscription, + &domain::invoice::Invoice, + &subscription_types::PaymentResponseData, + ), + ) -> Result { + let (subscription, invoice, payment_response) = value; + let status = common_enums::SubscriptionStatus::from_str(subscription.status.as_str()) + .map_err(|_| router_errors::ApiErrorResponse::SubscriptionError { + operation: "Failed to parse subscription status".to_string(), + }) + .attach_printable("Failed to parse subscription status")?; + + Ok(Self { + id: subscription.id.clone(), + merchant_reference_id: subscription.merchant_reference_id.clone(), + status, + plan_id: subscription.plan_id.clone(), + profile_id: subscription.profile_id.to_owned(), + payment: Some(payment_response.clone()), + customer_id: Some(subscription.customer_id.clone()), + item_price_id: subscription.item_price_id.clone(), + coupon: None, + billing_processor_subscription_id: subscription.connector_subscription_id.clone(), + invoice: Some(subscription_types::Invoice::foreign_try_from(invoice)?), + }) + } +} diff --git a/docker-compose-development.yml b/docker-compose-development.yml index a26ba9ec21..5bfb2d230a 100644 --- a/docker-compose-development.yml +++ b/docker-compose-development.yml @@ -20,7 +20,7 @@ services: networks: - router_net volumes: - - pg_data:/VAR/LIB/POSTGRESQL/DATA + - pg_data:/var/lib/postgresql environment: - POSTGRES_USER=db_user - POSTGRES_PASSWORD=db_pass diff --git a/docker-compose.yml b/docker-compose.yml index c0bab9a82d..f8dfd7e0f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: networks: - router_net volumes: - - pg_data:/var/lib/postgresql/data + - pg_data:/var/lib/postgresql environment: - POSTGRES_USER=db_user - POSTGRES_PASSWORD=db_pass diff --git a/migrations/2025-10-15-112824_add_invoice_paid_event_type/down.sql b/migrations/2025-10-15-112824_add_invoice_paid_event_type/down.sql new file mode 100644 index 0000000000..d0b0827812 --- /dev/null +++ b/migrations/2025-10-15-112824_add_invoice_paid_event_type/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +SELECT(1); \ No newline at end of file diff --git a/migrations/2025-10-15-112824_add_invoice_paid_event_type/up.sql b/migrations/2025-10-15-112824_add_invoice_paid_event_type/up.sql new file mode 100644 index 0000000000..7853a7d8c2 --- /dev/null +++ b/migrations/2025-10-15-112824_add_invoice_paid_event_type/up.sql @@ -0,0 +1,9 @@ +-- Your SQL goes here +ALTER TYPE "EventType" +ADD VALUE IF NOT EXISTS 'invoice_paid'; + +ALTER TYPE "EventObjectType" +ADD VALUE IF NOT EXISTS 'subscription_details'; + +ALTER TYPE "EventClass" +ADD VALUE IF NOT EXISTS 'subscriptions'; \ No newline at end of file diff --git a/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/down.sql b/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/down.sql new file mode 100644 index 0000000000..aa3c5bd23c --- /dev/null +++ b/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +CREATE INDEX IF NOT EXISTS invoice_subscription_id_connector_invoice_id_index ON invoice (subscription_id, connector_invoice_id); \ No newline at end of file diff --git a/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/up.sql b/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/up.sql new file mode 100644 index 0000000000..74365836aa --- /dev/null +++ b/migrations/2025-10-22-094643_drop_duplicate_index_from_invoice_table/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +DROP INDEX IF EXISTS invoice_subscription_id_connector_invoice_id_index; \ No newline at end of file