diff --git a/Cargo.lock b/Cargo.lock index abb65b07a3f1..8c7c88e61964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,7 @@ dependencies = [ "regex", "reqwest", "ring 0.17.8", + "router_derive", "router_env", "rust_decimal", "rustc-hash", diff --git a/api-reference/openapi_spec.json b/api-reference/openapi_spec.json index 592e5c7deaeb..7c965506d735 100644 --- a/api-reference/openapi_spec.json +++ b/api-reference/openapi_spec.json @@ -4172,6 +4172,57 @@ ] } }, + "/payouts/{payout_id}/confirm": { + "post": { + "tags": [ + "Payouts" + ], + "summary": "Payouts - Confirm", + "description": "Payouts - Confirm", + "operationId": "Confirm a Payout", + "parameters": [ + { + "name": "payout_id", + "in": "path", + "description": "The identifier for payout]", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Payout updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutCreateResponse" + } + } + } + }, + "400": { + "description": "Missing Mandatory fields" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/api_keys/{merchant_id)": { "post": { "tags": [ @@ -6706,6 +6757,46 @@ } } }, + "BusinessCollectLinkConfig": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessGenericLinkConfig" + }, + { + "type": "object", + "required": [ + "enabled_payment_methods" + ], + "properties": { + "enabled_payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnabledPaymentMethod" + }, + "description": "List of payment methods shown on collect UI", + "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\", \"sepa\"]}]" + } + } + } + ] + }, + "BusinessGenericLinkConfig": { + "allOf": [ + { + "$ref": "#/components/schemas/GenericLinkUiConfig" + }, + { + "type": "object", + "properties": { + "domain_name": { + "type": "string", + "description": "Custom domain name to be used for hosting the link", + "nullable": true + } + } + } + ] + }, "BusinessPaymentLinkConfig": { "allOf": [ { @@ -6722,6 +6813,16 @@ } ] }, + "BusinessPayoutLinkConfig": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessGenericLinkConfig" + }, + { + "type": "object" + } + ] + }, "BusinessProfileCreate": { "type": "object", "properties": { @@ -6842,6 +6943,14 @@ "type": "boolean", "description": "Indicates if the MIT (merchant initiated transaction) payments can be made connector\nagnostic, i.e., MITs may be processed through different connector than CIT (customer\ninitiated transaction) based on the routing rules.\nIf set to `false`, MIT will go through the same connector as the CIT.", "nullable": true + }, + "payout_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessPayoutLinkConfig" + } + ], + "nullable": true } }, "additionalProperties": false @@ -6983,6 +7092,14 @@ "type": "boolean", "description": "Indicates if the MIT (merchant initiated transaction) payments can be made connector\nagnostic, i.e., MITs may be processed through different connector than CIT (customer\ninitiated transaction) based on the routing rules.\nIf set to `false`, MIT will go through the same connector as the CIT.", "nullable": true + }, + "payout_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessPayoutLinkConfig" + } + ], + "nullable": true } } }, @@ -8918,6 +9035,26 @@ } } }, + "EnabledPaymentMethod": { + "type": "object", + "description": "Object for EnabledPaymentMethod", + "required": [ + "payment_method", + "payment_method_types" + ], + "properties": { + "payment_method": { + "$ref": "#/components/schemas/PaymentMethod" + }, + "payment_method_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentMethodType" + }, + "description": "An array of associated payment method types" + } + } + }, "EphemeralKeyCreateResponse": { "type": "object", "required": [ @@ -9638,6 +9775,33 @@ "GcashRedirection": { "type": "object" }, + "GenericLinkUiConfig": { + "type": "object", + "description": "Object for GenericLinkUiConfig", + "properties": { + "logo": { + "type": "string", + "description": "Merchant's display logo", + "example": "https://hyperswitch.io/favicon.ico", + "nullable": true, + "maxLength": 255 + }, + "merchant_name": { + "type": "string", + "description": "Custom merchant name for the link", + "example": "Hyperswitch", + "nullable": true, + "maxLength": 255 + }, + "theme": { + "type": "string", + "description": "Primary color to be used in the form represented in hex format", + "example": "#4285F4", + "nullable": true, + "maxLength": 255 + } + } + }, "GiftCardData": { "oneOf": [ { @@ -10905,6 +11069,14 @@ "type": "string", "description": "The id of the organization to which the merchant belongs to", "nullable": true + }, + "pm_collect_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessCollectLinkConfig" + } + ], + "nullable": true } }, "additionalProperties": false @@ -11071,6 +11243,14 @@ }, "recon_status": { "$ref": "#/components/schemas/ReconStatus" + }, + "pm_collect_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessCollectLinkConfig" + } + ], + "nullable": true } } }, @@ -11191,6 +11371,14 @@ "description": "The default business profile that must be used for creating merchant accounts and payments\nTo unset this field, pass an empty string", "nullable": true, "maxLength": 64 + }, + "pm_collect_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/BusinessCollectLinkConfig" + } + ], + "nullable": true } }, "additionalProperties": false @@ -13228,6 +13416,114 @@ "gift_card" ] }, + "PaymentMethodCollectLinkRequest": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/GenericLinkUiConfig" + } + ], + "nullable": true + }, + { + "type": "object", + "required": [ + "customer_id" + ], + "properties": { + "pm_collect_link_id": { + "type": "string", + "description": "The unique identifier for the collect link.", + "example": "pm_collect_link_2bdacf398vwzq5n422S1", + "nullable": true + }, + "customer_id": { + "type": "string", + "description": "The unique identifier of the customer.", + "example": "cus_92dnwed8s32bV9D8Snbiasd8v" + }, + "session_expiry": { + "type": "integer", + "format": "int32", + "description": "Will be used to expire client secret after certain amount of time to be supplied in seconds\n(900) for 15 mins", + "example": 900, + "nullable": true, + "minimum": 0 + }, + "return_url": { + "type": "string", + "description": "Redirect to this URL post completion", + "example": "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1/status", + "nullable": true + }, + "enabled_payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnabledPaymentMethod" + }, + "description": "List of payment methods shown on collect UI", + "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", + "nullable": true + } + } + } + ] + }, + "PaymentMethodCollectLinkResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/GenericLinkUiConfig" + }, + { + "type": "object", + "required": [ + "pm_collect_link_id", + "customer_id", + "expiry", + "link" + ], + "properties": { + "pm_collect_link_id": { + "type": "string", + "description": "The unique identifier for the collect link.", + "example": "pm_collect_link_2bdacf398vwzq5n422S1" + }, + "customer_id": { + "type": "string", + "description": "The unique identifier of the customer.", + "example": "cus_92dnwed8s32bV9D8Snbiasd8v" + }, + "expiry": { + "type": "string", + "format": "date-time", + "description": "Time when this link will be expired in ISO8601 format", + "example": "2025-01-18T11:04:09.922Z" + }, + "link": { + "type": "string", + "description": "URL to the form's link generated for collecting payment method details.", + "example": "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1" + }, + "return_url": { + "type": "string", + "description": "Redirect to this URL post completion", + "example": "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1/status", + "nullable": true + }, + "enabled_payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnabledPaymentMethod" + }, + "description": "List of payment methods shown on collect UI", + "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", + "nullable": true + } + } + } + ] + }, "PaymentMethodCreate": { "type": "object", "required": [ @@ -16488,6 +16784,38 @@ "wise" ] }, + "PayoutCreatePayoutLinkConfig": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/GenericLinkUiConfig" + } + ], + "nullable": true + }, + { + "type": "object", + "properties": { + "payout_link_id": { + "type": "string", + "description": "The unique identifier for the collect link.", + "example": "pm_collect_link_2bdacf398vwzq5n422S1", + "nullable": true + }, + "enabled_payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnabledPaymentMethod" + }, + "description": "List of payout methods shown on collect UI", + "example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]", + "nullable": true + } + } + } + ] + }, "PayoutCreateRequest": { "type": "object", "required": [ @@ -16650,17 +16978,40 @@ }, "payout_token": { "type": "string", - "description": "Provide a reference to a stored payment method", + "description": "Provide a reference to a stored payout method", "example": "187282ab-40ef-47a9-9206-5099ba31e432", "nullable": true }, "profile_id": { "type": "string", - "description": "The business profile to use for this payment, if not passed the default business profile\nassociated with the merchant account will be used.", + "description": "The business profile to use for this payout, if not passed the default business profile\nassociated with the merchant account will be used.", "nullable": true }, "priority": { "$ref": "#/components/schemas/PayoutSendPriority" + }, + "payout_link": { + "type": "boolean", + "description": "Whether to get the payout link (if applicable)", + "default": false, + "example": true, + "nullable": true + }, + "payout_link_config": { + "allOf": [ + { + "$ref": "#/components/schemas/PayoutCreatePayoutLinkConfig" + } + ], + "nullable": true + }, + "session_expiry": { + "type": "integer", + "format": "int32", + "description": "Will be used to expire client secret after certain amount of time to be supplied in seconds\n(900) for 15 mins", + "example": 900, + "nullable": true, + "minimum": 0 } }, "additionalProperties": false @@ -16819,7 +17170,7 @@ }, "profile_id": { "type": "string", - "description": "The business profile that is associated with this payment" + "description": "The business profile that is associated with this payout" }, "created": { "type": "string", @@ -16849,6 +17200,14 @@ }, "description": "List of attempts", "nullable": true + }, + "payout_link": { + "allOf": [ + { + "$ref": "#/components/schemas/PayoutLinkResponse" + } + ], + "nullable": true } }, "additionalProperties": false @@ -16865,6 +17224,36 @@ "Personal" ] }, + "PayoutLinkInitiateRequest": { + "type": "object", + "required": [ + "merchant_id", + "payout_id" + ], + "properties": { + "merchant_id": { + "type": "string" + }, + "payout_id": { + "type": "string" + } + } + }, + "PayoutLinkResponse": { + "type": "object", + "required": [ + "payout_link_id", + "link" + ], + "properties": { + "payout_link_id": { + "type": "string" + }, + "link": { + "type": "string" + } + } + }, "PayoutListConstraints": { "allOf": [ { @@ -17151,7 +17540,7 @@ "fast", "regular", "wire", - "crossBorder", + "cross_border", "internal" ] }, @@ -17167,6 +17556,7 @@ "pending", "ineligible", "requires_creation", + "requires_confirmation", "requires_payout_method_data", "requires_fulfillment", "requires_vendor_account_creation" diff --git a/config/config.example.toml b/config/config.example.toml index 8845aed80cb9..f29876e648b1 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -125,12 +125,12 @@ recon_admin_api_key = "recon_test_admin" # recon_admin API key for recon authent # PCI Compliant storage entity which stores payment method information # like card details [locker] -host = "" # Locker host -host_rs = "" # Rust Locker host -mock_locker = true # Emulate a locker locally using Postgres -locker_signing_key_id = "1" # Key_id to sign basilisk hs locker -locker_enabled = true # Boolean to enable or disable saving cards in locker -ttl_for_storage_in_secs = 220752000 # Time to live for storage entries in locker +host = "" # Locker host +host_rs = "" # Rust Locker host +mock_locker = true # Emulate a locker locally using Postgres +locker_signing_key_id = "1" # Key_id to sign basilisk hs locker +locker_enabled = true # Boolean to enable or disable saving cards in locker +ttl_for_storage_in_secs = 220752000 # Time to live for storage entries in locker [delayed_session_response] connectors_with_delayed_session_response = "trustpay,payme" # List of connectors which has delayed session response @@ -356,9 +356,9 @@ email_role_arn = "" # The amazon resource name ( arn ) of the role which sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session. [user] -password_validity_in_days = 90 # Number of days after which password should be updated -two_factor_auth_expiry_in_secs = 300 # Number of seconds after which 2FA should be done again if doing update/change from inside -totp_issuer_name = "Hyperswitch" # Name of the issuer for TOTP +password_validity_in_days = 90 # Number of days after which password should be updated +two_factor_auth_expiry_in_secs = 300 # Number of seconds after which 2FA should be done again if doing update/change from inside +totp_issuer_name = "Hyperswitch" # Name of the issuer for TOTP #tokenization configuration which describe token lifetime and payment method for specific connector [tokenization] @@ -542,6 +542,31 @@ merchant_cert = "APPLE_PAY_MERCHANT_CERTIFICATE" merchant_cert_key = "APPLE_PAY_MERCHANT_CERTIFICATE_KEY" # Private key generate by RSA:2048 algorithm. Refer Hyperswitch Docs (https://docs.hyperswitch.io/hyperswitch-cloud/payment-methods-setup/wallets/apple-pay/ios-application/) to generate the private key applepay_endpoint = "https://apple-pay-gateway.apple.com/paymentservices/registerMerchant" # Apple pay gateway merchant endpoint +[generic_link] +[generic_link.payment_method_collect] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payment_method_collect.ui_config] +theme = "#1A1A1A" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payment_method_collect.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + +[generic_link.payout_link] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payout_link.ui_config] +theme = "#1A1A1A" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payout_link.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + [payment_link] sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" @@ -645,7 +670,7 @@ enabled = false global_tenant = { schema = "public", redis_key_prefix = "" } [multitenancy.tenants] -public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default"} # schema -> Postgres db schema, redis_key_prefix -> redis key distinguisher, base_url -> url of the tenant +public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "public", redis_key_prefix = "", clickhouse_database = "default" } # schema -> Postgres db schema, redis_key_prefix -> redis key distinguisher, base_url -> url of the tenant [user_auth_methods] encryption_key = "" # Encryption key used for encrypting data in user_authentication_methods table diff --git a/config/deployments/env_specific.toml b/config/deployments/env_specific.toml index ebbae551ea05..652b4950316d 100644 --- a/config/deployments/env_specific.toml +++ b/config/deployments/env_specific.toml @@ -157,6 +157,31 @@ pool_size = 5 # Number of connections to keep open connection_timeout = 10 # Timeout for database connection in seconds queue_strategy = "Fifo" # Add the queue strategy used by the database bb8 client +[generic_link] +[generic_link.payment_method_collect] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payment_method_collect.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payment_method_collect.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + +[generic_link.payout_link] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payout_link.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payout_link.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + [payment_link] sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" diff --git a/config/development.toml b/config/development.toml index 58a95994b8db..4e230cd20252 100644 --- a/config/development.toml +++ b/config/development.toml @@ -565,6 +565,31 @@ apple_pay_ppc_key = "APPLE_PAY_PAYMENT_PROCESSING_CERTIFICATE_KEY" apple_pay_merchant_cert = "APPLE_PAY_MERCHNAT_CERTIFICATE" apple_pay_merchant_cert_key = "APPLE_PAY_MERCHNAT_CERTIFICATE_KEY" +[generic_link] +[generic_link.payment_method_collect] +sdk_url = "http://localhost:9050/HyperLoader.js" +expiry = 900 +[generic_link.payment_method_collect.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payment_method_collect.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + +[generic_link.payout_link] +sdk_url = "http://localhost:9050/HyperLoader.js" +expiry = 900 +[generic_link.payout_link.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payout_link.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + [payment_link] sdk_url = "http://localhost:9050/HyperLoader.js" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index ac0c8392d166..791f2510824f 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -512,3 +512,28 @@ public = { name = "hyperswitch", base_url = "http://localhost:8080", schema = "p [user_auth_methods] encryption_key = "A8EF32E029BC3342E54BF2E172A4D7AA43E8EF9D2C3A624A9F04E2EF79DC698F" + +[generic_link] +[generic_link.payment_method_collect] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payment_method_collect.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payment_method_collect.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] + +[generic_link.payout_link] +sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js" +expiry = 900 +[generic_link.payout_link.ui_config] +theme = "#4285F4" +logo = "https://app.hyperswitch.io/HyperswitchFavicon.png" +merchant_name = "HyperSwitch" +[generic_link.payout_link.enabled_payment_methods] +card = ["credit", "debit"] +bank_transfer = ["ach", "bacs", "sepa"] +wallet = ["paypal", "pix", "venmo"] diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index bd569b1febb5..b11037c2a43b 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use common_utils::{ consts, crypto::{Encryptable, OptionalEncryptableName}, - pii, + link_utils, pii, }; use masking::Secret; use serde::{Deserialize, Serialize}; @@ -95,6 +95,10 @@ pub struct MerchantAccountCreate { /// The id of the organization to which the merchant belongs to pub organization_id: Option, + + /// Default payment method collect link config + #[schema(value_type = Option)] + pub pm_collect_link_config: Option, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] @@ -186,6 +190,10 @@ pub struct MerchantAccountUpdate { /// To unset this field, pass an empty string #[schema(max_length = 64)] pub default_profile: Option, + + /// Default payment method collect link config + #[schema(value_type = Option)] + pub pm_collect_link_config: Option, } #[derive(Clone, Debug, ToSchema, Serialize)] @@ -277,6 +285,10 @@ pub struct MerchantAccountResponse { /// Used to indicate the status of the recon module for a merchant account #[schema(value_type = ReconStatus, example = "not_requested")] pub recon_status: enums::ReconStatus, + + /// Default payment method collect link config + #[schema(value_type = Option)] + pub pm_collect_link_config: Option, } #[derive(Clone, Debug, Deserialize, ToSchema, Serialize)] @@ -944,6 +956,10 @@ pub struct BusinessProfileCreate { /// initiated transaction) based on the routing rules. /// If set to `false`, MIT will go through the same connector as the CIT. pub is_connector_agnostic_mit_enabled: Option, + + /// Default payout link config + #[schema(value_type = Option)] + pub payout_link_config: Option, } #[derive(Clone, Debug, ToSchema, Serialize)] @@ -1028,6 +1044,10 @@ pub struct BusinessProfileResponse { /// initiated transaction) based on the routing rules. /// If set to `false`, MIT will go through the same connector as the CIT. pub is_connector_agnostic_mit_enabled: Option, + + /// Default payout link config + #[schema(value_type = Option)] + pub payout_link_config: Option, } #[derive(Clone, Debug, Deserialize, ToSchema, Serialize)] @@ -1104,6 +1124,35 @@ pub struct BusinessProfileUpdate { /// initiated transaction) based on the routing rules. /// If set to `false`, MIT will go through the same connector as the CIT. pub is_connector_agnostic_mit_enabled: Option, + + /// Default payout link config + #[schema(value_type = Option)] + pub payout_link_config: Option, +} +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] +pub struct BusinessCollectLinkConfig { + #[serde(flatten)] + pub config: BusinessGenericLinkConfig, + + /// List of payment methods shown on collect UI + #[schema(value_type = Vec, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs", "sepa"]}]"#)] + pub enabled_payment_methods: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] +pub struct BusinessPayoutLinkConfig { + #[serde(flatten)] + pub config: BusinessGenericLinkConfig, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] +pub struct BusinessGenericLinkConfig { + /// Custom domain name to be used for hosting the link + pub domain_name: Option, + + #[serde(flatten)] + #[schema(value_type = GenericLinkUiConfig)] + pub ui_config: link_utils::GenericLinkUiConfig, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, ToSchema)] diff --git a/crates/api_models/src/events/payment.rs b/crates/api_models/src/events/payment.rs index cd1671b2b8e0..f639e093e6ef 100644 --- a/crates/api_models/src/events/payment.rs +++ b/crates/api_models/src/events/payment.rs @@ -4,8 +4,9 @@ use crate::{ payment_methods::{ CustomerDefaultPaymentMethodResponse, CustomerPaymentMethodsListResponse, DefaultPaymentMethod, ListCountriesCurrenciesRequest, ListCountriesCurrenciesResponse, - PaymentMethodDeleteResponse, PaymentMethodListRequest, PaymentMethodListResponse, - PaymentMethodResponse, PaymentMethodUpdate, + PaymentMethodCollectLinkRenderRequest, PaymentMethodCollectLinkRequest, + PaymentMethodCollectLinkResponse, PaymentMethodDeleteResponse, PaymentMethodListRequest, + PaymentMethodListResponse, PaymentMethodResponse, PaymentMethodUpdate, }, payments::{ ExtendedCardInfoResponse, PaymentIdType, PaymentListConstraints, @@ -156,6 +157,32 @@ impl ApiEventMetric for CustomerDefaultPaymentMethodResponse { } } +impl ApiEventMetric for PaymentMethodCollectLinkRequest { + fn get_api_event_type(&self) -> Option { + self.pm_collect_link_id + .as_ref() + .map(|id| ApiEventsType::PaymentMethodCollectLink { + link_id: id.clone(), + }) + } +} + +impl ApiEventMetric for PaymentMethodCollectLinkRenderRequest { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::PaymentMethodCollectLink { + link_id: self.pm_collect_link_id.clone(), + }) + } +} + +impl ApiEventMetric for PaymentMethodCollectLinkResponse { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::PaymentMethodCollectLink { + link_id: self.pm_collect_link_id.clone(), + }) + } +} + impl ApiEventMetric for PaymentListFilterConstraints { fn get_api_event_type(&self) -> Option { Some(ApiEventsType::ResourceListAPI) diff --git a/crates/api_models/src/events/payouts.rs b/crates/api_models/src/events/payouts.rs index 9d86a6b38b6f..a08f3ed94a87 100644 --- a/crates/api_models/src/events/payouts.rs +++ b/crates/api_models/src/events/payouts.rs @@ -1,54 +1,71 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use crate::payouts::{ - PayoutActionRequest, PayoutCreateRequest, PayoutCreateResponse, PayoutListConstraints, - PayoutListFilterConstraints, PayoutListFilters, PayoutListResponse, PayoutRetrieveRequest, + PayoutActionRequest, PayoutCreateRequest, PayoutCreateResponse, PayoutLinkInitiateRequest, + PayoutListConstraints, PayoutListFilterConstraints, PayoutListFilters, PayoutListResponse, + PayoutRetrieveRequest, }; impl ApiEventMetric for PayoutRetrieveRequest { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::Payout { + payout_id: self.payout_id.clone(), + }) } } impl ApiEventMetric for PayoutCreateRequest { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + self.payout_id.as_ref().map(|id| ApiEventsType::Payout { + payout_id: id.clone(), + }) } } impl ApiEventMetric for PayoutCreateResponse { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::Payout { + payout_id: self.payout_id.clone(), + }) } } impl ApiEventMetric for PayoutActionRequest { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::Payout { + payout_id: self.payout_id.clone(), + }) } } impl ApiEventMetric for PayoutListConstraints { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::ResourceListAPI) } } impl ApiEventMetric for PayoutListFilterConstraints { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::ResourceListAPI) } } impl ApiEventMetric for PayoutListResponse { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::ResourceListAPI) } } impl ApiEventMetric for PayoutListFilters { fn get_api_event_type(&self) -> Option { - Some(ApiEventsType::Payout) + Some(ApiEventsType::ResourceListAPI) + } +} + +impl ApiEventMetric for PayoutLinkInitiateRequest { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Payout { + payout_id: self.payout_id.clone(), + }) } } diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs index 7d4d3282f30b..1f82508e15fc 100644 --- a/crates/api_models/src/payment_methods.rs +++ b/crates/api_models/src/payment_methods.rs @@ -4,7 +4,7 @@ use cards::CardNumber; use common_utils::{ consts::SURCHARGE_PERCENTAGE_PRECISION_LENGTH, crypto::OptionalEncryptableName, - id_type, pii, + id_type, link_utils, pii, types::{MinorUnit, Percentage, Surcharge}, }; use serde::de; @@ -944,6 +944,104 @@ pub struct CustomerPaymentMethod { pub billing: Option, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct PaymentMethodCollectLinkRequest { + /// The unique identifier for the collect link. + #[schema(value_type = Option, example = "pm_collect_link_2bdacf398vwzq5n422S1")] + pub pm_collect_link_id: Option, + + /// The unique identifier of the customer. + #[schema(value_type = String, example = "cus_92dnwed8s32bV9D8Snbiasd8v")] + pub customer_id: id_type::CustomerId, + + #[serde(flatten)] + #[schema(value_type = Option)] + pub ui_config: Option, + + /// Will be used to expire client secret after certain amount of time to be supplied in seconds + /// (900) for 15 mins + #[schema(value_type = Option, example = 900)] + pub session_expiry: Option, + + /// Redirect to this URL post completion + #[schema(value_type = Option, example = "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1/status")] + pub return_url: Option, + + /// List of payment methods shown on collect UI + #[schema(value_type = Option>, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs"]}]"#)] + pub enabled_payment_methods: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct PaymentMethodCollectLinkResponse { + /// The unique identifier for the collect link. + #[schema(value_type = String, example = "pm_collect_link_2bdacf398vwzq5n422S1")] + pub pm_collect_link_id: String, + + /// The unique identifier of the customer. + #[schema(value_type = String, example = "cus_92dnwed8s32bV9D8Snbiasd8v")] + pub customer_id: id_type::CustomerId, + + /// Time when this link will be expired in ISO8601 format + #[schema(value_type = PrimitiveDateTime, example = "2025-01-18T11:04:09.922Z")] + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: time::PrimitiveDateTime, + + /// URL to the form's link generated for collecting payment method details. + #[schema(value_type = String, example = "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1")] + pub link: masking::Secret, + + /// Redirect to this URL post completion + #[schema(value_type = Option, example = "https://sandbox.hyperswitch.io/payment_method/collect/pm_collect_link_2bdacf398vwzq5n422S1/status")] + pub return_url: Option, + + /// Collect link config used + #[serde(flatten)] + #[schema(value_type = GenericLinkUiConfig)] + pub ui_config: link_utils::GenericLinkUiConfig, + + /// List of payment methods shown on collect UI + #[schema(value_type = Option>, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs"]}]"#)] + pub enabled_payment_methods: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct PaymentMethodCollectLinkRenderRequest { + /// Unique identifier for a merchant. + #[schema(example = "merchant_1671528864")] + pub merchant_id: String, + + /// The unique identifier for the collect link. + #[schema(value_type = String, example = "pm_collect_link_2bdacf398vwzq5n422S1")] + pub pm_collect_link_id: String, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct PaymentMethodCollectLinkDetails { + pub publishable_key: masking::Secret, + pub client_secret: masking::Secret, + pub pm_collect_link_id: String, + pub customer_id: id_type::CustomerId, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub session_expiry: time::PrimitiveDateTime, + pub return_url: Option, + #[serde(flatten)] + pub ui_config: link_utils::GenericLinkUIConfigFormData, + pub enabled_payment_methods: Option>, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct PaymentMethodCollectLinkStatusDetails { + pub pm_collect_link_id: String, + pub customer_id: id_type::CustomerId, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub session_expiry: time::PrimitiveDateTime, + pub return_url: Option, + pub status: link_utils::PaymentMethodCollectStatus, + #[serde(flatten)] + pub ui_config: link_utils::GenericLinkUIConfigFormData, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] pub struct MaskedBankDetails { pub mask: String, diff --git a/crates/api_models/src/payouts.rs b/crates/api_models/src/payouts.rs index 8007083c86e9..eb696ca59c5e 100644 --- a/crates/api_models/src/payouts.rs +++ b/crates/api_models/src/payouts.rs @@ -1,7 +1,7 @@ use cards::CardNumber; use common_utils::{ consts::default_payouts_list_limit, - crypto, id_type, + crypto, id_type, link_utils, pii::{self, Email}, }; use masking::Secret; @@ -141,17 +141,45 @@ pub struct PayoutCreateRequest { #[schema(value_type = Option, example = r#"{ "udf1": "some-value", "udf2": "some-value" }"#)] pub metadata: Option, - /// Provide a reference to a stored payment method + /// Provide a reference to a stored payout method #[schema(example = "187282ab-40ef-47a9-9206-5099ba31e432")] pub payout_token: Option, - /// The business profile to use for this payment, if not passed the default business profile + /// The business profile to use for this payout, if not passed the default business profile /// associated with the merchant account will be used. pub profile_id: Option, /// The send method for processing payouts #[schema(value_type = PayoutSendPriority, example = "instant")] pub priority: Option, + + /// Whether to get the payout link (if applicable) + #[schema(default = false, example = true)] + pub payout_link: Option, + + /// custom payout link config for the particular payout + #[schema(value_type = Option)] + pub payout_link_config: Option, + + /// Will be used to expire client secret after certain amount of time to be supplied in seconds + /// (900) for 15 mins + #[schema(value_type = Option, example = 900)] + pub session_expiry: Option, +} + +#[derive(Default, Debug, Deserialize, Serialize, Clone, ToSchema)] +pub struct PayoutCreatePayoutLinkConfig { + /// The unique identifier for the collect link. + #[schema(value_type = Option, example = "pm_collect_link_2bdacf398vwzq5n422S1")] + pub payout_link_id: Option, + + #[serde(flatten)] + #[schema(value_type = Option)] + pub ui_config: Option, + + /// List of payout methods shown on collect UI + #[schema(value_type = Option>, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs"]}]"#)] + pub enabled_payment_methods: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] @@ -437,7 +465,7 @@ pub struct PayoutCreateResponse { #[schema(value_type = String, example = "E0001")] pub error_code: Option, - /// The business profile that is associated with this payment + /// The business profile that is associated with this payout pub profile_id: String, /// Time when the payout was created @@ -457,6 +485,10 @@ pub struct PayoutCreateResponse { #[schema(value_type = Option>)] #[serde(skip_serializing_if = "Option::is_none")] pub attempts: Option>, + + // If payout link is request, this represents response on + #[schema(value_type = Option)] + pub payout_link: Option, } #[derive( @@ -659,8 +691,53 @@ pub struct PayoutListFilters { pub connector: Vec, /// The list of available currency filters pub currency: Vec, - /// The list of available payment status filters + /// The list of available payout status filters pub status: Vec, - /// The list of available payment method filters + /// The list of available payout method filters pub payout_method: Vec, } + +#[derive(Clone, Debug, serde::Serialize, ToSchema)] +pub struct PayoutLinkResponse { + pub payout_link_id: String, + #[schema(value_type = String)] + pub link: Secret, +} + +#[derive(Clone, Debug, serde::Deserialize, ToSchema, serde::Serialize)] +pub struct PayoutLinkInitiateRequest { + pub merchant_id: String, + pub payout_id: String, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct PayoutLinkDetails { + pub publishable_key: Secret, + pub client_secret: Secret, + pub payout_link_id: String, + pub payout_id: String, + pub customer_id: id_type::CustomerId, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub session_expiry: PrimitiveDateTime, + pub return_url: Option, + #[serde(flatten)] + pub ui_config: link_utils::GenericLinkUIConfigFormData, + pub enabled_payment_methods: Vec, + pub amount: String, + pub currency: common_enums::Currency, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct PayoutLinkStatusDetails { + pub payout_link_id: String, + pub payout_id: String, + pub customer_id: id_type::CustomerId, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub session_expiry: PrimitiveDateTime, + pub return_url: Option, + pub status: api_enums::PayoutStatus, + pub error_code: Option, + pub error_message: Option, + #[serde(flatten)] + pub ui_config: link_utils::GenericLinkUIConfigFormData, +} diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index f6d47f9a6388..c3aa895e1ce4 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2141,6 +2141,7 @@ pub enum PayoutStatus { Ineligible, #[default] RequiresCreation, + RequiresConfirmation, RequiresPayoutMethodData, RequiresFulfillment, RequiresVendorAccountCreation, @@ -2210,17 +2211,17 @@ pub enum PayoutEntityType { Copy, Debug, Eq, - Hash, PartialEq, serde::Deserialize, serde::Serialize, strum::Display, strum::EnumString, ToSchema, + Hash, )] #[router_derive::diesel_enum(storage_type = "text")] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum PayoutSendPriority { Instant, Fast, @@ -2754,6 +2755,31 @@ pub enum BankHolderType { Business, } +#[derive( + Clone, + Copy, + Debug, + Default, + Eq, + Hash, + PartialEq, + serde::Deserialize, + strum::Display, + serde::Serialize, + strum::EnumIter, + strum::EnumString, + strum::VariantNames, + ToSchema, +)] +#[router_derive::diesel_enum(storage_type = "db_enum")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum GenericLinkType { + #[default] + PaymentMethodCollect, + PayoutLink, +} + #[derive(Debug, Clone, PartialEq, Eq, strum::Display, serde::Deserialize, serde::Serialize)] #[strum(serialize_all = "snake_case")] #[serde(rename_all = "snake_case")] diff --git a/crates/common_utils/Cargo.toml b/crates/common_utils/Cargo.toml index 58a881db5905..6bf719da4821 100644 --- a/crates/common_utils/Cargo.toml +++ b/crates/common_utils/Cargo.toml @@ -48,6 +48,7 @@ uuid = { version = "1.8.0", features = ["v7"] } rusty-money = { git = "https://github.com/varunsrin/rusty_money", rev = "bbc0150742a0fff905225ff11ee09388e9babdcc", features = ["iso", "crypto"] } common_enums = { version = "0.1.0", path = "../common_enums" } masking = { version = "0.1.0", path = "../masking" } +router_derive = { version = "0.1.0", path = "../router_derive" } router_env = { version = "0.1.0", path = "../router_env", features = ["log_extra_implicit_fields", "log_custom_entries_to_extra"], optional = true } [target.'cfg(not(target_os = "windows"))'.dependencies] diff --git a/crates/common_utils/src/events.rs b/crates/common_utils/src/events.rs index ef5502d8b5b2..0ef8a6a8bfcf 100644 --- a/crates/common_utils/src/events.rs +++ b/crates/common_utils/src/events.rs @@ -12,7 +12,9 @@ pub trait ApiEventMetric { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(tag = "flow_type", rename_all = "snake_case")] pub enum ApiEventsType { - Payout, + Payout { + payout_id: String, + }, Payment { payment_id: String, }, @@ -57,6 +59,9 @@ pub enum ApiEventsType { Events { merchant_id_or_profile_id: String, }, + PaymentMethodCollectLink { + link_id: String, + }, Poll { poll_id: String, }, diff --git a/crates/common_utils/src/lib.rs b/crates/common_utils/src/lib.rs index ee0a474bcde0..8e305dd3d671 100644 --- a/crates/common_utils/src/lib.rs +++ b/crates/common_utils/src/lib.rs @@ -19,6 +19,7 @@ pub mod events; pub mod ext_traits; pub mod fp_utils; pub mod id_type; +pub mod link_utils; pub mod macros; pub mod pii; #[allow(missing_docs)] // Todo: add docs diff --git a/crates/common_utils/src/link_utils.rs b/crates/common_utils/src/link_utils.rs new file mode 100644 index 000000000000..393a8cb9a9ef --- /dev/null +++ b/crates/common_utils/src/link_utils.rs @@ -0,0 +1,211 @@ +//! Common + +use std::primitive::i64; + +use common_enums::enums; +use diesel::{ + backend::Backend, + deserialize, + deserialize::FromSql, + serialize::{Output, ToSql}, + sql_types::Jsonb, + AsExpression, FromSqlRow, +}; +use error_stack::{report, ResultExt}; +use masking::Secret; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::{errors::ParsingError, id_type, types::MinorUnit}; + +#[derive( + Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, FromSqlRow, AsExpression, ToSchema, +)] +#[serde(rename_all = "snake_case", tag = "type", content = "value")] +#[diesel(sql_type = Jsonb)] +/// Link status enum +pub enum GenericLinkStatus { + /// Status variants for payment method collect link + PaymentMethodCollect(PaymentMethodCollectStatus), + /// Status variants for payout link + PayoutLink(PayoutLinkStatus), +} + +impl Default for GenericLinkStatus { + fn default() -> Self { + Self::PaymentMethodCollect(PaymentMethodCollectStatus::Initiated) + } +} + +crate::impl_to_sql_from_sql_json!(GenericLinkStatus); + +#[derive( + Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, FromSqlRow, AsExpression, ToSchema, +)] +#[serde(rename_all = "snake_case")] +#[diesel(sql_type = Jsonb)] +/// Status variants for payment method collect links +pub enum PaymentMethodCollectStatus { + /// Link was initialized + Initiated, + /// Link was expired or invalidated + Invalidated, + /// Payment method details were submitted + Submitted, +} + +impl FromSql for PaymentMethodCollectStatus +where + serde_json::Value: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result { + let value = >::from_sql(bytes)?; + let generic_status: GenericLinkStatus = serde_json::from_value(value)?; + match generic_status { + GenericLinkStatus::PaymentMethodCollect(status) => Ok(status), + GenericLinkStatus::PayoutLink(_) => Err(report!(ParsingError::EnumParseFailure( + "PaymentMethodCollectStatus" + ))) + .attach_printable("Invalid status for PaymentMethodCollect")?, + } + } +} + +impl ToSql for PaymentMethodCollectStatus +where + serde_json::Value: ToSql, +{ + // This wraps PaymentMethodCollectStatus with GenericLinkStatus + // Required for storing the status in required format in DB (GenericLinkStatus) + // This type is used in PaymentMethodCollectLink (a variant of GenericLink, used in the application for avoiding conversion of data and status) + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result { + let value = serde_json::to_value(GenericLinkStatus::PaymentMethodCollect(self.clone()))?; + + // the function `reborrow` only works in case of `Pg` backend. But, in case of other backends + // please refer to the diesel migration blog: + // https://github.com/Diesel-rs/Diesel/blob/master/guide_drafts/migration_guide.md#changed-tosql-implementations + >::to_sql(&value, &mut out.reborrow()) + } +} + +#[derive( + Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, FromSqlRow, AsExpression, ToSchema, +)] +#[serde(rename_all = "snake_case")] +#[diesel(sql_type = Jsonb)] +/// Status variants for payout links +pub enum PayoutLinkStatus { + /// Link was initialized + Initiated, + /// Link was expired or invalidated + Invalidated, + /// Payout details were submitted + Submitted, +} + +impl FromSql for PayoutLinkStatus +where + serde_json::Value: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result { + let value = >::from_sql(bytes)?; + let generic_status: GenericLinkStatus = serde_json::from_value(value)?; + match generic_status { + GenericLinkStatus::PayoutLink(status) => Ok(status), + GenericLinkStatus::PaymentMethodCollect(_) => { + Err(report!(ParsingError::EnumParseFailure("PayoutLinkStatus"))) + .attach_printable("Invalid status for PayoutLink")? + } + } + } +} + +impl ToSql for PayoutLinkStatus +where + serde_json::Value: ToSql, +{ + // This wraps PayoutLinkStatus with GenericLinkStatus + // Required for storing the status in required format in DB (GenericLinkStatus) + // This type is used in PayoutLink (a variant of GenericLink, used in the application for avoiding conversion of data and status) + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result { + let value = serde_json::to_value(GenericLinkStatus::PayoutLink(self.clone()))?; + + // the function `reborrow` only works in case of `Pg` backend. But, in case of other backends + // please refer to the diesel migration blog: + // https://github.com/Diesel-rs/Diesel/blob/master/guide_drafts/migration_guide.md#changed-tosql-implementations + >::to_sql(&value, &mut out.reborrow()) + } +} + +#[derive(Serialize, serde::Deserialize, Debug, Clone, FromSqlRow, AsExpression, ToSchema)] +#[diesel(sql_type = Jsonb)] +/// Payout link object +pub struct PayoutLinkData { + /// Identifier for the payout link + pub payout_link_id: String, + /// Identifier for the customer + pub customer_id: id_type::CustomerId, + /// Identifier for the payouts resource + pub payout_id: String, + /// Link to render the payout link + pub link: Secret, + /// Client secret generated for authenticating frontend APIs + pub client_secret: Secret, + /// Expiry in seconds from the time it was created + pub session_expiry: u32, + #[serde(flatten)] + /// Payout link's UI configurations + pub ui_config: GenericLinkUiConfig, + /// List of enabled payment methods + pub enabled_payment_methods: Option>, + /// Payout amount + pub amount: MinorUnit, + /// Payout currency + pub currency: enums::Currency, +} + +crate::impl_to_sql_from_sql_json!(PayoutLinkData); + +/// Object for GenericLinkUiConfig +#[derive(Clone, Debug, Default, serde::Deserialize, Serialize, ToSchema)] +pub struct GenericLinkUiConfig { + /// Merchant's display logo + #[schema(value_type = Option, max_length = 255, example = "https://hyperswitch.io/favicon.ico")] + pub logo: Option, + + /// Custom merchant name for the link + #[schema(value_type = Option, max_length = 255, example = "Hyperswitch")] + pub merchant_name: Option>, + + /// Primary color to be used in the form represented in hex format + #[schema(value_type = Option, max_length = 255, example = "#4285F4")] + pub theme: Option, +} + +/// Object for GenericLinkUIConfigFormData +#[derive(Clone, Debug, Default, serde::Deserialize, Serialize, ToSchema)] +pub struct GenericLinkUIConfigFormData { + /// Merchant's display logo + #[schema(value_type = String, max_length = 255, example = "https://hyperswitch.io/favicon.ico")] + pub logo: String, + + /// Custom merchant name for the link + #[schema(value_type = String, max_length = 255, example = "Hyperswitch")] + pub merchant_name: Secret, + + /// Primary color to be used in the form represented in hex format + #[schema(value_type = String, max_length = 255, example = "#4285F4")] + pub theme: String, +} + +/// Object for EnabledPaymentMethod +#[derive(Clone, Debug, Serialize, serde::Deserialize, ToSchema)] +pub struct EnabledPaymentMethod { + /// Payment method (banks, cards, wallets) enabled for the operation + #[schema(value_type = PaymentMethod)] + pub payment_method: enums::PaymentMethod, + + /// An array of associated payment method types + #[schema(value_type = Vec)] + pub payment_method_types: Vec, +} diff --git a/crates/diesel_models/src/business_profile.rs b/crates/diesel_models/src/business_profile.rs index 872bb0c09d6d..b956140a65e5 100644 --- a/crates/diesel_models/src/business_profile.rs +++ b/crates/diesel_models/src/business_profile.rs @@ -35,6 +35,7 @@ pub struct BusinessProfile { pub payment_link_config: Option, pub session_expiry: Option, pub authentication_connector_details: Option, + pub payout_link_config: Option, pub is_extended_card_info_enabled: Option, pub extended_card_info_config: Option, pub is_connector_agnostic_mit_enabled: Option, @@ -66,6 +67,7 @@ pub struct BusinessProfileNew { pub payment_link_config: Option, pub session_expiry: Option, pub authentication_connector_details: Option, + pub payout_link_config: Option, pub is_extended_card_info_enabled: Option, pub extended_card_info_config: Option, pub is_connector_agnostic_mit_enabled: Option, @@ -94,6 +96,7 @@ pub struct BusinessProfileUpdateInternal { pub payment_link_config: Option, pub session_expiry: Option, pub authentication_connector_details: Option, + pub payout_link_config: Option, pub is_extended_card_info_enabled: Option, pub extended_card_info_config: Option, pub is_connector_agnostic_mit_enabled: Option, @@ -121,6 +124,7 @@ pub enum BusinessProfileUpdate { payment_link_config: Option, session_expiry: Option, authentication_connector_details: Option, + payout_link_config: Option, extended_card_info_config: Option, use_billing_as_payment_method_billing: Option, collect_shipping_details_from_wallet_connector: Option, @@ -155,6 +159,7 @@ impl From for BusinessProfileUpdateInternal { payment_link_config, session_expiry, authentication_connector_details, + payout_link_config, extended_card_info_config, use_billing_as_payment_method_billing, collect_shipping_details_from_wallet_connector, @@ -177,6 +182,7 @@ impl From for BusinessProfileUpdateInternal { payment_link_config, session_expiry, authentication_connector_details, + payout_link_config, extended_card_info_config, use_billing_as_payment_method_billing, collect_shipping_details_from_wallet_connector, @@ -222,6 +228,7 @@ impl From for BusinessProfile { payment_link_config: new.payment_link_config, session_expiry: new.session_expiry, authentication_connector_details: new.authentication_connector_details, + payout_link_config: new.payout_link_config, is_connector_agnostic_mit_enabled: new.is_connector_agnostic_mit_enabled, is_extended_card_info_enabled: new.is_extended_card_info_enabled, extended_card_info_config: new.extended_card_info_config, @@ -252,6 +259,7 @@ impl BusinessProfileUpdate { payment_link_config, session_expiry, authentication_connector_details, + payout_link_config, is_extended_card_info_enabled, extended_card_info_config, is_connector_agnostic_mit_enabled, @@ -278,6 +286,7 @@ impl BusinessProfileUpdate { payment_link_config, session_expiry, authentication_connector_details, + payout_link_config, is_extended_card_info_enabled, is_connector_agnostic_mit_enabled, extended_card_info_config, diff --git a/crates/diesel_models/src/enums.rs b/crates/diesel_models/src/enums.rs index d1c5349b1a38..e95116f99a44 100644 --- a/crates/diesel_models/src/enums.rs +++ b/crates/diesel_models/src/enums.rs @@ -9,9 +9,9 @@ pub mod diesel_exports { DbDisputeStatus as DisputeStatus, DbEventClass as EventClass, DbEventObjectType as EventObjectType, DbEventType as EventType, DbFraudCheckStatus as FraudCheckStatus, DbFraudCheckType as FraudCheckType, - DbFutureUsage as FutureUsage, DbIntentStatus as IntentStatus, - DbMandateStatus as MandateStatus, DbMandateType as MandateType, - DbMerchantStorageScheme as MerchantStorageScheme, + DbFutureUsage as FutureUsage, DbGenericLinkType as GenericLinkType, + DbIntentStatus as IntentStatus, DbMandateStatus as MandateStatus, + DbMandateType as MandateType, DbMerchantStorageScheme as MerchantStorageScheme, DbPaymentMethodIssuerCode as PaymentMethodIssuerCode, DbPaymentSource as PaymentSource, DbPaymentType as PaymentType, DbPayoutStatus as PayoutStatus, DbPayoutType as PayoutType, DbProcessTrackerStatus as ProcessTrackerStatus, DbReconStatus as ReconStatus, diff --git a/crates/diesel_models/src/generic_link.rs b/crates/diesel_models/src/generic_link.rs new file mode 100644 index 000000000000..b3e3da7a6d84 --- /dev/null +++ b/crates/diesel_models/src/generic_link.rs @@ -0,0 +1,192 @@ +use common_utils::{ + consts, id_type, + link_utils::{ + EnabledPaymentMethod, GenericLinkStatus, GenericLinkUiConfig, PaymentMethodCollectStatus, + PayoutLinkData, PayoutLinkStatus, + }, +}; +use diesel::{AsChangeset, Identifiable, Insertable, Queryable}; +use masking::Secret; +use serde::{Deserialize, Serialize}; +use time::{Duration, PrimitiveDateTime}; + +use crate::{enums as storage_enums, schema::generic_link}; + +#[derive(Clone, Debug, Eq, PartialEq, Identifiable, Queryable, Serialize, Deserialize)] +#[diesel(table_name = generic_link)] +#[diesel(primary_key(link_id))] +pub struct GenericLink { + pub link_id: String, + pub primary_reference: String, + pub merchant_id: String, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub created_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub last_modified_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: PrimitiveDateTime, + pub link_data: serde_json::Value, + pub link_status: GenericLinkStatus, + pub link_type: storage_enums::GenericLinkType, + pub url: Secret, + pub return_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GenericLinkState { + pub link_id: String, + pub primary_reference: String, + pub merchant_id: String, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub created_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub last_modified_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: PrimitiveDateTime, + pub link_data: GenericLinkData, + pub link_status: GenericLinkStatus, + pub link_type: storage_enums::GenericLinkType, + pub url: Secret, + pub return_url: Option, +} + +#[derive( + Clone, + Debug, + Eq, + PartialEq, + Insertable, + serde::Serialize, + serde::Deserialize, + router_derive::DebugAsDisplay, +)] +#[diesel(table_name = generic_link)] +pub struct GenericLinkNew { + pub link_id: String, + pub primary_reference: String, + pub merchant_id: String, + #[serde(with = "common_utils::custom_serde::iso8601::option")] + pub created_at: Option, + #[serde(with = "common_utils::custom_serde::iso8601::option")] + pub last_modified_at: Option, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: PrimitiveDateTime, + pub link_data: serde_json::Value, + pub link_status: GenericLinkStatus, + pub link_type: storage_enums::GenericLinkType, + pub url: Secret, + pub return_url: Option, +} + +impl Default for GenericLinkNew { + fn default() -> Self { + let now = common_utils::date_time::now(); + + Self { + link_id: String::default(), + primary_reference: String::default(), + merchant_id: String::default(), + created_at: Some(now), + last_modified_at: Some(now), + expiry: now + Duration::seconds(consts::DEFAULT_SESSION_EXPIRY), + link_data: serde_json::Value::default(), + link_status: GenericLinkStatus::default(), + link_type: common_enums::GenericLinkType::default(), + url: Secret::default(), + return_url: Option::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GenericLinkData { + PaymentMethodCollect(PaymentMethodCollectLinkData), + PayoutLink(PayoutLinkData), +} + +impl GenericLinkData { + pub fn get_payment_method_collect_data(&self) -> Result<&PaymentMethodCollectLinkData, String> { + match self { + Self::PaymentMethodCollect(pm) => Ok(pm), + _ => Err("Invalid link type for fetching payment method collect data".to_string()), + } + } + pub fn get_payout_link_data(&self) -> Result<&PayoutLinkData, String> { + match self { + Self::PayoutLink(pl) => Ok(pl), + _ => Err("Invalid link type for fetching payout link data".to_string()), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PaymentMethodCollectLink { + pub link_id: String, + pub primary_reference: String, + pub merchant_id: String, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub created_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub last_modified_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: PrimitiveDateTime, + pub link_data: PaymentMethodCollectLinkData, + pub link_status: PaymentMethodCollectStatus, + pub link_type: storage_enums::GenericLinkType, + pub url: Secret, + pub return_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentMethodCollectLinkData { + pub pm_collect_link_id: String, + pub customer_id: id_type::CustomerId, + pub link: Secret, + pub client_secret: Secret, + pub session_expiry: u32, + #[serde(flatten)] + pub ui_config: GenericLinkUiConfig, + pub enabled_payment_methods: Option>, +} + +#[derive(Clone, Debug, Identifiable, Queryable, Serialize, Deserialize)] +#[diesel(table_name = generic_link)] +#[diesel(primary_key(link_id))] +pub struct PayoutLink { + pub link_id: String, + pub primary_reference: String, + pub merchant_id: String, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub created_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub last_modified_at: PrimitiveDateTime, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expiry: PrimitiveDateTime, + pub link_data: PayoutLinkData, + pub link_status: PayoutLinkStatus, + pub link_type: storage_enums::GenericLinkType, + pub url: Secret, + pub return_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PayoutLinkUpdate { + StatusUpdate { link_status: PayoutLinkStatus }, +} + +#[derive(Clone, Debug, AsChangeset, router_derive::DebugAsDisplay)] +#[diesel(table_name = generic_link)] +pub struct GenericLinkUpdateInternal { + pub link_status: Option, +} + +impl From for GenericLinkUpdateInternal { + fn from(generic_link_update: PayoutLinkUpdate) -> Self { + match generic_link_update { + PayoutLinkUpdate::StatusUpdate { link_status } => Self { + link_status: Some(GenericLinkStatus::PayoutLink(link_status)), + }, + } + } +} diff --git a/crates/diesel_models/src/lib.rs b/crates/diesel_models/src/lib.rs index 36dec7dde5cd..67061bf6c336 100644 --- a/crates/diesel_models/src/lib.rs +++ b/crates/diesel_models/src/lib.rs @@ -20,6 +20,7 @@ pub mod events; pub mod file; #[allow(unused)] pub mod fraud_check; +pub mod generic_link; pub mod gsm; #[cfg(feature = "kv_store")] pub mod kv; @@ -54,9 +55,10 @@ pub type StorageResult = error_stack::Result; pub type PgPooledConn = async_bb8_diesel::Connection; pub use self::{ address::*, api_keys::*, cards_info::*, configs::*, customers::*, dispute::*, ephemeral_key::*, - events::*, file::*, locker_mock_up::*, mandate::*, merchant_account::*, + events::*, file::*, generic_link::*, locker_mock_up::*, mandate::*, merchant_account::*, merchant_connector_account::*, payment_attempt::*, payment_intent::*, payment_method::*, payout_attempt::*, payouts::*, process_tracker::*, refund::*, reverse_lookup::*, + user_authentication_method::*, }; /// The types and implementations provided by this module are required for the schema generated by diff --git a/crates/diesel_models/src/merchant_account.rs b/crates/diesel_models/src/merchant_account.rs index 2bd77d4eb987..f0078ca0ba13 100644 --- a/crates/diesel_models/src/merchant_account.rs +++ b/crates/diesel_models/src/merchant_account.rs @@ -41,6 +41,7 @@ pub struct MerchantAccount { pub default_profile: Option, pub recon_status: storage_enums::ReconStatus, pub payment_link_config: Option, + pub pm_collect_link_config: Option, } #[derive(Clone, Debug, Insertable, router_derive::DebugAsDisplay)] @@ -71,6 +72,7 @@ pub struct MerchantAccountNew { pub default_profile: Option, pub recon_status: storage_enums::ReconStatus, pub payment_link_config: Option, + pub pm_collect_link_config: Option, } #[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)] @@ -100,4 +102,5 @@ pub struct MerchantAccountUpdateInternal { pub default_profile: Option>, pub recon_status: Option, pub payment_link_config: Option, + pub pm_collect_link_config: Option, } diff --git a/crates/diesel_models/src/payouts.rs b/crates/diesel_models/src/payouts.rs index 47c83cc515b0..bf6ce772cb81 100644 --- a/crates/diesel_models/src/payouts.rs +++ b/crates/diesel_models/src/payouts.rs @@ -33,6 +33,8 @@ pub struct Payouts { pub profile_id: String, pub status: storage_enums::PayoutStatus, pub confirm: Option, + pub payout_link_id: Option, + pub client_secret: Option, pub priority: Option, } @@ -72,6 +74,8 @@ pub struct PayoutsNew { pub profile_id: String, pub status: storage_enums::PayoutStatus, pub confirm: Option, + pub payout_link_id: Option, + pub client_secret: Option, pub priority: Option, } @@ -90,6 +94,7 @@ pub enum PayoutsUpdate { profile_id: Option, status: Option, confirm: Option, + payout_type: Option, }, PayoutMethodIdUpdate { payout_method_id: String, @@ -123,6 +128,7 @@ pub struct PayoutsUpdateInternal { pub last_modified_at: PrimitiveDateTime, pub attempt_count: Option, pub confirm: Option, + pub payout_type: Option, } impl Default for PayoutsUpdateInternal { @@ -143,6 +149,7 @@ impl Default for PayoutsUpdateInternal { last_modified_at: common_utils::date_time::now(), attempt_count: None, confirm: None, + payout_type: None, } } } @@ -163,6 +170,7 @@ impl From for PayoutsUpdateInternal { profile_id, status, confirm, + payout_type, } => Self { amount: Some(amount), destination_currency: Some(destination_currency), @@ -176,6 +184,7 @@ impl From for PayoutsUpdateInternal { profile_id, status, confirm, + payout_type, ..Default::default() }, PayoutsUpdate::PayoutMethodIdUpdate { payout_method_id } => Self { @@ -216,6 +225,7 @@ impl PayoutsUpdate { last_modified_at, attempt_count, confirm, + payout_type, } = self.into(); Payouts { amount: amount.unwrap_or(source.amount), @@ -233,6 +243,7 @@ impl PayoutsUpdate { last_modified_at, attempt_count: attempt_count.unwrap_or(source.attempt_count), confirm: confirm.or(source.confirm), + payout_type: payout_type.or(source.payout_type), ..source } } diff --git a/crates/diesel_models/src/query.rs b/crates/diesel_models/src/query.rs index 9fbdf53554f7..c5781cfdc863 100644 --- a/crates/diesel_models/src/query.rs +++ b/crates/diesel_models/src/query.rs @@ -16,6 +16,7 @@ pub mod dispute; pub mod events; pub mod file; pub mod fraud_check; +pub mod generic_link; pub mod generics; pub mod gsm; pub mod locker_mock_up; diff --git a/crates/diesel_models/src/query/generic_link.rs b/crates/diesel_models/src/query/generic_link.rs new file mode 100644 index 000000000000..75f1fc8ded85 --- /dev/null +++ b/crates/diesel_models/src/query/generic_link.rs @@ -0,0 +1,239 @@ +use common_utils::{errors, ext_traits::ValueExt, link_utils::GenericLinkStatus}; +use diesel::{associations::HasTable, ExpressionMethods}; +use error_stack::{report, Report, ResultExt}; + +use super::generics; +use crate::{ + errors as db_errors, + generic_link::{ + GenericLink, GenericLinkData, GenericLinkNew, GenericLinkState, GenericLinkUpdateInternal, + PaymentMethodCollectLink, PayoutLink, PayoutLinkUpdate, + }, + schema::generic_link::dsl, + PgPooledConn, StorageResult, +}; + +impl GenericLinkNew { + pub async fn insert(self, conn: &PgPooledConn) -> StorageResult { + generics::generic_insert(conn, self) + .await + .and_then(|res: GenericLink| { + GenericLinkState::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse generic link data from DB") + }) + } + + pub async fn insert_pm_collect_link( + self, + conn: &PgPooledConn, + ) -> StorageResult { + generics::generic_insert(conn, self) + .await + .and_then(|res: GenericLink| { + PaymentMethodCollectLink::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse payment method collect link data from DB") + }) + } + + pub async fn insert_payout_link(self, conn: &PgPooledConn) -> StorageResult { + generics::generic_insert(conn, self) + .await + .and_then(|res: GenericLink| { + PayoutLink::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse payout link data from DB") + }) + } +} + +impl GenericLink { + pub async fn find_generic_link_by_link_id( + conn: &PgPooledConn, + link_id: &str, + ) -> StorageResult { + generics::generic_find_one::<::Table, _, _>( + conn, + dsl::link_id.eq(link_id.to_owned()), + ) + .await + .and_then(|res: Self| { + GenericLinkState::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse generic link data from DB") + }) + } + + pub async fn find_pm_collect_link_by_link_id( + conn: &PgPooledConn, + link_id: &str, + ) -> StorageResult { + generics::generic_find_one::<::Table, _, _>( + conn, + dsl::link_id.eq(link_id.to_owned()), + ) + .await + .and_then(|res: Self| { + PaymentMethodCollectLink::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse payment method collect link data from DB") + }) + } + + pub async fn find_payout_link_by_link_id( + conn: &PgPooledConn, + link_id: &str, + ) -> StorageResult { + generics::generic_find_one::<::Table, _, _>( + conn, + dsl::link_id.eq(link_id.to_owned()), + ) + .await + .and_then(|res: Self| { + PayoutLink::try_from(res) + .change_context(db_errors::DatabaseError::Others) + .attach_printable("failed to parse payout link data from DB") + }) + } +} + +impl PayoutLink { + pub async fn update_payout_link( + self, + conn: &PgPooledConn, + payout_link_update: PayoutLinkUpdate, + ) -> StorageResult { + generics::generic_update_with_results::<::Table, _, _, _>( + conn, + dsl::link_id.eq(self.link_id.to_owned()), + GenericLinkUpdateInternal::from(payout_link_update), + ) + .await + .and_then(|mut payout_links| { + payout_links + .pop() + .ok_or(error_stack::report!(db_errors::DatabaseError::NotFound)) + }) + .or_else(|error| match error.current_context() { + db_errors::DatabaseError::NoFieldsToUpdate => Ok(self), + _ => Err(error), + }) + } +} + +impl TryFrom for GenericLinkState { + type Error = Report; + fn try_from(db_val: GenericLink) -> Result { + let link_data = match db_val.link_type { + common_enums::GenericLinkType::PaymentMethodCollect => { + let link_data = db_val + .link_data + .parse_value("PaymentMethodCollectLinkData")?; + GenericLinkData::PaymentMethodCollect(link_data) + } + common_enums::GenericLinkType::PayoutLink => { + let link_data = db_val.link_data.parse_value("PayoutLinkData")?; + GenericLinkData::PayoutLink(link_data) + } + }; + + Ok(Self { + link_id: db_val.link_id, + primary_reference: db_val.primary_reference, + merchant_id: db_val.merchant_id, + created_at: db_val.created_at, + last_modified_at: db_val.last_modified_at, + expiry: db_val.expiry, + link_data, + link_status: db_val.link_status, + link_type: db_val.link_type, + url: db_val.url, + return_url: db_val.return_url, + }) + } +} + +impl TryFrom for PaymentMethodCollectLink { + type Error = Report; + fn try_from(db_val: GenericLink) -> Result { + let (link_data, link_status) = match db_val.link_type { + common_enums::GenericLinkType::PaymentMethodCollect => { + let link_data = db_val + .link_data + .parse_value("PaymentMethodCollectLinkData")?; + let link_status = match db_val.link_status { + GenericLinkStatus::PaymentMethodCollect(status) => Ok(status), + _ => Err(report!(errors::ParsingError::EnumParseFailure( + "GenericLinkStatus" + ))) + .attach_printable_lazy(|| { + format!( + "Invalid status for PaymentMethodCollectLink - {:?}", + db_val.link_status + ) + }), + }?; + (link_data, link_status) + } + _ => Err(report!(errors::ParsingError::UnknownError)).attach_printable_lazy(|| { + format!( + "Invalid link_type for PaymentMethodCollectLink - {}", + db_val.link_type + ) + })?, + }; + + Ok(Self { + link_id: db_val.link_id, + primary_reference: db_val.primary_reference, + merchant_id: db_val.merchant_id, + created_at: db_val.created_at, + last_modified_at: db_val.last_modified_at, + expiry: db_val.expiry, + link_data, + link_status, + link_type: db_val.link_type, + url: db_val.url, + return_url: db_val.return_url, + }) + } +} + +impl TryFrom for PayoutLink { + type Error = Report; + fn try_from(db_val: GenericLink) -> Result { + let (link_data, link_status) = match db_val.link_type { + common_enums::GenericLinkType::PayoutLink => { + let link_data = db_val.link_data.parse_value("PayoutLinkData")?; + let link_status = match db_val.link_status { + GenericLinkStatus::PayoutLink(status) => Ok(status), + _ => Err(report!(errors::ParsingError::EnumParseFailure( + "GenericLinkStatus" + ))) + .attach_printable_lazy(|| { + format!("Invalid status for PayoutLink - {:?}", db_val.link_status) + }), + }?; + (link_data, link_status) + } + _ => Err(report!(errors::ParsingError::UnknownError)).attach_printable_lazy(|| { + format!("Invalid link_type for PayoutLink - {}", db_val.link_type) + })?, + }; + + Ok(Self { + link_id: db_val.link_id, + primary_reference: db_val.primary_reference, + merchant_id: db_val.merchant_id, + created_at: db_val.created_at, + last_modified_at: db_val.last_modified_at, + expiry: db_val.expiry, + link_data, + link_status, + link_type: db_val.link_type, + url: db_val.url, + return_url: db_val.return_url, + }) + } +} diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index 649579949a0e..162dac1377e9 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -197,6 +197,7 @@ diesel::table! { payment_link_config -> Nullable, session_expiry -> Nullable, authentication_connector_details -> Nullable, + payout_link_config -> Nullable, is_extended_card_info_enabled -> Nullable, extended_card_info_config -> Nullable, is_connector_agnostic_mit_enabled -> Nullable, @@ -496,6 +497,28 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use crate::enums::diesel_exports::*; + + generic_link (link_id) { + #[max_length = 64] + link_id -> Varchar, + #[max_length = 64] + primary_reference -> Varchar, + #[max_length = 64] + merchant_id -> Varchar, + created_at -> Timestamp, + last_modified_at -> Timestamp, + expiry -> Timestamp, + link_data -> Jsonb, + link_status -> Jsonb, + link_type -> GenericLinkType, + url -> Text, + return_url -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use crate::enums::diesel_exports::*; @@ -644,6 +667,7 @@ diesel::table! { default_profile -> Nullable, recon_status -> ReconStatus, payment_link_config -> Nullable, + pm_collect_link_config -> Nullable, } } @@ -1025,6 +1049,10 @@ diesel::table! { profile_id -> Varchar, status -> PayoutStatus, confirm -> Nullable, + #[max_length = 255] + payout_link_id -> Nullable, + #[max_length = 128] + client_secret -> Nullable, #[max_length = 32] priority -> Nullable, } @@ -1276,6 +1304,7 @@ diesel::allow_tables_to_appear_in_same_query!( file_metadata, fraud_check, gateway_status_map, + generic_link, incremental_authorization, locker_mock_up, mandate, diff --git a/crates/hyperswitch_domain_models/src/payouts/payouts.rs b/crates/hyperswitch_domain_models/src/payouts/payouts.rs index 67ef54fdb2f5..206e9bcdbfc7 100644 --- a/crates/hyperswitch_domain_models/src/payouts/payouts.rs +++ b/crates/hyperswitch_domain_models/src/payouts/payouts.rs @@ -90,6 +90,8 @@ pub struct Payouts { pub profile_id: String, pub status: storage_enums::PayoutStatus, pub confirm: Option, + pub payout_link_id: Option, + pub client_secret: Option, pub priority: Option, } @@ -116,6 +118,8 @@ pub struct PayoutsNew { pub profile_id: String, pub status: storage_enums::PayoutStatus, pub confirm: Option, + pub payout_link_id: Option, + pub client_secret: Option, pub priority: Option, } @@ -145,6 +149,8 @@ impl Default for PayoutsNew { profile_id: String::default(), status: storage_enums::PayoutStatus::default(), confirm: None, + payout_link_id: Option::default(), + client_secret: Option::default(), priority: None, } } @@ -165,6 +171,7 @@ pub enum PayoutsUpdate { profile_id: Option, status: Option, confirm: Option, + payout_type: Option, }, PayoutMethodIdUpdate { payout_method_id: String, @@ -196,6 +203,7 @@ pub struct PayoutsUpdateInternal { pub status: Option, pub attempt_count: Option, pub confirm: Option, + pub payout_type: Option, } impl From for PayoutsUpdateInternal { @@ -214,6 +222,7 @@ impl From for PayoutsUpdateInternal { profile_id, status, confirm, + payout_type, } => Self { amount: Some(amount), destination_currency: Some(destination_currency), @@ -227,6 +236,7 @@ impl From for PayoutsUpdateInternal { profile_id, status, confirm, + payout_type, ..Default::default() }, PayoutsUpdate::PayoutMethodIdUpdate { payout_method_id } => Self { diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 36b31fa28246..01ffc013203e 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -168,6 +168,7 @@ Never share your secret api keys. Keep them guarded and secure. routes::payouts::payouts_fulfill, routes::payouts::payouts_list, routes::payouts::payouts_filter, + routes::payouts::payouts_confirm, // Routes for api keys routes::api_keys::api_key_create, @@ -185,6 +186,8 @@ Never share your secret api keys. Keep them guarded and secure. ), components(schemas( common_utils::types::MinorUnit, + common_utils::link_utils::GenericLinkUiConfig, + common_utils::link_utils::EnabledPaymentMethod, api_models::refunds::RefundRequest, api_models::refunds::RefundType, api_models::refunds::RefundResponse, @@ -197,6 +200,9 @@ Never share your secret api keys. Keep them guarded and secure. api_models::admin::MerchantConnectorResponse, api_models::admin::AuthenticationConnectorDetails, api_models::admin::ExtendedCardInfoConfig, + api_models::admin::BusinessGenericLinkConfig, + api_models::admin::BusinessCollectLinkConfig, + api_models::admin::BusinessPayoutLinkConfig, api_models::customers::CustomerRequest, api_models::customers::CustomerDeleteResponse, api_models::payment_methods::PaymentMethodCreate, @@ -431,6 +437,8 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payment_methods::SurchargeDetailsResponse, api_models::payment_methods::SurchargeResponse, api_models::payment_methods::SurchargePercentage, + api_models::payment_methods::PaymentMethodCollectLinkRequest, + api_models::payment_methods::PaymentMethodCollectLinkResponse, api_models::refunds::RefundListRequest, api_models::refunds::RefundListResponse, api_models::payments::TimeRange, @@ -463,7 +471,9 @@ 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::PayoutLinkResponse, api_models::payouts::Bank, + api_models::payouts::PayoutCreatePayoutLinkConfig, api_models::enums::PayoutEntityType, api_models::enums::PayoutSendPriority, api_models::enums::PayoutStatus, @@ -492,6 +502,7 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payments::PaymentLinkResponse, api_models::payments::RetrievePaymentLinkResponse, api_models::payments::PaymentLinkInitiateRequest, + api_models::payouts::PayoutLinkInitiateRequest, api_models::payments::ExtendedCardInfoResponse, api_models::payments::GooglePayAssuranceDetails, api_models::routing::RoutingConfigRequest, diff --git a/crates/openapi/src/routes/payouts.rs b/crates/openapi/src/routes/payouts.rs index 8a76be1289de..63daa85226a3 100644 --- a/crates/openapi/src/routes/payouts.rs +++ b/crates/openapi/src/routes/payouts.rs @@ -111,3 +111,21 @@ pub async fn payouts_list() {} security(("api_key" = [])) )] pub async fn payouts_filter() {} + +/// Payouts - Confirm +#[utoipa::path( + post, + path = "/payouts/{payout_id}/confirm", + params( + ("payout_id" = String, Path, description = "The identifier for payout]") + ), + request_body=PayoutCreateRequest, + responses( + (status = 200, description = "Payout updated", body = PayoutCreateResponse), + (status = 400, description = "Missing Mandatory fields") + ), + tag = "Payouts", + operation_id = "Confirm a Payout", + security(("api_key" = [])) +)] +pub async fn payouts_confirm() {} diff --git a/crates/router/src/compatibility/stripe/webhooks.rs b/crates/router/src/compatibility/stripe/webhooks.rs index 7bf47ae784ce..e02c55a0e70a 100644 --- a/crates/router/src/compatibility/stripe/webhooks.rs +++ b/crates/router/src/compatibility/stripe/webhooks.rs @@ -158,7 +158,8 @@ impl From for StripePayoutStatus { | common_enums::PayoutStatus::RequiresCreation | common_enums::PayoutStatus::RequiresFulfillment | common_enums::PayoutStatus::RequiresPayoutMethodData - | common_enums::PayoutStatus::RequiresVendorAccountCreation => Self::PayoutProcessing, + | common_enums::PayoutStatus::RequiresVendorAccountCreation + | common_enums::PayoutStatus::RequiresConfirmation => Self::PayoutProcessing, } } } diff --git a/crates/router/src/compatibility/wrap.rs b/crates/router/src/compatibility/wrap.rs index b6012c425a58..7c1bd7e67121 100644 --- a/crates/router/src/compatibility/wrap.rs +++ b/crates/router/src/compatibility/wrap.rs @@ -140,6 +140,17 @@ where .map_into_boxed_body() } + Ok(api::ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => { + let link_type = (boxed_generic_link_data).to_string(); + match services::generic_link_response::build_generic_link_html(*boxed_generic_link_data) + { + Ok(rendered_html) => api::http_response_html_data(rendered_html), + Err(_) => { + api::http_response_err(format!("Error while rendering {} HTML page", link_type)) + } + } + } + Ok(api::ApplicationResponse::PaymentLinkForm(boxed_payment_link_data)) => { match *boxed_payment_link_data { api::PaymentLinkAction::PaymentLinkFormData(payment_link_data) => { diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index 1ad65c363b0a..50324d95064b 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -374,6 +374,7 @@ pub(crate) async fn fetch_raw_secrets( applepay_merchant_configs, lock_settings: conf.lock_settings, temp_locker_enable_config: conf.temp_locker_enable_config, + generic_link: conf.generic_link, payment_link: conf.payment_link, #[cfg(feature = "olap")] analytics, diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 9e345315e98d..807563917419 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -106,6 +106,7 @@ pub struct Settings { pub applepay_merchant_configs: SecretStateContainer, pub lock_settings: LockSettings, pub temp_locker_enable_config: TempLockerEnableConfig, + pub generic_link: GenericLink, pub payment_link: PaymentLink, #[cfg(feature = "olap")] pub analytics: SecretStateContainer, @@ -206,6 +207,27 @@ pub struct KvConfig { pub soft_kill: Option, } +#[derive(Debug, Deserialize, Clone, Default)] +pub struct GenericLink { + pub payment_method_collect: GenericLinkEnvConfig, + pub payout_link: GenericLinkEnvConfig, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct GenericLinkEnvConfig { + pub sdk_url: String, + pub expiry: u32, + pub ui_config: GenericLinkEnvUiConfig, + pub enabled_payment_methods: HashMap>, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct GenericLinkEnvUiConfig { + pub logo: String, + pub merchant_name: Secret, + pub theme: String, +} + #[derive(Debug, Deserialize, Clone, Default)] pub struct PaymentLink { pub sdk_url: String, @@ -654,7 +676,13 @@ impl Settings { .with_list_parse_key("redis.cluster_urls") .with_list_parse_key("events.kafka.brokers") .with_list_parse_key("connectors.supported.wallets") - .with_list_parse_key("connector_request_reference_id_config.merchant_ids_send_payment_id_as_connector_request_id"), + .with_list_parse_key("connector_request_reference_id_config.merchant_ids_send_payment_id_as_connector_request_id") + .with_list_parse_key("generic_link.payment_method_collect.enabled_payment_methods.card") + .with_list_parse_key("generic_link.payment_method_collect.enabled_payment_methods.bank_transfer") + .with_list_parse_key("generic_link.payment_method_collect.enabled_payment_methods.wallet") + .with_list_parse_key("generic_link.payout_link.enabled_payment_methods.card") + .with_list_parse_key("generic_link.payout_link.enabled_payment_methods.bank_transfer") + .with_list_parse_key("generic_link.payout_link.enabled_payment_methods.wallet"), ) .build()?; @@ -719,6 +747,8 @@ impl Settings { self.secrets_management .validate() .map_err(|err| ApplicationError::InvalidConfigurationValueError(err.into()))?; + self.generic_link.payment_method_collect.validate()?; + self.generic_link.payout_link.validate()?; Ok(()) } } diff --git a/crates/router/src/configs/validations.rs b/crates/router/src/configs/validations.rs index f3118cf840e6..168ae3181466 100644 --- a/crates/router/src/configs/validations.rs +++ b/crates/router/src/configs/validations.rs @@ -180,3 +180,21 @@ impl super::settings::LockSettings { }) } } + +impl super::settings::GenericLinkEnvConfig { + pub fn validate(&self) -> Result<(), ApplicationError> { + use common_utils::fp_utils::when; + + when(self.expiry == 0, || { + Err(ApplicationError::InvalidConfigurationValueError( + "link's expiry should not be 0".into(), + )) + })?; + + when(self.sdk_url.is_empty(), || { + Err(ApplicationError::InvalidConfigurationValueError( + "sdk_url to be integrated in the link cannot be empty".into(), + )) + }) + } +} diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 53787fe04ab9..7402d9730fd7 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -27,6 +27,8 @@ pub mod payment_link; pub mod payment_methods; pub mod payments; #[cfg(feature = "payouts")] +pub mod payout_link; +#[cfg(feature = "payouts")] pub mod payouts; pub mod pm_auth; pub mod poll; diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index b9ac5a02b7e0..c08eda6f03c2 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -107,6 +107,17 @@ pub async fn create_merchant_account( .attach_printable("Invalid routing algorithm given")?; } + let pm_collect_link_config = req + .pm_collect_link_config + .as_ref() + .map(|c| { + c.encode_to_value() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "pm_collect_link_config", + }) + }) + .transpose()?; + let key_store = domain::MerchantKeyStore { merchant_id: req.merchant_id.clone(), key: domain_types::encrypt(key.to_vec().into(), master_key) @@ -217,6 +228,7 @@ pub async fn create_merchant_account( default_profile: None, recon_status: diesel_models::enums::ReconStatus::NotRequested, payment_link_config: None, + pm_collect_link_config, }) } .await @@ -440,6 +452,7 @@ pub async fn update_business_profile_cascade( payment_link_config: None, session_expiry: None, authentication_connector_details: None, + payout_link_config: None, extended_card_info_config: None, use_billing_as_payment_method_billing: None, collect_shipping_details_from_wallet_connector: None, @@ -512,6 +525,17 @@ pub async fn merchant_account_update( }) .transpose()?; + let pm_collect_link_config = req + .pm_collect_link_config + .as_ref() + .map(|c| { + c.encode_to_value() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "pm_collect_link_config", + }) + }) + .transpose()?; + // In order to support backwards compatibility, if a business_labels are passed in the update // call, then create new business_profiles with the profile_name as business_label req.primary_business_details @@ -602,6 +626,7 @@ pub async fn merchant_account_update( payout_routing_algorithm: None, default_profile: business_profile_id_update, payment_link_config: None, + pm_collect_link_config, }; let response = db @@ -1686,6 +1711,14 @@ pub async fn update_business_profile( .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "authentication_connector_details", })?, + payout_link_config: request + .payout_link_config + .as_ref() + .map(Encode::encode_to_value) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config", + })?, extended_card_info_config, use_billing_as_payment_method_billing: request.use_billing_as_payment_method_billing, collect_shipping_details_from_wallet_connector: request diff --git a/crates/router/src/core/generic_link/expired_link/index.html b/crates/router/src/core/generic_link/expired_link/index.html new file mode 100644 index 000000000000..49ffc7519b6e --- /dev/null +++ b/crates/router/src/core/generic_link/expired_link/index.html @@ -0,0 +1,11 @@ + + + + + + {{ title }} + + +
{{ message }}
+ + diff --git a/crates/router/src/core/generic_link/payment_method_collect/initiate/index.html b/crates/router/src/core/generic_link/payment_method_collect/initiate/index.html new file mode 100644 index 000000000000..b1d05e7b49d5 --- /dev/null +++ b/crates/router/src/core/generic_link/payment_method_collect/initiate/index.html @@ -0,0 +1,15 @@ + + + + + + Payment Method Collect + + {{ css_style_tag }} + +
+
+
+ {{ js_script_tag }} {{ hyper_sdk_loader_script_tag }} + + diff --git a/crates/router/src/core/generic_link/payment_method_collect/initiate/script.js b/crates/router/src/core/generic_link/payment_method_collect/initiate/script.js new file mode 100644 index 000000000000..8a898ca1f34a --- /dev/null +++ b/crates/router/src/core/generic_link/payment_method_collect/initiate/script.js @@ -0,0 +1,81 @@ +// @ts-check + +var widgets = null; +var paymentMethodCollect = null; +// @ts-ignore +var publishableKey = window.__PM_COLLECT_DETAILS.publishable_key; +var hyper = null; + +/** + * Trigger - init + * Uses + * - Instantiate SDK + */ +function boot() { + // @ts-ignore + var paymentMethodCollectDetails = window.__PM_COLLECT_DETAILS; + + // Initialize SDK + // @ts-ignore + if (window.Hyper) { + initializeCollectSDK(); + } +} +boot(); + +/** + * Trigger - post downloading SDK + * Uses + * - Instantiate SDK + * - Create a payment method collect widget + * - Mount it in DOM + **/ +function initializeCollectSDK() { + // @ts-ignore + var paymentMethodCollectDetails = window.__PM_COLLECT_DETAILS; + var clientSecret = paymentMethodCollectDetails.client_secret; + var appearance = { + variables: { + colorPrimary: + paymentMethodCollectDetails?.theme?.primary_color || "rgb(0, 109, 249)", + fontFamily: "Work Sans, sans-serif", + fontSizeBase: "16px", + colorText: "rgb(51, 65, 85)", + colorTextSecondary: "#334155B3", + colorPrimaryText: "rgb(51, 65, 85)", + colorTextPlaceholder: "#33415550", + borderColor: "#33415550", + colorBackground: "rgb(255, 255, 255)", + }, + }; + // Instantiate + // @ts-ignore + hyper = window.Hyper(publishableKey, { + isPreloadEnabled: false, + }); + widgets = hyper.widgets({ + appearance: appearance, + clientSecret: clientSecret, + }); + + // Create payment method collect widget + var paymentMethodCollectOptions = { + linkId: paymentMethodCollectDetails.pm_collect_link_id, + customerId: paymentMethodCollectDetails.customer_id, + theme: paymentMethodCollectDetails.theme, + collectorName: paymentMethodCollectDetails.merchant_name, + logo: paymentMethodCollectDetails.logo, + enabledPaymentMethods: paymentMethodCollectDetails.enabled_payment_methods, + returnUrl: paymentMethodCollectDetails.return_url, + flow: "PayoutMethodCollect", + }; + paymentMethodCollect = widgets.create( + "paymentMethodCollect", + paymentMethodCollectOptions + ); + + // Mount + if (paymentMethodCollect !== null) { + paymentMethodCollect.mount("#payment-method-collect"); + } +} diff --git a/crates/router/src/core/generic_link/payment_method_collect/initiate/styles.css b/crates/router/src/core/generic_link/payment_method_collect/initiate/styles.css new file mode 100644 index 000000000000..01ca0f20c8bb --- /dev/null +++ b/crates/router/src/core/generic_link/payment_method_collect/initiate/styles.css @@ -0,0 +1,51 @@ +html, +body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + margin: 0; + color: #333333; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + /* IE and Edge */ + -ms-overflow-style: none; + /* Firefox */ + scrollbar-width: none; +} + +.main, #payment-method-collect { + height: 100vh; + width: 100vw; +} + +.main { + overflow: hidden; +} + +#payment-method-collect { + overflow: scroll; +} + +@media only screen and (max-width: 1199px) { + body { + overflow-y: scroll; + } + + .main { + width: auto; + min-width: 300px; + } +} diff --git a/crates/router/src/core/generic_link/payment_method_collect/status/index.html b/crates/router/src/core/generic_link/payment_method_collect/status/index.html new file mode 100644 index 000000000000..801b3d773e20 --- /dev/null +++ b/crates/router/src/core/generic_link/payment_method_collect/status/index.html @@ -0,0 +1,13 @@ + + + + + + Collected! + {{ css_style_tag }} + + +
+ {{ js_script_tag }} + + diff --git a/crates/router/src/core/generic_link/payment_method_collect/status/script.js b/crates/router/src/core/generic_link/payment_method_collect/status/script.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/router/src/core/generic_link/payment_method_collect/status/styles.css b/crates/router/src/core/generic_link/payment_method_collect/status/styles.css new file mode 100644 index 000000000000..160026e0721a --- /dev/null +++ b/crates/router/src/core/generic_link/payment_method_collect/status/styles.css @@ -0,0 +1,47 @@ +html, +body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + margin: 0; + color: #333333; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + /* IE and Edge */ + -ms-overflow-style: none; + /* Firefox */ + scrollbar-width: none; +} + +.main { + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + min-width: 600px; + width: 50vw; +} + +@media only screen and (max-width: 1199px) { + body { + overflow-y: scroll; + } + + .main { + width: auto; + min-width: 300px; + } +} diff --git a/crates/router/src/core/generic_link/payout_link/initiate/index.html b/crates/router/src/core/generic_link/payout_link/initiate/index.html new file mode 100644 index 000000000000..1857e497d3fd --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/initiate/index.html @@ -0,0 +1,15 @@ + + + + + + Payout Links + + {{ css_style_tag }} + +
+ +
+ {{ js_script_tag }} {{ hyper_sdk_loader_script_tag }} + + diff --git a/crates/router/src/core/generic_link/payout_link/initiate/script.js b/crates/router/src/core/generic_link/payout_link/initiate/script.js new file mode 100644 index 000000000000..a8050e90d6eb --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/initiate/script.js @@ -0,0 +1,153 @@ +// @ts-check + +var widgets = null; +var payoutWidget = null; +// @ts-ignore +var publishableKey = window.__PAYOUT_DETAILS.publishable_key; +var hyper = null; + +/** + * Use - format date in "hh:mm AM/PM timezone MM DD, YYYY" + * @param {Date} date + **/ +function formatDate(date) { + var months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + var hours = date.getHours(); + var minutes = date.getMinutes(); + // @ts-ignore + minutes = minutes < 10 ? "0" + minutes : minutes; + var suffix = hours > 11 ? "PM" : "AM"; + hours = hours % 12; + hours = hours ? hours : 12; + var day = date.getDate(); + var month = months[date.getMonth()]; + var year = date.getUTCFullYear(); + + // @ts-ignore + var locale = navigator.language || navigator.userLanguage; + var timezoneShorthand = date + .toLocaleDateString(locale, { + day: "2-digit", + timeZoneName: "long", + }) + .substring(4) + .split(" ") + .reduce(function (tz, c) { + return tz + c.charAt(0).toUpperCase(); + }, ""); + + var formatted = + hours + + ":" + + minutes + + " " + + suffix + + " " + + timezoneShorthand + + " " + + month + + " " + + day + + ", " + + year; + return formatted; +} + +/** + * Trigger - init + * Uses + * - Initialize SDK + * - Update document's icon + */ +function boot() { + // Initialize SDK + // @ts-ignore + if (window.Hyper) { + initializePayoutSDK(); + } + + // @ts-ignore + var payoutDetails = window.__PAYOUT_DETAILS; + + // Attach document icon + if (payoutDetails.logo) { + var link = document.createElement("link"); + link.rel = "icon"; + link.href = payoutDetails.logo; + link.type = "image/x-icon"; + document.head.appendChild(link); + } +} +boot(); + +/** + * Trigger - post downloading SDK + * Uses + * - Initialize SDK + * - Create a payout widget + * - Mount it in DOM + **/ +function initializePayoutSDK() { + // @ts-ignore + var payoutDetails = window.__PAYOUT_DETAILS; + var clientSecret = payoutDetails.client_secret; + var appearance = { + variables: { + colorPrimary: payoutDetails?.theme?.primary_color || "rgb(0, 109, 249)", + fontFamily: "Work Sans, sans-serif", + fontSizeBase: "16px", + colorText: "rgb(51, 65, 85)", + colorTextSecondary: "#334155B3", + colorPrimaryText: "rgb(51, 65, 85)", + colorTextPlaceholder: "#33415550", + borderColor: "#33415550", + colorBackground: "rgb(255, 255, 255)", + }, + }; + // Instantiate + // @ts-ignore + hyper = window.Hyper(publishableKey, { + isPreloadEnabled: false, + }); + widgets = hyper.widgets({ + appearance: appearance, + clientSecret: clientSecret, + }); + + // Create payment method collect widget + let sessionExpiry = formatDate(new Date(payoutDetails.session_expiry)); + var payoutOptions = { + linkId: payoutDetails.payout_link_id, + payoutId: payoutDetails.payout_id, + customerId: payoutDetails.customer_id, + theme: payoutDetails.theme, + collectorName: payoutDetails.merchant_name, + logo: payoutDetails.logo, + enabledPaymentMethods: payoutDetails.enabled_payment_methods, + returnUrl: payoutDetails.return_url, + sessionExpiry, + amount: payoutDetails.amount, + currency: payoutDetails.currency, + flow: "PayoutLinkInitiate", + }; + payoutWidget = widgets.create("paymentMethodCollect", payoutOptions); + + // Mount + if (payoutWidget !== null) { + payoutWidget.mount("#payout-link"); + } +} diff --git a/crates/router/src/core/generic_link/payout_link/initiate/styles.css b/crates/router/src/core/generic_link/payout_link/initiate/styles.css new file mode 100644 index 000000000000..305281cd50a9 --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/initiate/styles.css @@ -0,0 +1,51 @@ +html, +body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + margin: 0; + color: #333333; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + /* IE and Edge */ + -ms-overflow-style: none; + /* Firefox */ + scrollbar-width: none; +} + +.main, #payout-link { + height: 100vh; + width: 100vw; +} + +.main { + overflow: hidden; +} + +#payout-link { + overflow: scroll; +} + +@media only screen and (max-width: 1199px) { + body { + overflow-y: scroll; + } + + .main { + width: auto; + min-width: 300px; + } +} diff --git a/crates/router/src/core/generic_link/payout_link/status/index.html b/crates/router/src/core/generic_link/payout_link/status/index.html new file mode 100644 index 000000000000..f0fb564f64b8 --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/status/index.html @@ -0,0 +1,18 @@ + + + + + + Payout Status + {{ css_style_tag }} + + +
+
+
+
+
+
+ {{ js_script_tag }} + + diff --git a/crates/router/src/core/generic_link/payout_link/status/script.js b/crates/router/src/core/generic_link/payout_link/status/script.js new file mode 100644 index 000000000000..ba19f6bff9e6 --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/status/script.js @@ -0,0 +1,181 @@ +// @ts-check +/** + * Trigger - init + * Uses + * - Update document's icon + */ +function boot() { + // @ts-ignore + var payoutDetails = window.__PAYOUT_DETAILS; + + // Attach document icon + if (typeof payoutDetails.logo === "string") { + var link = document.createElement("link"); + link.rel = "icon"; + link.href = payoutDetails.logo; + link.type = "image/x-icon"; + document.head.appendChild(link); + } + // Render status details + renderStatusDetails(payoutDetails); + // Redirect + if (typeof payoutDetails.return_url === "string") { + // Form query params + var queryParams = { + payout_id: payoutDetails.payout_id, + status: payoutDetails.status, + }; + var url = new URL(payoutDetails.return_url); + var params = new URLSearchParams(url.search); + // Attach query params to return_url + for (var key in queryParams) { + if (queryParams.hasOwnProperty(key)) { + params.set(key, queryParams[key]); + } + } + url.search = params.toString(); + redirectToEndUrl(url); + } +} +boot(); + +/** + * Trigger - on boot + * Uses + * - Render merchant details + * - Render status + */ +function renderStatusDetails(payoutDetails) { + var statusCardNode = document.getElementById("status-card"); + var merchantHeaderNode = document.getElementById("merchant-header"); + + if ( + typeof payoutDetails.merchant_name === "string" && + merchantHeaderNode instanceof HTMLDivElement + ) { + var merchantNameNode = document.createElement("div"); + merchantNameNode.innerText = payoutDetails.merchant_name; + merchantHeaderNode.appendChild(merchantNameNode); + } + if ( + typeof payoutDetails.logo === "string" && + merchantHeaderNode instanceof HTMLDivElement + ) { + var merchantLogoNode = document.createElement("img"); + merchantLogoNode.src = payoutDetails.logo; + merchantHeaderNode.appendChild(merchantLogoNode); + } + var status = payoutDetails.status; + var statusInfo = { + statusImageSrc: + "https://live.hyperswitch.io/payment-link-assets/success.png", + statusText: "Payout Successful", + statusMessage: "Your payout was made to selected payment method.", + }; + switch (status) { + case "success": + break; + case "initiated": + case "pending": + statusInfo.statusImageSrc = + "https://live.hyperswitch.io/payment-link-assets/pending.png"; + statusInfo.statusText = "Payout Processing"; + statusInfo.statusMessage = + "Your payout should be processed within 2-3 business days."; + break; + case "failed": + case "cancelled": + case "expired": + case "reversed": + case "ineligible": + case "requires_creation": + case "requires_confirmation": + case "requires_payout_method_data": + case "requires_fulfillment": + case "requires_vendor_account_creation": + default: + statusInfo.statusImageSrc = + "https://live.hyperswitch.io/payment-link-assets/failed.png"; + statusInfo.statusText = "Payout Failed"; + statusInfo.statusMessage = + "Failed to process your payout. Please check with your provider for more details."; + break; + } + + var statusImageNode = document.createElement("img"); + statusImageNode.src = statusInfo.statusImageSrc; + statusImageNode.id = "status-image"; + var statusTextNode = document.createElement("div"); + statusTextNode.innerText = statusInfo.statusText; + statusTextNode.id = "status-text"; + var statusMsgNode = document.createElement("div"); + statusMsgNode.innerText = statusInfo.statusMessage; + statusMsgNode.id = "status-message"; + + // Append status info + if (statusCardNode instanceof HTMLDivElement) { + statusCardNode.appendChild(statusImageNode); + statusCardNode.appendChild(statusTextNode); + statusCardNode.appendChild(statusMsgNode); + } + + var resourceInfo = { + "Ref Id": payoutDetails.payout_id, + }; + if (typeof payoutDetails.error_code === "string") { + resourceInfo["Error Code"] = payoutDetails.error_code; + } + if (typeof payoutDetails.error_message === "string") { + resourceInfo["Error Message"] = payoutDetails.error_message; + } + var resourceNode = document.createElement("div"); + resourceNode.id = "resource-info-container"; + for (var key in resourceInfo) { + var infoNode = document.createElement("div"); + infoNode.id = "resource-info"; + var infoKeyNode = document.createElement("div"); + infoKeyNode.innerText = key; + infoKeyNode.id = "info-key"; + var infoValNode = document.createElement("div"); + infoValNode.innerText = resourceInfo[key]; + infoValNode.id = "info-val"; + infoNode.appendChild(infoKeyNode); + infoNode.appendChild(infoValNode); + resourceNode.appendChild(infoNode); + } + + // Append resource info + if (statusCardNode instanceof HTMLDivElement) { + statusCardNode.appendChild(resourceNode); + } +} + +/** + * Trigger - if return_url was specified during payout link creation + * Uses + * - Redirect to end url + * @param {URL} returnUrl + */ +function redirectToEndUrl(returnUrl) { + // Form redirect text + var statusRedirectTextNode = document.getElementById("redirect-text"); + var timeout = 5, + j = 0; + for (var i = 0; i <= timeout; i++) { + setTimeout(function () { + var secondsLeft = timeout - j++; + var innerText = + secondsLeft === 0 + ? "Redirecting ..." + : "Redirecting in " + secondsLeft + " seconds ..."; + if (statusRedirectTextNode instanceof HTMLDivElement) { + statusRedirectTextNode.innerText = innerText; + } + if (secondsLeft === 0) { + setTimeout(function () { + window.location.href = returnUrl.toString(); + }, 1000); + } + }, i * 1000); + } +} diff --git a/crates/router/src/core/generic_link/payout_link/status/styles.css b/crates/router/src/core/generic_link/payout_link/status/styles.css new file mode 100644 index 000000000000..cf2d89b6a30b --- /dev/null +++ b/crates/router/src/core/generic_link/payout_link/status/styles.css @@ -0,0 +1,113 @@ +html, +body { + height: 100%; + overflow: hidden; +} + +body { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + margin: 0; + color: #333333; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + /* IE and Edge */ + -ms-overflow-style: none; + /* Firefox */ + scrollbar-width: none; +} + +.main { + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + width: 500px; +} + +#status-card { + max-width: 500px; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + box-shadow: 1px 1px 10px 1px rgb(231, 234, 241); + border-radius: 0.5rem; +} + +#merchant-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgb(231, 234, 241); + padding: 20px 40px; + width: calc(100% - 80px); + font-weight: 600; + font-size: 25px; +} +#merchant-header > img { + height: 30px; +} + +#status-image { + margin-top: 30px; + height: 160px; +} + +#status-text { + margin-top: 10px; + font-size: 20px; + font-weight: 600; +} + +#status-message { + text-align: center; + margin: 0 40px 40px 40px; + color: rgb(103, 112, 125); +} + +#resource-info-container { + width: calc(100% - 80px); + border-top: 1px solid rgb(231, 234, 241); + padding: 20px 40px; +} +#resource-info { + display: flex; + align-items: center; +} +#info-key { + text-align: right; + font-size: 15px; + min-width: 10ch; +} +#info-val { + margin-left: 10px; + padding-left: 10px; + border-left: 1px solid rgba(103, 112, 125, 0.2); + font-size: 13px; +} +#redirect-text { + margin-top: 40px; +} + +@media only screen and (max-width: 1199px) { + body { + overflow-y: scroll; + } + + .main { + width: auto; + min-width: 300px; + } +} diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index a4d4f38bfaf0..faa28ffdbc85 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -2,16 +2,20 @@ pub mod cards; pub mod surcharge_decision_configs; pub mod transformers; pub mod vault; - pub use api_models::enums::Connector; -use api_models::payments::CardToken; #[cfg(feature = "payouts")] pub use api_models::{enums::PayoutConnectors, payouts as payout_types}; -use diesel_models::enums; -use error_stack::ResultExt; +use api_models::{payment_methods, payments::CardToken}; +use common_utils::{ext_traits::Encode, id_type::CustomerId}; +use diesel_models::{ + enums, GenericLinkNew, PaymentMethodCollectLink, PaymentMethodCollectLinkData, +}; +use error_stack::{report, ResultExt}; use hyperswitch_domain_models::payments::{payment_attempt::PaymentAttempt, PaymentIntent}; use router_env::{instrument, tracing}; +use time::Duration; +use super::errors::{RouterResponse, StorageErrorExt}; use crate::{ consts, core::{ @@ -19,13 +23,14 @@ use crate::{ payments::helpers, pm_auth as core_pm_auth, }, - db, - routes::SessionState, + routes::{app::StorageInterface, SessionState}, + services::{self, GenericLinks}, types::{ api::{self, payments}, domain, storage, }, }; +mod validator; const PAYMENT_METHOD_STATUS_UPDATE_TASK: &str = "PAYMENT_METHOD_STATUS_UPDATE"; const PAYMENT_METHOD_STATUS_TAG: &str = "PAYMENT_METHOD_STATUS"; @@ -104,6 +109,230 @@ pub async fn retrieve_payment_method( } } +pub async fn initiate_pm_collect_link( + state: SessionState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + req: payment_methods::PaymentMethodCollectLinkRequest, +) -> RouterResponse { + // Validate request and initiate flow + let pm_collect_link_data = + validator::validate_request_and_initiate_payment_method_collect_link( + &state, + &merchant_account, + &key_store, + &req, + ) + .await?; + + // Create DB entries + let pm_collect_link = create_pm_collect_db_entry( + &state, + &merchant_account, + &pm_collect_link_data, + req.return_url.clone(), + ) + .await?; + let customer_id = CustomerId::from(pm_collect_link.primary_reference.into()).change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "customer_id", + }, + )?; + + // Return response + let response = payment_methods::PaymentMethodCollectLinkResponse { + pm_collect_link_id: pm_collect_link.link_id, + customer_id, + expiry: pm_collect_link.expiry, + link: pm_collect_link.url, + return_url: pm_collect_link.return_url, + ui_config: pm_collect_link.link_data.ui_config, + enabled_payment_methods: pm_collect_link.link_data.enabled_payment_methods, + }; + Ok(services::ApplicationResponse::Json(response)) +} + +pub async fn create_pm_collect_db_entry( + state: &SessionState, + merchant_account: &domain::MerchantAccount, + pm_collect_link_data: &PaymentMethodCollectLinkData, + return_url: Option, +) -> RouterResult { + let db: &dyn StorageInterface = &*state.store; + + let link_data = serde_json::to_value(pm_collect_link_data) + .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError)) + .attach_printable("Failed to convert PaymentMethodCollectLinkData to Value")?; + + let pm_collect_link = GenericLinkNew { + link_id: pm_collect_link_data.pm_collect_link_id.to_string(), + primary_reference: pm_collect_link_data + .customer_id + .get_string_repr() + .to_string(), + merchant_id: merchant_account.merchant_id.to_string(), + link_type: common_enums::GenericLinkType::PaymentMethodCollect, + link_data, + url: pm_collect_link_data.link.clone(), + return_url, + expiry: common_utils::date_time::now() + + Duration::seconds(pm_collect_link_data.session_expiry.into()), + ..Default::default() + }; + + db.insert_pm_collect_link(pm_collect_link) + .await + .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError { + message: "payment method collect link already exists".to_string(), + }) +} + +pub async fn render_pm_collect_link( + state: SessionState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + req: payment_methods::PaymentMethodCollectLinkRenderRequest, +) -> RouterResponse { + let db: &dyn StorageInterface = &*state.store; + + // Fetch pm collect link + let pm_collect_link = db + .find_pm_collect_link_by_link_id(&req.pm_collect_link_id) + .await + .to_not_found_response(errors::ApiErrorResponse::GenericNotFoundError { + message: "payment method collect link not found".to_string(), + })?; + + // Check status and return form data accordingly + let has_expired = common_utils::date_time::now() > pm_collect_link.expiry; + let status = pm_collect_link.link_status; + let link_data = pm_collect_link.link_data; + let default_config = &state.conf.generic_link.payment_method_collect; + let default_ui_config = default_config.ui_config.clone(); + let ui_config_data = common_utils::link_utils::GenericLinkUIConfigFormData { + merchant_name: link_data + .ui_config + .merchant_name + .unwrap_or(default_ui_config.merchant_name), + logo: link_data.ui_config.logo.unwrap_or(default_ui_config.logo), + theme: link_data + .ui_config + .theme + .clone() + .unwrap_or(default_ui_config.theme.clone()), + }; + match status { + common_utils::link_utils::PaymentMethodCollectStatus::Initiated => { + // if expired, send back expired status page + if has_expired { + let expired_link_data = services::GenericExpiredLinkData { + title: "Payment collect link has expired".to_string(), + message: "This payment collect link has expired.".to_string(), + theme: link_data.ui_config.theme.unwrap_or(default_ui_config.theme), + }; + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::ExpiredLink(expired_link_data), + ))) + + // else, send back form link + } else { + let customer_id = + CustomerId::from(pm_collect_link.primary_reference.clone().into()) + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "customer_id", + })?; + // Fetch customer + let customer = db + .find_customer_by_customer_id_merchant_id( + &customer_id, + &req.merchant_id, + &key_store, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: format!( + "Customer [{}] not found for link_id - {}", + pm_collect_link.primary_reference, pm_collect_link.link_id + ), + }) + .attach_printable(format!( + "customer [{}] not found", + pm_collect_link.primary_reference + ))?; + + let js_data = payment_methods::PaymentMethodCollectLinkDetails { + publishable_key: merchant_account + .publishable_key + .ok_or(errors::ApiErrorResponse::MissingRequiredField { + field_name: "publishable_key", + })? + .into(), + client_secret: link_data.client_secret.clone(), + pm_collect_link_id: pm_collect_link.link_id, + customer_id: customer.customer_id, + session_expiry: pm_collect_link.expiry, + return_url: pm_collect_link.return_url, + ui_config: ui_config_data, + enabled_payment_methods: link_data.enabled_payment_methods, + }; + + let serialized_css_content = String::new(); + + let serialized_js_content = format!( + "window.__PM_COLLECT_DETAILS = {}", + js_data + .encode_to_string_of_json() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize PaymentMethodCollectLinkDetails")? + ); + + let generic_form_data = services::GenericLinkFormData { + js_data: serialized_js_content, + css_data: serialized_css_content, + sdk_url: default_config.sdk_url.clone(), + html_meta_tags: String::new(), + }; + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::PaymentMethodCollect(generic_form_data), + ))) + } + } + + // Send back status page + status => { + let js_data = payment_methods::PaymentMethodCollectLinkStatusDetails { + pm_collect_link_id: pm_collect_link.link_id, + customer_id: link_data.customer_id, + session_expiry: pm_collect_link.expiry, + return_url: pm_collect_link.return_url, + ui_config: ui_config_data, + status, + }; + + let serialized_css_content = String::new(); + + let serialized_js_content = format!( + "window.__PM_COLLECT_DETAILS = {}", + js_data + .encode_to_string_of_json() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable( + "Failed to serialize PaymentMethodCollectLinkStatusDetails" + )? + ); + + let generic_status_data = services::GenericLinkStatusData { + js_data: serialized_js_content, + css_data: serialized_css_content, + }; + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::PaymentMethodCollectStatus(generic_status_data), + ))) + } + } +} + fn generate_task_id_for_payment_method_status_update_workflow( key_id: &str, runner: &storage::ProcessTrackerRunner, @@ -113,7 +342,7 @@ fn generate_task_id_for_payment_method_status_update_workflow( } pub async fn add_payment_method_status_update_task( - db: &dyn db::StorageInterface, + db: &dyn StorageInterface, payment_method: &diesel_models::PaymentMethod, prev_status: enums::PaymentMethodStatus, curr_status: enums::PaymentMethodStatus, @@ -121,7 +350,7 @@ pub async fn add_payment_method_status_update_task( ) -> Result<(), errors::ProcessTrackerError> { let created_at = payment_method.created_at; let schedule_time = - created_at.saturating_add(time::Duration::seconds(consts::DEFAULT_SESSION_EXPIRY)); + created_at.saturating_add(Duration::seconds(consts::DEFAULT_SESSION_EXPIRY)); let tracking_data = storage::PaymentMethodStatusTrackingData { payment_method_id: payment_method.payment_method_id.clone(), diff --git a/crates/router/src/core/payment_methods/validator.rs b/crates/router/src/core/payment_methods/validator.rs new file mode 100644 index 000000000000..19530f8944e9 --- /dev/null +++ b/crates/router/src/core/payment_methods/validator.rs @@ -0,0 +1,134 @@ +use api_models::{admin, payment_methods::PaymentMethodCollectLinkRequest}; +use common_utils::{ext_traits::ValueExt, link_utils}; +use diesel_models::generic_link::PaymentMethodCollectLinkData; +use error_stack::ResultExt; +use masking::Secret; + +use crate::{ + consts, + core::{ + errors::{self, RouterResult}, + utils as core_utils, + }, + routes::{app::StorageInterface, SessionState}, + types::domain, + utils, +}; + +pub async fn validate_request_and_initiate_payment_method_collect_link( + state: &SessionState, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + req: &PaymentMethodCollectLinkRequest, +) -> RouterResult { + // Validate customer_id + let db: &dyn StorageInterface = &*state.store; + let customer_id = req.customer_id.clone(); + let merchant_id = merchant_account.merchant_id.clone(); + match db + .find_customer_by_customer_id_merchant_id( + &customer_id, + &merchant_id, + key_store, + merchant_account.storage_scheme, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => { + if err.current_context().is_db_not_found() { + Err(err).change_context(errors::ApiErrorResponse::InvalidRequestData { + message: format!( + "customer [{}] not found for merchant [{}]", + customer_id.get_string_repr(), + merchant_id + ), + }) + } else { + Err(err) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("database error while finding customer") + } + } + }?; + + // Create payment method collect link ID + let pm_collect_link_id = core_utils::get_or_generate_id( + "pm_collect_link_id", + &req.pm_collect_link_id, + "pm_collect_link", + )?; + + // Fetch all configs + let default_config = &state.conf.generic_link.payment_method_collect; + let merchant_config = merchant_account + .pm_collect_link_config + .as_ref() + .map(|config| { + config + .clone() + .parse_value::("BusinessCollectLinkConfig") + }) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "pm_collect_link_config in merchant_account", + })?; + let merchant_ui_config = merchant_config.as_ref().map(|c| c.config.ui_config.clone()); + let ui_config = req + .ui_config + .as_ref() + .or(merchant_ui_config.as_ref()) + .cloned(); + + // Form data to be injected in the link + let (logo, merchant_name, theme) = match ui_config { + Some(config) => (config.logo, config.merchant_name, config.theme), + _ => (None, None, None), + }; + let pm_collect_link_config = link_utils::GenericLinkUiConfig { + logo, + merchant_name, + theme, + }; + let client_secret = utils::generate_id(consts::ID_LENGTH, "pm_collect_link_secret"); + let domain = merchant_config + .clone() + .and_then(|c| c.config.domain_name) + .map(|domain| format!("https://{}", domain)) + .unwrap_or(state.base_url.clone()); + let session_expiry = match req.session_expiry { + Some(expiry) => expiry, + None => default_config.expiry, + }; + let link = Secret::new(format!( + "{domain}/payment_methods/collect/{merchant_id}/{pm_collect_link_id}" + )); + let enabled_payment_methods = match (&req.enabled_payment_methods, &merchant_config) { + (Some(enabled_payment_methods), _) => enabled_payment_methods.clone(), + (None, Some(config)) => config.enabled_payment_methods.clone(), + _ => { + let mut default_enabled_payout_methods: Vec = vec![]; + for (payment_method, payment_method_types) in + default_config.enabled_payment_methods.clone().into_iter() + { + let enabled_payment_method = link_utils::EnabledPaymentMethod { + payment_method, + payment_method_types, + }; + default_enabled_payout_methods.push(enabled_payment_method); + } + + default_enabled_payout_methods + } + }; + + Ok(PaymentMethodCollectLinkData { + pm_collect_link_id: pm_collect_link_id.clone(), + customer_id, + link, + client_secret: Secret::new(client_secret), + session_expiry, + ui_config: pm_collect_link_config, + enabled_payment_methods: Some(enabled_payment_methods), + }) +} diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index e9df375ab6ed..a60d75100dbc 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -5,6 +5,7 @@ use api_models::{ payments::{CardToken, GetPaymentMethodType, RequestSurchargeDetails}, }; use base64::Engine; +use common_enums::ConnectorType; use common_utils::{ crypto::Encryptable, ext_traits::{AsyncExt, ByteSliceExt, Encode, ValueExt}, @@ -119,6 +120,16 @@ pub fn filter_mca_based_on_business_profile( } } +pub fn filter_mca_based_on_connector_type( + merchant_connector_accounts: Vec, + connector_type: ConnectorType, +) -> Vec { + merchant_connector_accounts + .into_iter() + .filter(|mca| mca.connector_type == connector_type) + .collect::>() +} + #[instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn create_or_update_address_for_payment_by_request( diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index 1d257810c5da..3488bb917e1d 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -1034,6 +1034,7 @@ impl ForeignFrom<(storage::Payouts, storage::PayoutAttempt, domain::Customer)> attempts: Some(vec![attempt]), billing: None, client_secret: None, + payout_link: None, } } } diff --git a/crates/router/src/core/payout_link.rs b/crates/router/src/core/payout_link.rs new file mode 100644 index 000000000000..200f23f49a40 --- /dev/null +++ b/crates/router/src/core/payout_link.rs @@ -0,0 +1,301 @@ +use std::collections::{HashMap, HashSet}; + +use api_models::payouts; +use common_utils::{ + ext_traits::{Encode, OptionExt}, + link_utils, +}; +use diesel_models::PayoutLinkUpdate; +use error_stack::ResultExt; + +use super::errors::{RouterResponse, StorageErrorExt}; +use crate::{ + core::payments::helpers, + errors, + routes::{app::StorageInterface, SessionState}, + services::{self, GenericLinks}, + types::{api::enums, domain}, +}; + +pub async fn initiate_payout_link( + state: SessionState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + req: payouts::PayoutLinkInitiateRequest, +) -> RouterResponse { + let db: &dyn StorageInterface = &*state.store; + let merchant_id = &merchant_account.merchant_id; + // Fetch payout + let payout = db + .find_payout_by_merchant_id_payout_id( + merchant_id, + &req.payout_id, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PayoutNotFound)?; + let payout_attempt = db + .find_payout_attempt_by_merchant_id_payout_attempt_id( + merchant_id, + &format!("{}_{}", payout.payout_id, payout.attempt_count), + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PayoutNotFound)?; + let payout_link_id = payout + .payout_link_id + .clone() + .get_required_value("payout link id") + .change_context(errors::ApiErrorResponse::GenericNotFoundError { + message: "payout link not found".to_string(), + })?; + // Fetch payout link + let payout_link = db + .find_payout_link_by_link_id(&payout_link_id) + .await + .to_not_found_response(errors::ApiErrorResponse::GenericNotFoundError { + message: "payout link not found".to_string(), + })?; + + // Check status and return form data accordingly + let has_expired = common_utils::date_time::now() > payout_link.expiry; + let status = payout_link.link_status.clone(); + let link_data = payout_link.link_data.clone(); + let default_config = &state.conf.generic_link.payout_link; + let default_ui_config = default_config.ui_config.clone(); + let ui_config_data = link_utils::GenericLinkUIConfigFormData { + merchant_name: link_data + .ui_config + .merchant_name + .unwrap_or(default_ui_config.merchant_name), + logo: link_data.ui_config.logo.unwrap_or(default_ui_config.logo), + theme: link_data + .ui_config + .theme + .clone() + .unwrap_or(default_ui_config.theme.clone()), + }; + match (has_expired, &status) { + // Send back generic expired page + (true, _) | (_, &link_utils::PayoutLinkStatus::Invalidated) => { + let expired_link_data = services::GenericExpiredLinkData { + title: "Payout Expired".to_string(), + message: "This payout link has expired.".to_string(), + theme: link_data.ui_config.theme.unwrap_or(default_ui_config.theme), + }; + + if status != link_utils::PayoutLinkStatus::Invalidated { + let payout_link_update = PayoutLinkUpdate::StatusUpdate { + link_status: link_utils::PayoutLinkStatus::Invalidated, + }; + db.update_payout_link(payout_link, payout_link_update) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payout links in db")?; + } + + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::ExpiredLink(expired_link_data), + ))) + } + + // Initiate Payout link flow + (_, link_utils::PayoutLinkStatus::Initiated) => { + let customer_id = link_data.customer_id; + let amount = payout + .destination_currency + .to_currency_base_unit(payout.amount) + .change_context(errors::ApiErrorResponse::CurrencyConversionFailed)?; + // Fetch customer + let customer = db + .find_customer_by_customer_id_merchant_id( + &customer_id, + &req.merchant_id, + &key_store, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: format!( + "Customer [{}] not found for link_id - {}", + payout_link.primary_reference, payout_link.link_id + ), + }) + .attach_printable_lazy(|| { + format!("customer [{}] not found", payout_link.primary_reference) + })?; + let enabled_payout_methods = + filter_payout_methods(db, &merchant_account, &key_store, &payout).await?; + // Fetch default enabled_payout_methods + let mut default_enabled_payout_methods: Vec = vec![]; + for (payment_method, payment_method_types) in + default_config.enabled_payment_methods.clone().into_iter() + { + let enabled_payment_method = link_utils::EnabledPaymentMethod { + payment_method, + payment_method_types, + }; + default_enabled_payout_methods.push(enabled_payment_method); + } + let fallback_enabled_payout_methods = if enabled_payout_methods.is_empty() { + &default_enabled_payout_methods + } else { + &enabled_payout_methods + }; + // Fetch enabled payout methods from the request. If not found, fetch the enabled payout methods from MCA, + // If none are configured for merchant connector accounts, fetch them from the default enabled payout methods. + let enabled_payment_methods = link_data + .enabled_payment_methods + .unwrap_or(fallback_enabled_payout_methods.to_vec()); + + let js_data = payouts::PayoutLinkDetails { + publishable_key: merchant_account + .publishable_key + .ok_or(errors::ApiErrorResponse::MissingRequiredField { + field_name: "publishable_key", + })? + .into(), + client_secret: link_data.client_secret.clone(), + payout_link_id: payout_link.link_id, + payout_id: payout_link.primary_reference, + customer_id: customer.customer_id, + session_expiry: payout_link.expiry, + return_url: payout_link.return_url, + ui_config: ui_config_data, + enabled_payment_methods, + amount, + currency: payout.destination_currency, + }; + + let serialized_css_content = String::new(); + + let serialized_js_content = format!( + "window.__PAYOUT_DETAILS = {}", + js_data + .encode_to_string_of_json() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize PaymentMethodCollectLinkDetails")? + ); + + let generic_form_data = services::GenericLinkFormData { + js_data: serialized_js_content, + css_data: serialized_css_content, + sdk_url: default_config.sdk_url.clone(), + html_meta_tags: String::new(), + }; + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::PayoutLink(generic_form_data), + ))) + } + + // Send back status page + (_, link_utils::PayoutLinkStatus::Submitted) => { + let js_data = payouts::PayoutLinkStatusDetails { + payout_link_id: payout_link.link_id, + payout_id: payout_link.primary_reference, + customer_id: link_data.customer_id, + session_expiry: payout_link.expiry, + return_url: payout_link.return_url, + status: payout.status, + error_code: payout_attempt.error_code, + error_message: payout_attempt.error_message, + ui_config: ui_config_data, + }; + + let serialized_css_content = String::new(); + + let serialized_js_content = format!( + "window.__PAYOUT_DETAILS = {}", + js_data + .encode_to_string_of_json() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize PaymentMethodCollectLinkDetails")? + ); + + let generic_status_data = services::GenericLinkStatusData { + js_data: serialized_js_content, + css_data: serialized_css_content, + }; + Ok(services::ApplicationResponse::GenericLinkForm(Box::new( + GenericLinks::PayoutLinkStatus(generic_status_data), + ))) + } + } +} + +#[cfg(feature = "payouts")] +pub async fn filter_payout_methods( + db: &dyn StorageInterface, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + payout: &hyperswitch_domain_models::payouts::payouts::Payouts, +) -> errors::RouterResult> { + //Fetch all merchant connector accounts + let all_mcas = db + .find_merchant_connector_account_by_merchant_id_and_disabled_list( + &merchant_account.merchant_id, + false, + key_store, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + // fetch all mca based on profile id + let filtered_mca_on_profile = + helpers::filter_mca_based_on_business_profile(all_mcas, Some(payout.profile_id.clone())); + //Since we just need payout connectors here, filter mca based on connector type. + let filtered_mca = helpers::filter_mca_based_on_connector_type( + filtered_mca_on_profile.clone(), + common_enums::ConnectorType::PayoutProcessor, + ); + + let mut response: Vec = vec![]; + let mut payment_method_list_hm: HashMap< + common_enums::PaymentMethod, + HashSet, + > = HashMap::new(); + let mut bank_transfer_hs: HashSet = HashSet::new(); + let mut card_hs: HashSet = HashSet::new(); + let mut wallet_hs: HashSet = HashSet::new(); + for mca in &filtered_mca { + let payment_methods = match &mca.payment_methods_enabled { + Some(pm) => pm, + None => continue, + }; + for payment_method in payment_methods.iter() { + let parse_result = serde_json::from_value::( + payment_method.clone(), + ); + if let Ok(payment_methods_enabled) = parse_result { + let payment_method = payment_methods_enabled.payment_method; + let payment_method_types = match payment_methods_enabled.payment_method_types { + Some(pmt) => pmt, + None => continue, + }; + for pmts in &payment_method_types { + if payment_method == common_enums::PaymentMethod::Card { + card_hs.insert(pmts.payment_method_type); + payment_method_list_hm.insert(payment_method, card_hs.clone()); + } else if payment_method == common_enums::PaymentMethod::Wallet { + wallet_hs.insert(pmts.payment_method_type); + payment_method_list_hm.insert(payment_method, wallet_hs.clone()); + } else if payment_method == common_enums::PaymentMethod::BankTransfer { + bank_transfer_hs.insert(pmts.payment_method_type); + payment_method_list_hm.insert(payment_method, bank_transfer_hs.clone()); + } + } + } + } + } + for (pm, method_types) in payment_method_list_hm { + if !method_types.is_empty() { + let payment_method_types: Vec = + method_types.into_iter().collect(); + let enabled_payment_method = link_utils::EnabledPaymentMethod { + payment_method: pm, + payment_method_types, + }; + response.push(enabled_payment_method); + } + } + Ok(response) +} diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index 447787a50a2a..630590750046 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -5,9 +5,16 @@ pub mod retry; pub mod validator; use std::vec::IntoIter; -use api_models::enums as api_enums; -use common_utils::{consts, crypto::Encryptable, ext_traits::ValueExt, pii, types::MinorUnit}; -use diesel_models::enums as storage_enums; +use api_models::{self, enums as api_enums, payouts::PayoutLinkResponse}; +use common_utils::{ + consts, + crypto::Encryptable, + ext_traits::{AsyncExt, ValueExt}, + link_utils::PayoutLinkStatus, + pii, + types::MinorUnit, +}; +use diesel_models::{enums as storage_enums, generic_link::PayoutLink}; use error_stack::{report, ResultExt}; #[cfg(feature = "olap")] use futures::future::join_all; @@ -58,6 +65,7 @@ pub struct PayoutData { pub payout_method_data: Option, pub profile_id: String, pub should_terminate: bool, + pub payout_link: Option, } // ********************************************** CORE FLOWS ********************************************** @@ -327,6 +335,74 @@ pub async fn payouts_create_core( ) .await?; + if let Some(true) = payout_data.payouts.confirm { + payouts_core( + &state, + &merchant_account, + &key_store, + &mut payout_data, + req.routing.clone(), + req.connector.clone(), + ) + .await? + }; + + response_handler(&merchant_account, &payout_data).await +} + +#[instrument(skip_all)] +pub async fn payouts_confirm_core( + state: SessionState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + req: payouts::PayoutCreateRequest, +) -> RouterResponse { + let mut payout_data = make_payout_data( + &state, + &merchant_account, + &key_store, + &payouts::PayoutRequest::PayoutCreateRequest(req.to_owned()), + ) + .await?; + let payout_attempt = payout_data.payout_attempt.to_owned(); + let status = payout_attempt.status; + + helpers::update_payouts_and_payout_attempt(&mut payout_data, &merchant_account, &req, &state) + .await?; + helpers::validate_payout_status_against_not_allowed_statuses( + &status, + &[ + storage_enums::PayoutStatus::Cancelled, + storage_enums::PayoutStatus::Success, + storage_enums::PayoutStatus::Failed, + storage_enums::PayoutStatus::Pending, + storage_enums::PayoutStatus::Ineligible, + storage_enums::PayoutStatus::RequiresFulfillment, + storage_enums::PayoutStatus::RequiresVendorAccountCreation, + storage_enums::PayoutStatus::RequiresVendorAccountCreation, + ], + "confirm", + )?; + + // Update payout link's status + let db = &*state.store; + + // payout_data.payout_link + payout_data.payout_link = payout_data + .payout_link + .clone() + .async_map(|pl| async move { + let payout_link_update = storage::PayoutLinkUpdate::StatusUpdate { + link_status: PayoutLinkStatus::Submitted, + }; + db.update_payout_link(pl, payout_link_update) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payout links in db") + }) + .await + .transpose()?; + payouts_core( &state, &merchant_account, @@ -367,74 +443,8 @@ pub async fn payouts_update_core( ), })); } - // Update DB with new data - let payouts = payout_data.payouts.to_owned(); - let amount = MinorUnit::from(req.amount.unwrap_or(MinorUnit::new(payouts.amount).into())) - .get_amount_as_i64(); - let updated_payouts = storage::PayoutsUpdate::Update { - amount, - destination_currency: req.currency.unwrap_or(payouts.destination_currency), - source_currency: req.currency.unwrap_or(payouts.source_currency), - description: req.description.clone().or(payouts.description.clone()), - recurring: req.recurring.unwrap_or(payouts.recurring), - auto_fulfill: req.auto_fulfill.unwrap_or(payouts.auto_fulfill), - return_url: req.return_url.clone().or(payouts.return_url.clone()), - entity_type: req.entity_type.unwrap_or(payouts.entity_type), - metadata: req.metadata.clone().or(payouts.metadata.clone()), - status: Some(status), - profile_id: Some(payout_attempt.profile_id.clone()), - confirm: req.confirm, - }; - - let db = &*state.store; - payout_data.payouts = db - .update_payout( - &payouts, - updated_payouts, - &payout_attempt, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Error updating payouts")?; - - let updated_business_country = - payout_attempt - .business_country - .map_or(req.business_country.to_owned(), |c| { - req.business_country - .to_owned() - .and_then(|nc| if nc != c { Some(nc) } else { None }) - }); - let updated_business_label = - payout_attempt - .business_label - .map_or(req.business_label.to_owned(), |l| { - req.business_label - .to_owned() - .and_then(|nl| if nl != l { Some(nl) } else { None }) - }); - match (updated_business_country, updated_business_label) { - (None, None) => {} - (business_country, business_label) => { - let payout_attempt = payout_data.payout_attempt; - let updated_payout_attempt = storage::PayoutAttemptUpdate::BusinessUpdate { - business_country, - business_label, - }; - payout_data.payout_attempt = db - .update_payout_attempt( - &payout_attempt, - updated_payout_attempt, - &payout_data.payouts, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Error updating payout_attempt")?; - } - } - + helpers::update_payouts_and_payout_attempt(&mut payout_data, &merchant_account, &req, &state) + .await?; let payout_attempt = payout_data.payout_attempt.to_owned(); if (req.connector.is_none(), payout_attempt.connector.is_some()) != (true, true) { @@ -902,48 +912,42 @@ pub async fn call_connector_payout( .get_required_value("payout_method_data")?, ); } - - if let Some(true) = payouts.confirm { - // Eligibility flow - complete_payout_eligibility( - state, - merchant_account, - key_store, - connector_data, - payout_data, - ) - .await?; - - // Create customer flow - complete_create_recipient( - state, - merchant_account, - key_store, - connector_data, - payout_data, - ) - .await?; - - // Create customer's disbursement account flow - complete_create_recipient_disburse_account( - state, - merchant_account, - key_store, - connector_data, - payout_data, - ) - .await?; - - // Payout creation flow - Box::pin(complete_create_payout( - state, - merchant_account, - key_store, - connector_data, - payout_data, - )) - .await?; - }; + // Eligibility flow + complete_payout_eligibility( + state, + merchant_account, + key_store, + connector_data, + payout_data, + ) + .await?; + // Create customer flow + complete_create_recipient( + state, + merchant_account, + key_store, + connector_data, + payout_data, + ) + .await?; + // Create customer's disbursement account flow + complete_create_recipient_disburse_account( + state, + merchant_account, + key_store, + connector_data, + payout_data, + ) + .await?; + // Payout creation flow + Box::pin(complete_create_payout( + state, + merchant_account, + key_store, + connector_data, + payout_data, + )) + .await?; // Auto fulfillment flow let status = payout_data.payout_attempt.status; @@ -970,7 +974,12 @@ pub async fn complete_create_recipient( payout_data: &mut PayoutData, ) -> RouterResult<()> { if !payout_data.should_terminate - && payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation + && matches!( + payout_data.payout_attempt.status, + common_enums::PayoutStatus::RequiresCreation + | common_enums::PayoutStatus::RequiresConfirmation + | common_enums::PayoutStatus::RequiresPayoutMethodData + ) && connector_data .connector_name .supports_create_recipient(payout_data.payouts.payout_type) @@ -1001,7 +1010,6 @@ pub async fn create_recipient( // Create the connector label using {profile_id}_{connector_name} let connector_label = format!("{}_{}", payout_data.profile_id, connector_name); - let (should_call_connector, _connector_customer_id) = helpers::should_call_payout_connector_create_customer( state, @@ -1290,7 +1298,12 @@ pub async fn complete_create_payout( payout_data: &mut PayoutData, ) -> RouterResult<()> { if !payout_data.should_terminate - && payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation + && matches!( + payout_data.payout_attempt.status, + storage_enums::PayoutStatus::RequiresCreation + | storage_enums::PayoutStatus::RequiresConfirmation + | storage_enums::PayoutStatus::RequiresPayoutMethodData + ) { if connector_data .connector_name @@ -1870,6 +1883,7 @@ pub async fn response_handler( ) -> RouterResponse { let payout_attempt = payout_data.payout_attempt.to_owned(); let payouts = payout_data.payouts.to_owned(); + let payout_link = payout_data.payout_link.to_owned(); let billing_address = payout_data.billing_address.to_owned(); let customer_details = payout_data.customer_details.to_owned(); let customer_id = payouts.customer_id; @@ -1916,7 +1930,7 @@ pub async fn response_handler( name, phone, phone_country_code, - client_secret: None, + client_secret: payouts.client_secret.to_owned(), return_url: payouts.return_url.to_owned(), business_country: payout_attempt.business_country, business_label: payout_attempt.business_label, @@ -1932,6 +1946,10 @@ pub async fn response_handler( connector_transaction_id: payout_attempt.connector_payout_id, priority: payouts.priority, attempts: None, + payout_link: payout_link.map(|payout_link| PayoutLinkResponse { + payout_link_id: payout_link.link_id.clone(), + link: payout_link.url, + }), }; Ok(services::ApplicationResponse::Json(response)) } @@ -1974,6 +1992,25 @@ pub async fn payout_create_db_entries( })? .customer_id; + // Validate whether profile_id passed in request is valid and is linked to the merchant + let business_profile = + validate_and_get_business_profile(state, profile_id, merchant_id).await?; + + let payout_link = match req.payout_link { + Some(true) => Some( + validator::create_payout_link( + state, + &business_profile, + &customer_id, + &merchant_account.merchant_id, + req, + payout_id, + ) + .await?, + ), + _ => None, + }; + // Get or create address let billing_address = payment_helpers::create_or_find_address_for_payment_by_request( db, @@ -2004,6 +2041,10 @@ pub async fn payout_create_db_entries( } else { None }; + let client_secret = utils::generate_id( + consts::ID_LENGTH, + format!("payout_{payout_id}_secret").as_str(), + ); let amount = MinorUnit::from(req.amount.unwrap_or(api::Amount::Zero)).get_amount_as_i64(); let payouts_req = storage::PayoutsNew { payout_id: payout_id.to_string(), @@ -2024,6 +2065,10 @@ pub async fn payout_create_db_entries( attempt_count: 1, metadata: req.metadata.clone(), confirm: req.confirm, + payout_link_id: payout_link + .clone() + .map(|link_data| link_data.link_id.clone()), + client_secret: Some(client_secret), priority: req.priority, ..Default::default() }; @@ -2034,13 +2079,15 @@ pub async fn payout_create_db_entries( payout_id: payout_id.to_owned(), }) .attach_printable("Error inserting payouts in db")?; - // Make payout_attempt entry let status = if req.payout_method_data.is_some() || req.payout_token.is_some() || stored_payout_method_data.is_some() { - storage_enums::PayoutStatus::RequiresCreation + match req.confirm { + Some(true) => storage_enums::PayoutStatus::RequiresCreation, + _ => storage_enums::PayoutStatus::RequiresConfirmation, + } } else { storage_enums::PayoutStatus::RequiresPayoutMethodData }; @@ -2071,10 +2118,6 @@ pub async fn payout_create_db_entries( }) .attach_printable("Error inserting payout_attempt in db")?; - // Validate whether profile_id passed in request is valid and is linked to the merchant - let business_profile = - validate_and_get_business_profile(state, profile_id, merchant_id).await?; - // Make PayoutData Ok(PayoutData { billing_address, @@ -2090,6 +2133,7 @@ pub async fn payout_create_db_entries( .or(stored_payout_method_data.cloned()), should_terminate: false, profile_id: profile_id.to_owned(), + payout_link, }) } @@ -2154,6 +2198,45 @@ pub async fn make_payout_data( // Validate whether profile_id passed in request is valid and is linked to the merchant let business_profile = validate_and_get_business_profile(state, &profile_id, merchant_id).await?; + let payout_method_data = match req { + payouts::PayoutRequest::PayoutCreateRequest(r) => r.payout_method_data.to_owned(), + payouts::PayoutRequest::PayoutRetrieveRequest(_) + | payouts::PayoutRequest::PayoutActionRequest(_) => { + match payout_attempt.payout_token.to_owned() { + Some(payout_token) => { + let customer_id = customer_details + .as_ref() + .map(|cd| cd.customer_id.to_owned()) + .get_required_value("customer")?; + helpers::make_payout_method_data( + state, + None, + Some(&payout_token), + &customer_id, + &merchant_account.merchant_id, + payouts.payout_type, + key_store, + None, + merchant_account.storage_scheme, + ) + .await? + } + None => None, + } + } + }; + + let payout_link = payouts + .payout_link_id + .clone() + .async_map(|link_id| async move { + db.find_payout_link_by_link_id(&link_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error fetching payout links from db") + }) + .await + .transpose()?; Ok(PayoutData { billing_address, @@ -2161,10 +2244,11 @@ pub async fn make_payout_data( customer_details, payouts, payout_attempt, - payout_method_data: None, + payout_method_data: payout_method_data.to_owned(), merchant_connector_account: None, should_terminate: false, profile_id, + payout_link, }) } diff --git a/crates/router/src/core/payouts/helpers.rs b/crates/router/src/core/payouts/helpers.rs index d7079fbfa89b..0b54205bf671 100644 --- a/crates/router/src/core/payouts/helpers.rs +++ b/crates/router/src/core/payouts/helpers.rs @@ -2,10 +2,11 @@ use api_models::{enums, payment_methods::Card, payouts}; use common_utils::{ errors::CustomResult, ext_traits::{AsyncExt, StringExt}, - generate_customer_id_of_default_length, id_type, + fp_utils, generate_customer_id_of_default_length, id_type, + types::MinorUnit, }; use diesel_models::encryption::Encryption; -use error_stack::ResultExt; +use error_stack::{report, ResultExt}; use masking::{ExposeInterface, PeekInterface, Secret}; use router_env::logger; @@ -883,6 +884,20 @@ pub fn is_payout_initiated(status: api_enums::PayoutStatus) -> bool { ) } +pub(crate) fn validate_payout_status_against_not_allowed_statuses( + payout_status: &api_enums::PayoutStatus, + not_allowed_statuses: &[api_enums::PayoutStatus], + action: &'static str, +) -> Result<(), errors::ApiErrorResponse> { + fp_utils::when(not_allowed_statuses.contains(payout_status), || { + Err(errors::ApiErrorResponse::PreconditionFailed { + message: format!( + "You cannot {action} this payout because it has status {payout_status}", + ), + }) + }) +} + pub fn is_payout_terminal_state(status: api_enums::PayoutStatus) -> bool { !matches!( status, @@ -922,3 +937,105 @@ pub(super) async fn filter_by_constraints( .await?; Ok(result) } + +pub async fn update_payouts_and_payout_attempt( + payout_data: &mut PayoutData, + merchant_account: &domain::MerchantAccount, + req: &payouts::PayoutCreateRequest, + state: &SessionState, +) -> CustomResult<(), errors::ApiErrorResponse> { + let payout_attempt = payout_data.payout_attempt.to_owned(); + let status = payout_attempt.status; + let payout_id = payout_attempt.payout_id.clone(); + // Verify update feasibility + if is_payout_terminal_state(status) || is_payout_initiated(status) { + return Err(report!(errors::ApiErrorResponse::InvalidRequestData { + message: format!( + "Payout {} cannot be updated for status {}", + payout_id, status + ), + })); + } + + // Update DB with new data + let payouts = payout_data.payouts.to_owned(); + let amount = MinorUnit::from(req.amount.unwrap_or(MinorUnit::new(payouts.amount).into())) + .get_amount_as_i64(); + let updated_payouts = storage::PayoutsUpdate::Update { + amount, + destination_currency: req + .currency + .to_owned() + .unwrap_or(payouts.destination_currency), + source_currency: req.currency.to_owned().unwrap_or(payouts.source_currency), + description: req + .description + .to_owned() + .clone() + .or(payouts.description.clone()), + recurring: req.recurring.to_owned().unwrap_or(payouts.recurring), + auto_fulfill: req.auto_fulfill.to_owned().unwrap_or(payouts.auto_fulfill), + return_url: req + .return_url + .to_owned() + .clone() + .or(payouts.return_url.clone()), + entity_type: req.entity_type.to_owned().unwrap_or(payouts.entity_type), + metadata: req.metadata.clone().or(payouts.metadata.clone()), + status: Some(status), + profile_id: Some(payout_attempt.profile_id.clone()), + confirm: req.confirm.to_owned(), + payout_type: req + .payout_type + .to_owned() + .or(payouts.payout_type.to_owned()), + }; + let db = &*state.store; + payout_data.payouts = db + .update_payout( + &payouts, + updated_payouts, + &payout_attempt, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payouts")?; + let updated_business_country = + payout_attempt + .business_country + .map_or(req.business_country.to_owned(), |c| { + req.business_country + .to_owned() + .and_then(|nc| if nc != c { Some(nc) } else { None }) + }); + let updated_business_label = + payout_attempt + .business_label + .map_or(req.business_label.to_owned(), |l| { + req.business_label + .to_owned() + .and_then(|nl| if nl != l { Some(nl) } else { None }) + }); + match (updated_business_country, updated_business_label) { + (None, None) => Ok(()), + (business_country, business_label) => { + let payout_attempt = &payout_data.payout_attempt; + let updated_payout_attempt = storage::PayoutAttemptUpdate::BusinessUpdate { + business_country, + business_label, + }; + payout_data.payout_attempt = db + .update_payout_attempt( + payout_attempt, + updated_payout_attempt, + &payout_data.payouts, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payout_attempt")?; + Ok(()) + } + } +} diff --git a/crates/router/src/core/payouts/retry.rs b/crates/router/src/core/payouts/retry.rs index a005e65ee93c..d552298498c2 100644 --- a/crates/router/src/core/payouts/retry.rs +++ b/crates/router/src/core/payouts/retry.rs @@ -351,6 +351,7 @@ impl GsmValidation for PayoutData { fn should_call_gsm(&self) -> bool { match self.payout_attempt.status { common_enums::PayoutStatus::Success + | common_enums::PayoutStatus::RequiresConfirmation | common_enums::PayoutStatus::Cancelled | common_enums::PayoutStatus::Pending | common_enums::PayoutStatus::Initiated diff --git a/crates/router/src/core/payouts/validator.rs b/crates/router/src/core/payouts/validator.rs index b3ef9e8d4b45..5bd5d62b6a11 100644 --- a/crates/router/src/core/payouts/validator.rs +++ b/crates/router/src/core/payouts/validator.rs @@ -1,19 +1,33 @@ +use api_models::admin; #[cfg(feature = "olap")] use common_utils::errors::CustomResult; +use common_utils::{ + ext_traits::ValueExt, + id_type::CustomerId, + link_utils::{GenericLinkStatus, GenericLinkUiConfig, PayoutLinkData, PayoutLinkStatus}, + types::MinorUnit, +}; +use diesel_models::{ + business_profile::BusinessProfile, + generic_link::{GenericLinkNew, PayoutLink}, +}; use error_stack::{report, ResultExt}; pub use hyperswitch_domain_models::errors::StorageError; +use masking::Secret; use router_env::{instrument, tracing}; +use time::Duration; use super::helpers; use crate::{ + consts, core::{ - errors::{self, RouterResult}, + errors::{self, RouterResult, StorageErrorExt}, utils as core_utils, }, db::StorageInterface, routes::SessionState, types::{api::payouts, domain, storage}, - utils, + utils::{self, OptionExt}, }; #[instrument(skip(db))] @@ -52,6 +66,12 @@ pub async fn validate_create_request( ) -> RouterResult<(String, Option, String)> { let merchant_id = &merchant_account.merchant_id; + if let Some(payout_link) = &req.payout_link { + if *payout_link { + validate_payout_link_request(req.confirm)?; + } + }; + // Merchant ID let predicate = req.merchant_id.as_ref().map(|mid| mid != merchant_id); utils::when(predicate.unwrap_or(false), || { @@ -122,6 +142,19 @@ pub async fn validate_create_request( Ok((payout_id, payout_method_data, profile_id)) } +pub fn validate_payout_link_request(confirm: Option) -> Result<(), errors::ApiErrorResponse> { + if let Some(cnf) = confirm { + if cnf { + return Err(errors::ApiErrorResponse::InvalidRequestData { + message: "cannot confirm a payout while creating a payout link".to_string(), + }); + } else { + return Ok(()); + } + } + Ok(()) +} + #[cfg(feature = "olap")] pub(super) fn validate_payout_list_request( req: &payouts::PayoutListConstraints, @@ -158,3 +191,126 @@ pub(super) fn validate_payout_list_request_for_joins( })?; Ok(()) } + +#[allow(clippy::too_many_arguments)] +pub async fn create_payout_link( + state: &SessionState, + business_profile: &BusinessProfile, + customer_id: &CustomerId, + merchant_id: &String, + req: &payouts::PayoutCreateRequest, + payout_id: &String, +) -> RouterResult { + let payout_link_config_req = req.payout_link_config.to_owned(); + // Create payment method collect link ID + let payout_link_id = core_utils::get_or_generate_id( + "payout_link_id", + &payout_link_config_req + .as_ref() + .and_then(|config| config.payout_link_id.clone()), + "payout_link", + )?; + + // Fetch all configs + let default_config = &state.conf.generic_link.payout_link; + let profile_config = business_profile + .payout_link_config + .as_ref() + .map(|config| { + config + .clone() + .parse_value::("BusinessPayoutLinkConfig") + }) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config in business_profile", + })?; + let profile_ui_config = profile_config.as_ref().map(|c| c.config.ui_config.clone()); + let ui_config = payout_link_config_req + .as_ref() + .and_then(|config| config.ui_config.clone()) + .or(profile_ui_config); + + // Form data to be injected in the link + let (logo, merchant_name, theme) = match ui_config { + Some(config) => (config.logo, config.merchant_name, config.theme), + _ => (None, None, None), + }; + let payout_link_config = GenericLinkUiConfig { + logo, + merchant_name, + theme, + }; + let client_secret = utils::generate_id(consts::ID_LENGTH, "payout_link_secret"); + let base_url = profile_config + .as_ref() + .and_then(|c| c.config.domain_name.as_ref()) + .map(|domain| format!("https://{}", domain)) + .unwrap_or(state.base_url.clone()); + let session_expiry = req + .session_expiry + .as_ref() + .map_or(default_config.expiry, |expiry| *expiry); + let link = Secret::new(format!("{base_url}/payout_link/{merchant_id}/{payout_id}")); + let req_enabled_payment_methods = payout_link_config_req + .as_ref() + .and_then(|req| req.enabled_payment_methods.to_owned()); + let amount = req + .amount + .as_ref() + .get_required_value("amount") + .attach_printable("amount is a required value when creating payout links")?; + let currency = req + .currency + .as_ref() + .get_required_value("currency") + .attach_printable("currency is a required value when creating payout links")?; + + let data = PayoutLinkData { + payout_link_id: payout_link_id.clone(), + customer_id: customer_id.clone(), + payout_id: payout_id.to_string(), + link, + client_secret: Secret::new(client_secret), + session_expiry, + ui_config: payout_link_config, + enabled_payment_methods: req_enabled_payment_methods, + amount: MinorUnit::from(*amount), + currency: *currency, + }; + + create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await +} + +pub async fn create_payout_link_db_entry( + state: &SessionState, + merchant_id: &String, + payout_link_data: &PayoutLinkData, + return_url: Option, +) -> RouterResult { + let db: &dyn StorageInterface = &*state.store; + + let link_data = serde_json::to_value(payout_link_data) + .map_err(|_| report!(errors::ApiErrorResponse::InternalServerError)) + .attach_printable("Failed to convert PayoutLinkData to Value")?; + + let payout_link = GenericLinkNew { + link_id: payout_link_data.payout_link_id.to_string(), + primary_reference: payout_link_data.payout_id.to_string(), + merchant_id: merchant_id.to_string(), + link_type: common_enums::GenericLinkType::PayoutLink, + link_status: GenericLinkStatus::PayoutLink(PayoutLinkStatus::Initiated), + link_data, + url: payout_link_data.link.clone(), + return_url, + expiry: common_utils::date_time::now() + + Duration::seconds(payout_link_data.session_expiry.into()), + ..Default::default() + }; + + db.insert_payout_link(payout_link) + .await + .to_duplicate_response(errors::ApiErrorResponse::GenericDuplicateError { + message: "payout link already exists".to_string(), + }) +} diff --git a/crates/router/src/core/routing.rs b/crates/router/src/core/routing.rs index 5314de4d2f88..c2312fe22456 100644 --- a/crates/router/src/core/routing.rs +++ b/crates/router/src/core/routing.rs @@ -621,6 +621,7 @@ pub async fn unlink_routing_config( payout_routing_algorithm: None, default_profile: None, payment_link_config: None, + pm_collect_link_config: None, }; db.update_specific_fields_in_merchant( diff --git a/crates/router/src/core/routing/helpers.rs b/crates/router/src/core/routing/helpers.rs index 274d4192710f..caa976580b86 100644 --- a/crates/router/src/core/routing/helpers.rs +++ b/crates/router/src/core/routing/helpers.rs @@ -216,6 +216,7 @@ pub async fn update_merchant_active_algorithm_ref( payout_routing_algorithm: None, default_profile: None, payment_link_config: None, + pm_collect_link_config: None, }; db.update_specific_fields_in_merchant( @@ -280,6 +281,7 @@ pub async fn update_business_profile_active_algorithm_ref( payment_link_config: None, session_expiry: None, authentication_connector_details: None, + payout_link_config: None, extended_card_info_config: None, use_billing_as_payment_method_billing: None, collect_shipping_details_from_wallet_connector: None, diff --git a/crates/router/src/db.rs b/crates/router/src/db.rs index 2bf80827e8a1..2a00e7adbb66 100644 --- a/crates/router/src/db.rs +++ b/crates/router/src/db.rs @@ -16,6 +16,7 @@ pub mod ephemeral_key; pub mod events; pub mod file; pub mod fraud_check; +pub mod generic_link; pub mod gsm; pub mod health_check; pub mod kafka_store; @@ -120,6 +121,7 @@ pub trait StorageInterface: + role::RoleInterface + user_authentication_method::UserAuthenticationMethodInterface + authentication::AuthenticationInterface + + generic_link::GenericLinkInterface + 'static { fn get_scheduler_db(&self) -> Box; diff --git a/crates/router/src/db/generic_link.rs b/crates/router/src/db/generic_link.rs new file mode 100644 index 000000000000..3a62a443f9dc --- /dev/null +++ b/crates/router/src/db/generic_link.rs @@ -0,0 +1,194 @@ +use error_stack::report; +use router_env::{instrument, tracing}; + +use crate::{ + connection, + core::errors::{self, CustomResult}, + db::MockDb, + services::Store, + types::storage, +}; + +#[async_trait::async_trait] +pub trait GenericLinkInterface { + async fn find_generic_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult; + + async fn find_pm_collect_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult; + + async fn find_payout_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult; + + async fn insert_generic_link( + &self, + _generic_link: storage::GenericLinkNew, + ) -> CustomResult; + + async fn insert_pm_collect_link( + &self, + _pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult; + + async fn insert_payout_link( + &self, + _payout_link: storage::GenericLinkNew, + ) -> CustomResult; + + async fn update_payout_link( + &self, + payout_link: storage::PayoutLink, + payout_link_update: storage::PayoutLinkUpdate, + ) -> CustomResult; +} + +#[async_trait::async_trait] +impl GenericLinkInterface for Store { + #[instrument(skip_all)] + async fn find_generic_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_read(self).await?; + storage::GenericLink::find_generic_link_by_link_id(&conn, link_id) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn find_pm_collect_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_read(self).await?; + storage::GenericLink::find_pm_collect_link_by_link_id(&conn, link_id) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn find_payout_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_read(self).await?; + storage::GenericLink::find_payout_link_by_link_id(&conn, link_id) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn insert_generic_link( + &self, + generic_link: storage::GenericLinkNew, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + generic_link + .insert(&conn) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn insert_pm_collect_link( + &self, + pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + pm_collect_link + .insert_pm_collect_link(&conn) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn insert_payout_link( + &self, + pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + pm_collect_link + .insert_payout_link(&conn) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } + + #[instrument(skip_all)] + async fn update_payout_link( + &self, + payout_link: storage::PayoutLink, + payout_link_update: storage::PayoutLinkUpdate, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + payout_link + .update_payout_link(&conn, payout_link_update) + .await + .map_err(|error| report!(errors::StorageError::from(error))) + } +} + +#[async_trait::async_trait] +impl GenericLinkInterface for MockDb { + async fn find_generic_link_by_link_id( + &self, + _generic_link_id: &str, + ) -> CustomResult { + // TODO: Implement function for `MockDb`x + Err(errors::StorageError::MockDbError)? + } + + async fn find_pm_collect_link_by_link_id( + &self, + _generic_link_id: &str, + ) -> CustomResult { + // TODO: Implement function for `MockDb`x + Err(errors::StorageError::MockDbError)? + } + + async fn find_payout_link_by_link_id( + &self, + _generic_link_id: &str, + ) -> CustomResult { + // TODO: Implement function for `MockDb`x + Err(errors::StorageError::MockDbError)? + } + + async fn insert_generic_link( + &self, + _generic_link: storage::GenericLinkNew, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn insert_pm_collect_link( + &self, + _pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn insert_payout_link( + &self, + _pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn update_payout_link( + &self, + _payout_link: storage::PayoutLink, + _payout_link_update: storage::PayoutLinkUpdate, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } +} diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index 044b17947c5a..e60bcc79bdb6 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -55,6 +55,7 @@ use crate::{ ephemeral_key::EphemeralKeyInterface, events::EventInterface, file::FileMetadataInterface, + generic_link::GenericLinkInterface, gsm::GsmInterface, health_check::HealthCheckDbInterface, locker_mock_up::LockerMockUpInterface, @@ -2875,6 +2876,67 @@ impl RoleInterface for KafkaStore { } } +#[async_trait::async_trait] +impl GenericLinkInterface for KafkaStore { + async fn find_generic_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + self.diesel_store + .find_generic_link_by_link_id(link_id) + .await + } + + async fn find_pm_collect_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + self.diesel_store + .find_pm_collect_link_by_link_id(link_id) + .await + } + + async fn find_payout_link_by_link_id( + &self, + link_id: &str, + ) -> CustomResult { + self.diesel_store.find_payout_link_by_link_id(link_id).await + } + + async fn insert_generic_link( + &self, + generic_link: storage::GenericLinkNew, + ) -> CustomResult { + self.diesel_store.insert_generic_link(generic_link).await + } + + async fn insert_pm_collect_link( + &self, + pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + self.diesel_store + .insert_pm_collect_link(pm_collect_link) + .await + } + + async fn insert_payout_link( + &self, + pm_collect_link: storage::GenericLinkNew, + ) -> CustomResult { + self.diesel_store.insert_payout_link(pm_collect_link).await + } + + async fn update_payout_link( + &self, + payout_link: storage::PayoutLink, + payout_link_update: storage::PayoutLinkUpdate, + ) -> CustomResult { + self.diesel_store + .update_payout_link(payout_link, payout_link_update) + .await + } +} + #[async_trait::async_trait] impl UserKeyStoreInterface for KafkaStore { async fn insert_user_key_store( diff --git a/crates/router/src/events/api_logs.rs b/crates/router/src/events/api_logs.rs index ddf7a9db06a3..6da8d697ac27 100644 --- a/crates/router/src/events/api_logs.rs +++ b/crates/router/src/events/api_logs.rs @@ -17,7 +17,7 @@ use crate::{ core::payments::PaymentsRedirectResponseData, services::{ authentication::AuthenticationType, kafka::KafkaMessage, ApplicationResponse, - PaymentLinkFormData, + GenericLinkFormData, PaymentLinkFormData, }, types::api::{ AttachEvidenceRequest, Config, ConfigUpdate, CreateFileRequest, DisputeId, FileId, PollId, @@ -116,6 +116,7 @@ impl_misc_api_event_type!( FileId, AttachEvidenceRequest, PaymentLinkFormData, + GenericLinkFormData, ConfigUpdate ); diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index e3c01987cced..c543be8cc922 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -155,7 +155,9 @@ pub fn mk_app( #[cfg(feature = "payouts")] { - server_app = server_app.service(routes::Payouts::server(state.clone())); + server_app = server_app + .service(routes::Payouts::server(state.clone())) + .service(routes::PayoutLink::server(state.clone())); } #[cfg(feature = "stripe")] diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index cb229b5c802a..f21c11c0aa77 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -28,6 +28,7 @@ pub mod metrics; pub mod payment_link; pub mod payment_methods; pub mod payments; +pub mod payout_link; #[cfg(feature = "payouts")] pub mod payouts; #[cfg(any(feature = "olap", feature = "oltp"))] @@ -54,8 +55,6 @@ pub mod webhooks; pub use self::app::DummyConnector; #[cfg(any(feature = "olap", feature = "oltp"))] pub use self::app::Forex; -#[cfg(feature = "payouts")] -pub use self::app::Payouts; #[cfg(all(feature = "olap", feature = "recon"))] pub use self::app::Recon; pub use self::app::{ @@ -66,6 +65,8 @@ pub use self::app::{ }; #[cfg(feature = "olap")] pub use self::app::{Blocklist, Routing, Verify, WebhookEvents}; +#[cfg(feature = "payouts")] +pub use self::app::{PayoutLink, Payouts}; #[cfg(feature = "stripe")] pub use super::compatibility::stripe::StripeApis; #[cfg(feature = "olap")] diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index d4aebc3adc46..b91129f45759 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -23,6 +23,8 @@ use super::blocklist; #[cfg(feature = "dummy_connector")] use super::dummy_connector::*; #[cfg(feature = "payouts")] +use super::payout_link::*; +#[cfg(feature = "payouts")] use super::payouts::*; #[cfg(feature = "olap")] use super::routing as cloud_routing; @@ -889,6 +891,7 @@ impl Payouts { .route(web::get().to(payouts_retrieve)) .route(web::put().to(payouts_update)), ) + .service(web::resource("/{payout_id}/confirm").route(web::post().to(payouts_confirm))) .service(web::resource("/{payout_id}/cancel").route(web::post().to(payouts_cancel))) .service(web::resource("/{payout_id}/fulfill").route(web::post().to(payouts_fulfill))); route @@ -916,6 +919,13 @@ impl PaymentMethods { .route(web::post().to(create_payment_method_api)) .route(web::get().to(list_payment_method_api)), // TODO : added for sdk compatibility for now, need to deprecate this later ) + .service( + web::resource("/collect").route(web::post().to(initiate_pm_collect_link_flow)), + ) + .service( + web::resource("/collect/{merchant_id}/{collect_id}") + .route(web::get().to(render_pm_collect_link)), + ) .service( web::resource("/{payment_method_id}") .route(web::get().to(payment_method_retrieve_api)) @@ -1248,6 +1258,20 @@ impl PaymentLink { } } +#[cfg(feature = "payouts")] +pub struct PayoutLink; + +#[cfg(feature = "payouts")] +impl PayoutLink { + pub fn server(state: AppState) -> Scope { + let mut route = web::scope("/payout_link").app_data(web::Data::new(state)); + route = route.service( + web::resource("/{merchant_id}/{payout_id}").route(web::get().to(render_payout_link)), + ); + route + } +} + pub struct BusinessProfile; #[cfg(feature = "olap")] diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 720174117835..b18720652cf8 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -98,6 +98,7 @@ impl From for ApiIdentifier { | Flow::PaymentMethodsRetrieve | Flow::PaymentMethodsUpdate | Flow::PaymentMethodsDelete + | Flow::PaymentMethodCollectLink | Flow::ValidatePaymentMethod | Flow::ListCountriesCurrencies | Flow::DefaultPaymentMethodsSet @@ -132,7 +133,9 @@ impl From for ApiIdentifier { | Flow::PayoutsFulfill | Flow::PayoutsList | Flow::PayoutsFilter - | Flow::PayoutsAccounts => Self::Payouts, + | Flow::PayoutsAccounts + | Flow::PayoutsConfirm + | Flow::PayoutLinkInitiate => Self::Payouts, Flow::RefundsCreate | Flow::RefundsRetrieve diff --git a/crates/router/src/routes/metrics/request.rs b/crates/router/src/routes/metrics/request.rs index 41f8c8d8071f..5ec2692da99c 100644 --- a/crates/router/src/routes/metrics/request.rs +++ b/crates/router/src/routes/metrics/request.rs @@ -43,6 +43,7 @@ pub fn track_response_status_code(response: &ApplicationResponse) -> i64 { | ApplicationResponse::StatusOk | ApplicationResponse::TextPlain(_) | ApplicationResponse::Form(_) + | ApplicationResponse::GenericLinkForm(_) | ApplicationResponse::PaymentLinkForm(_) | ApplicationResponse::FileData(_) | ApplicationResponse::JsonWithHeaders(_) => 200, diff --git a/crates/router/src/routes/payment_methods.rs b/crates/router/src/routes/payment_methods.rs index 09b5c6257117..c83b9483c498 100644 --- a/crates/router/src/routes/payment_methods.rs +++ b/crates/router/src/routes/payment_methods.rs @@ -7,7 +7,10 @@ use time::PrimitiveDateTime; use super::app::{AppState, SessionState}; use crate::{ - core::{api_locking, errors, payment_methods::cards}, + core::{ + api_locking, errors, + payment_methods::{self as payment_methods_routes, cards}, + }, services::{api, authentication as auth, authorization::permissions::Permission}, types::{ api::payment_methods::{self, PaymentMethodId}, @@ -223,6 +226,65 @@ pub async fn list_customer_payment_method_api_client( .await } +/// Generate a form link for collecting payment methods for a customer +#[instrument(skip_all, fields(flow = ?Flow::PaymentMethodCollectLink))] +pub async fn initiate_pm_collect_link_flow( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::PaymentMethodCollectLink; + Box::pin(api::server_wrap( + flow, + state, + &req, + json_payload.into_inner(), + |state, auth, req, _| { + payment_methods_routes::initiate_pm_collect_link( + state, + auth.merchant_account, + auth.key_store, + req, + ) + }, + &auth::ApiKeyAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +/// Generate a form link for collecting payment methods for a customer +#[instrument(skip_all, fields(flow = ?Flow::PaymentMethodCollectLink))] +pub async fn render_pm_collect_link( + state: web::Data, + req: HttpRequest, + path: web::Path<(String, String)>, +) -> HttpResponse { + let flow = Flow::PaymentMethodCollectLink; + let (merchant_id, pm_collect_link_id) = path.into_inner(); + let payload = payment_methods::PaymentMethodCollectLinkRenderRequest { + merchant_id: merchant_id.clone(), + pm_collect_link_id, + }; + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, auth, req, _| { + payment_methods_routes::render_pm_collect_link( + state, + auth.merchant_account, + auth.key_store, + req, + ) + }, + &auth::MerchantIdAuth(merchant_id), + api_locking::LockAction::NotApplicable, + )) + .await +} + #[instrument(skip_all, fields(flow = ?Flow::PaymentMethodsRetrieve))] pub async fn payment_method_retrieve_api( state: web::Data, diff --git a/crates/router/src/routes/payout_link.rs b/crates/router/src/routes/payout_link.rs new file mode 100644 index 000000000000..34850bdea6e7 --- /dev/null +++ b/crates/router/src/routes/payout_link.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "payouts")] +use actix_web::{web, Responder}; +#[cfg(feature = "payouts")] +use api_models::payouts::PayoutLinkInitiateRequest; +#[cfg(feature = "payouts")] +use router_env::Flow; + +#[cfg(feature = "payouts")] +use crate::{ + core::{api_locking, payout_link::*}, + services::{api, authentication as auth}, + AppState, +}; +#[cfg(feature = "payouts")] +pub async fn render_payout_link( + state: web::Data, + req: actix_web::HttpRequest, + path: web::Path<(String, String)>, +) -> impl Responder { + let flow = Flow::PayoutLinkInitiate; + let (merchant_id, payout_id) = path.into_inner(); + let payload = PayoutLinkInitiateRequest { + merchant_id: merchant_id.clone(), + payout_id, + }; + Box::pin(api::server_wrap( + flow, + state, + &req, + payload.clone(), + |state, auth, req, _| { + initiate_payout_link(state, auth.merchant_account, auth.key_store, req) + }, + &auth::MerchantIdAuth(merchant_id), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/routes/payouts.rs b/crates/router/src/routes/payouts.rs index 315c6e6bd0a1..4e81b67fb3d6 100644 --- a/crates/router/src/routes/payouts.rs +++ b/crates/router/src/routes/payouts.rs @@ -131,6 +131,39 @@ pub async fn payouts_update( )) .await } + +#[instrument(skip_all, fields(flow = ?Flow::PayoutsConfirm))] +pub async fn payouts_confirm( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, + path: web::Path, +) -> HttpResponse { + let flow = Flow::PayoutsConfirm; + let mut payload = json_payload.into_inner(); + let payout_id = path.into_inner(); + tracing::Span::current().record("payout_id", &payout_id); + payload.payout_id = Some(payout_id); + payload.confirm = Some(true); + let (auth_type, _auth_flow) = + match auth::check_client_secret_and_get_auth(req.headers(), &payload) { + Ok(auth) => auth, + Err(e) => return api::log_and_return_error_response(e), + }; + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, auth, req, _| { + payouts_confirm_core(state, auth.merchant_account, auth.key_store, req) + }, + &*auth_type, + api_locking::LockAction::NotApplicable, + )) + .await +} /// Payouts - Cancel #[utoipa::path( post, diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index ba02999023a6..a7198e74c4eb 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -1,9 +1,10 @@ pub mod client; +pub mod generic_link_response; pub mod request; use std::{ collections::{HashMap, HashSet}, error::Error, - fmt::Debug, + fmt::{Debug, Display}, future::Future, str, sync::Arc, @@ -62,7 +63,10 @@ use crate::{ app::{AppStateInfo, ReqState, SessionStateInfo}, metrics, AppState, SessionState, }, - services::connector_integration_interface::RouterDataConversion, + services::{ + connector_integration_interface::RouterDataConversion, + generic_link_response::build_generic_link_html, + }, types::{ self, api::{self, ConnectorCommon}, @@ -719,6 +723,53 @@ pub enum ApplicationResponse { PaymentLinkForm(Box), FileData((Vec, mime::Mime)), JsonWithHeaders((R, Vec<(String, Maskable)>)), + GenericLinkForm(Box), +} + +#[derive(Debug, Eq, PartialEq)] +pub enum GenericLinks { + ExpiredLink(GenericExpiredLinkData), + PaymentMethodCollect(GenericLinkFormData), + PayoutLink(GenericLinkFormData), + PayoutLinkStatus(GenericLinkStatusData), + PaymentMethodCollectStatus(GenericLinkStatusData), +} + +impl Display for Box { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match **self { + GenericLinks::ExpiredLink(_) => "ExpiredLink", + GenericLinks::PaymentMethodCollect(_) => "PaymentMethodCollect", + GenericLinks::PayoutLink(_) => "PayoutLink", + GenericLinks::PayoutLinkStatus(_) => "PayoutLinkStatus", + GenericLinks::PaymentMethodCollectStatus(_) => "PaymentMethodCollectStatus", + } + ) + } +} + +#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericLinkFormData { + pub js_data: String, + pub css_data: String, + pub sdk_url: String, + pub html_meta_tags: String, +} + +#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericExpiredLinkData { + pub title: String, + pub message: String, + pub theme: String, +} + +#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericLinkStatusData { + pub js_data: String, + pub css_data: String, } #[derive(Debug, Eq, PartialEq)] @@ -1066,6 +1117,16 @@ where .map_into_boxed_body() } + Ok(ApplicationResponse::GenericLinkForm(boxed_generic_link_data)) => { + let link_type = (boxed_generic_link_data).to_string(); + match build_generic_link_html(*boxed_generic_link_data) { + Ok(rendered_html) => http_response_html_data(rendered_html), + Err(_) => { + http_response_err(format!("Error while rendering {} HTML page", link_type)) + } + } + } + Ok(ApplicationResponse::PaymentLinkForm(boxed_payment_link_data)) => { match *boxed_payment_link_data { PaymentLinkAction::PaymentLinkFormData(payment_link_data) => { diff --git a/crates/router/src/services/api/generic_link_response.rs b/crates/router/src/services/api/generic_link_response.rs new file mode 100644 index 000000000000..fb8b3b3ec74d --- /dev/null +++ b/crates/router/src/services/api/generic_link_response.rs @@ -0,0 +1,247 @@ +use common_utils::errors::CustomResult; +use error_stack::ResultExt; +use tera::{Context, Tera}; + +use super::{GenericExpiredLinkData, GenericLinkFormData, GenericLinkStatusData, GenericLinks}; +use crate::core::errors; + +pub fn build_generic_link_html( + boxed_generic_link_data: GenericLinks, +) -> CustomResult { + match boxed_generic_link_data { + GenericLinks::ExpiredLink(link_data) => build_generic_expired_link_html(&link_data), + + GenericLinks::PaymentMethodCollect(pm_collect_data) => { + build_pm_collect_link_html(&pm_collect_data) + } + GenericLinks::PaymentMethodCollectStatus(pm_collect_data) => { + build_pm_collect_link_status_html(&pm_collect_data) + } + GenericLinks::PayoutLink(payout_link_data) => build_payout_link_html(&payout_link_data), + + GenericLinks::PayoutLinkStatus(pm_collect_data) => { + build_payout_link_status_html(&pm_collect_data) + } + } +} + +pub fn build_generic_expired_link_html( + link_data: &GenericExpiredLinkData, +) -> CustomResult { + let mut tera = Tera::default(); + let mut context = Context::new(); + + // Build HTML + let html_template = include_str!("../../core/generic_link/expired_link/index.html").to_string(); + let _ = tera.add_raw_template("generic_expired_link", &html_template); + context.insert("title", &link_data.title); + context.insert("message", &link_data.message); + context.insert("theme", &link_data.theme); + + tera.render("generic_expired_link", &context) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render expired link HTML template") +} + +pub fn build_payout_link_html( + link_data: &GenericLinkFormData, +) -> CustomResult { + let mut tera = Tera::default(); + let mut context = Context::new(); + + // Insert dynamic context in CSS + let css_dynamic_context = "{{ color_scheme }}"; + let css_template = + include_str!("../../core/generic_link/payout_link/initiate/styles.css").to_string(); + let final_css = format!("{}\n{}", css_dynamic_context, css_template); + let _ = tera.add_raw_template("payout_link_styles", &final_css); + context.insert("color_scheme", &link_data.css_data); + + let css_style_tag = tera + .render("payout_link_styles", &context) + .map(|css| format!("", css)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link's CSS template")?; + + // Insert dynamic context in JS + let js_dynamic_context = "{{ payout_link_context }}"; + let js_template = + include_str!("../../core/generic_link/payout_link/initiate/script.js").to_string(); + let final_js = format!("{}\n{}", js_dynamic_context, js_template); + let _ = tera.add_raw_template("payout_link_script", &final_js); + context.insert("payout_link_context", &link_data.js_data); + + let js_script_tag = tera + .render("payout_link_script", &context) + .map(|js| format!("", js)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link's JS template")?; + + // Build HTML + let html_template = + include_str!("../../core/generic_link/payout_link/initiate/index.html").to_string(); + let _ = tera.add_raw_template("payout_link", &html_template); + context.insert("css_style_tag", &css_style_tag); + context.insert("js_script_tag", &js_script_tag); + context.insert( + "hyper_sdk_loader_script_tag", + &format!( + r#""#, + link_data.sdk_url + ), + ); + + tera.render("payout_link", &context) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link's HTML template") +} + +pub fn build_pm_collect_link_html( + link_data: &GenericLinkFormData, +) -> CustomResult { + let mut tera = Tera::default(); + let mut context = Context::new(); + + // Insert dynamic context in CSS + let css_dynamic_context = "{{ color_scheme }}"; + let css_template = + include_str!("../../core/generic_link/payment_method_collect/initiate/styles.css") + .to_string(); + let final_css = format!("{}\n{}", css_dynamic_context, css_template); + let _ = tera.add_raw_template("pm_collect_link_styles", &final_css); + context.insert("color_scheme", &link_data.css_data); + + let css_style_tag = tera + .render("pm_collect_link_styles", &context) + .map(|css| format!("", css)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link's CSS template")?; + + // Insert dynamic context in JS + let js_dynamic_context = "{{ collect_link_context }}"; + let js_template = + include_str!("../../core/generic_link/payment_method_collect/initiate/script.js") + .to_string(); + let final_js = format!("{}\n{}", js_dynamic_context, js_template); + let _ = tera.add_raw_template("pm_collect_link_script", &final_js); + context.insert("collect_link_context", &link_data.js_data); + + let js_script_tag = tera + .render("pm_collect_link_script", &context) + .map(|js| format!("", js)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link's JS template")?; + + // Build HTML + let html_template = + include_str!("../../core/generic_link/payment_method_collect/initiate/index.html") + .to_string(); + let _ = tera.add_raw_template("payment_method_collect_link", &html_template); + context.insert("css_style_tag", &css_style_tag); + context.insert("js_script_tag", &js_script_tag); + context.insert( + "hyper_sdk_loader_script_tag", + &format!( + r#""#, + link_data.sdk_url + ), + ); + + tera.render("payment_method_collect_link", &context) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link's HTML template") +} + +pub fn build_payout_link_status_html( + link_data: &GenericLinkStatusData, +) -> CustomResult { + let mut tera = Tera::default(); + let mut context = Context::new(); + + // Insert dynamic context in CSS + let css_dynamic_context = "{{ color_scheme }}"; + let css_template = + include_str!("../../core/generic_link/payout_link/status/styles.css").to_string(); + let final_css = format!("{}\n{}", css_dynamic_context, css_template); + let _ = tera.add_raw_template("payout_link_status_styles", &final_css); + context.insert("color_scheme", &link_data.css_data); + + let css_style_tag = tera + .render("payout_link_status_styles", &context) + .map(|css| format!("", css)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link status CSS template")?; + + // Insert dynamic context in JS + let js_dynamic_context = "{{ collect_link_status_context }}"; + let js_template = + include_str!("../../core/generic_link/payout_link/status/script.js").to_string(); + let final_js = format!("{}\n{}", js_dynamic_context, js_template); + let _ = tera.add_raw_template("payout_link_status_script", &final_js); + context.insert("collect_link_status_context", &link_data.js_data); + + let js_script_tag = tera + .render("payout_link_status_script", &context) + .map(|js| format!("", js)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link status JS template")?; + + // Build HTML + let html_template = + include_str!("../../core/generic_link/payout_link/status/index.html").to_string(); + let _ = tera.add_raw_template("payout_status_link", &html_template); + context.insert("css_style_tag", &css_style_tag); + context.insert("js_script_tag", &js_script_tag); + + tera.render("payout_status_link", &context) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payout link status HTML template") +} + +pub fn build_pm_collect_link_status_html( + link_data: &GenericLinkStatusData, +) -> CustomResult { + let mut tera = Tera::default(); + let mut context = Context::new(); + + // Insert dynamic context in CSS + let css_dynamic_context = "{{ color_scheme }}"; + let css_template = + include_str!("../../core/generic_link/payment_method_collect/status/styles.css") + .to_string(); + let final_css = format!("{}\n{}", css_dynamic_context, css_template); + let _ = tera.add_raw_template("pm_collect_link_status_styles", &final_css); + context.insert("color_scheme", &link_data.css_data); + + let css_style_tag = tera + .render("pm_collect_link_status_styles", &context) + .map(|css| format!("", css)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link status CSS template")?; + + // Insert dynamic context in JS + let js_dynamic_context = "{{ collect_link_status_context }}"; + let js_template = + include_str!("../../core/generic_link/payment_method_collect/status/script.js").to_string(); + let final_js = format!("{}\n{}", js_dynamic_context, js_template); + let _ = tera.add_raw_template("pm_collect_link_status_script", &final_js); + context.insert("collect_link_status_context", &link_data.js_data); + + let js_script_tag = tera + .render("pm_collect_link_status_script", &context) + .map(|js| format!("", js)) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link status JS template")?; + + // Build HTML + let html_template = + include_str!("../../core/generic_link/payment_method_collect/status/index.html") + .to_string(); + let _ = tera.add_raw_template("payment_method_collect_status_link", &html_template); + context.insert("css_style_tag", &css_style_tag); + context.insert("js_script_tag", &js_script_tag); + + tera.render("payment_method_collect_status_link", &context) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to render payment method collect link status HTML template") +} diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index 675b3951806b..43d49b92efb1 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -1,4 +1,6 @@ use actix_web::http::header::HeaderMap; +#[cfg(feature = "payouts")] +use api_models::payouts; use api_models::{ payment_methods::{PaymentMethodCreate, PaymentMethodListRequest}, payments, @@ -946,6 +948,12 @@ where pub trait ClientSecretFetch { fn get_client_secret(&self) -> Option<&String>; } +#[cfg(feature = "payouts")] +impl ClientSecretFetch for payouts::PayoutCreateRequest { + fn get_client_secret(&self) -> Option<&String> { + self.client_secret.as_ref() + } +} impl ClientSecretFetch for payments::PaymentsRequest { fn get_client_secret(&self) -> Option<&String> { @@ -1028,7 +1036,6 @@ where PublishableKeyAuth: AuthenticateAndFetch, { let api_key = get_api_key(headers)?; - if api_key.starts_with("pk_") { payload .get_client_secret() diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index 09c362c11643..77693157f625 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -22,6 +22,11 @@ impl TryFrom for MerchantAccountResponse { .primary_business_details .parse_value("primary_business_details")?; + let pm_collect_link_config: Option = item + .pm_collect_link_config + .map(|config| config.parse_value("pm_collect_link_config")) + .transpose()?; + Ok(Self { merchant_id: item.merchant_id, merchant_name: item.merchant_name, @@ -46,6 +51,7 @@ impl TryFrom for MerchantAccountResponse { is_recon_enabled: item.is_recon_enabled, default_profile: item.default_profile, recon_status: item.recon_status, + pm_collect_link_config, }) } } @@ -80,6 +86,12 @@ impl ForeignTryFrom for BusinessProf authentication_connector_details.parse_value("AuthenticationDetails") }) .transpose()?, + payout_link_config: item + .payout_link_config + .map(|payout_link_config| { + payout_link_config.parse_value("BusinessPayoutLinkConfig") + }) + .transpose()?, use_billing_as_payment_method_billing: item.use_billing_as_payment_method_billing, extended_card_info_config: item .extended_card_info_config @@ -184,6 +196,14 @@ impl ForeignTryFrom<(domain::MerchantAccount, BusinessProfileCreate)> .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "authentication_connector_details", })?, + payout_link_config: request + .payout_link_config + .as_ref() + .map(Encode::encode_to_value) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "payout_link_config", + })?, is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled, is_extended_card_info_enabled: None, extended_card_info_config: None, diff --git a/crates/router/src/types/api/payment_methods.rs b/crates/router/src/types/api/payment_methods.rs index 6bf100e4d67d..ef92c099735b 100644 --- a/crates/router/src/types/api/payment_methods.rs +++ b/crates/router/src/types/api/payment_methods.rs @@ -2,8 +2,9 @@ pub use api_models::payment_methods::{ CardDetail, CardDetailFromLocker, CardDetailsPaymentMethod, CustomerPaymentMethod, CustomerPaymentMethodsListResponse, DefaultPaymentMethod, DeleteTokenizeByTokenRequest, GetTokenizePayloadRequest, GetTokenizePayloadResponse, ListCountriesCurrenciesRequest, - PaymentMethodCreate, PaymentMethodCreateData, PaymentMethodDeleteResponse, PaymentMethodId, - PaymentMethodList, PaymentMethodListRequest, PaymentMethodListResponse, PaymentMethodResponse, + PaymentMethodCollectLinkRenderRequest, PaymentMethodCollectLinkRequest, PaymentMethodCreate, + PaymentMethodCreateData, PaymentMethodDeleteResponse, PaymentMethodId, PaymentMethodList, + PaymentMethodListRequest, PaymentMethodListResponse, PaymentMethodResponse, PaymentMethodUpdate, PaymentMethodsData, TokenizePayloadEncrypted, TokenizePayloadRequest, TokenizedCardValue1, TokenizedCardValue2, TokenizedWalletValue1, TokenizedWalletValue2, }; diff --git a/crates/router/src/types/domain/merchant_account.rs b/crates/router/src/types/domain/merchant_account.rs index 5818ead38680..aa1faeab67d2 100644 --- a/crates/router/src/types/domain/merchant_account.rs +++ b/crates/router/src/types/domain/merchant_account.rs @@ -46,6 +46,7 @@ pub struct MerchantAccount { pub default_profile: Option, pub recon_status: diesel_models::enums::ReconStatus, pub payment_link_config: Option, + pub pm_collect_link_config: Option, } #[allow(clippy::large_enum_variant)] @@ -71,6 +72,7 @@ pub enum MerchantAccountUpdate { payout_routing_algorithm: Option, default_profile: Option>, payment_link_config: Option, + pm_collect_link_config: Option, }, StorageSchemeUpdate { storage_scheme: MerchantStorageScheme, @@ -105,6 +107,7 @@ impl From for MerchantAccountUpdateInternal { payout_routing_algorithm, default_profile, payment_link_config, + pm_collect_link_config, } => Self { merchant_name: merchant_name.map(Encryption::from), merchant_details: merchant_details.map(Encryption::from), @@ -126,6 +129,7 @@ impl From for MerchantAccountUpdateInternal { payout_routing_algorithm, default_profile, payment_link_config, + pm_collect_link_config, ..Default::default() }, MerchantAccountUpdate::StorageSchemeUpdate { storage_scheme } => Self { @@ -184,6 +188,7 @@ impl super::behaviour::Conversion for MerchantAccount { default_profile: self.default_profile, recon_status: self.recon_status, payment_link_config: self.payment_link_config, + pm_collect_link_config: self.pm_collect_link_config, }) } @@ -229,6 +234,7 @@ impl super::behaviour::Conversion for MerchantAccount { default_profile: item.default_profile, recon_status: item.recon_status, payment_link_config: item.payment_link_config, + pm_collect_link_config: item.pm_collect_link_config, }) } .await @@ -265,6 +271,7 @@ impl super::behaviour::Conversion for MerchantAccount { default_profile: self.default_profile, recon_status: self.recon_status, payment_link_config: self.payment_link_config, + pm_collect_link_config: self.pm_collect_link_config, }) } } diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs index 4ad25f003d1b..9894beb54c1b 100644 --- a/crates/router/src/types/domain/user.rs +++ b/crates/router/src/types/domain/user.rs @@ -396,6 +396,7 @@ impl NewUserMerchant { payment_response_hash_key: None, enable_payment_response_hash: None, redirect_to_merchant_with_http_post: None, + pm_collect_link_config: None, }, )) .await diff --git a/crates/router/src/types/storage.rs b/crates/router/src/types/storage.rs index 5a358c272b1b..e77175c2c7fe 100644 --- a/crates/router/src/types/storage.rs +++ b/crates/router/src/types/storage.rs @@ -17,6 +17,7 @@ pub mod ephemeral_key; pub mod events; pub mod file; pub mod fraud_check; +pub mod generic_link; pub mod gsm; #[cfg(feature = "kv_store")] pub mod kv; @@ -60,10 +61,10 @@ pub use self::{ address::*, api_keys::*, authentication::*, authorization::*, blocklist::*, blocklist_fingerprint::*, blocklist_lookup::*, business_profile::*, capture::*, cards_info::*, configs::*, customers::*, dashboard_metadata::*, dispute::*, ephemeral_key::*, events::*, - file::*, fraud_check::*, gsm::*, locker_mock_up::*, mandate::*, merchant_account::*, - merchant_connector_account::*, merchant_key_store::*, payment_link::*, payment_method::*, - process_tracker::*, refund::*, reverse_lookup::*, role::*, routing_algorithm::*, user::*, - user_authentication_method::*, user_role::*, + file::*, fraud_check::*, generic_link::*, gsm::*, locker_mock_up::*, mandate::*, + merchant_account::*, merchant_connector_account::*, merchant_key_store::*, payment_link::*, + payment_method::*, process_tracker::*, refund::*, reverse_lookup::*, role::*, + routing_algorithm::*, user::*, user_authentication_method::*, user_role::*, }; use crate::types::api::routing; diff --git a/crates/router/src/types/storage/generic_link.rs b/crates/router/src/types/storage/generic_link.rs new file mode 100644 index 000000000000..baf5326f1a0d --- /dev/null +++ b/crates/router/src/types/storage/generic_link.rs @@ -0,0 +1,4 @@ +pub use diesel_models::generic_link::{ + GenericLink, GenericLinkData, GenericLinkNew, GenericLinkState, GenericLinkUpdateInternal, + PaymentMethodCollectLink, PayoutLink, PayoutLinkUpdate, +}; diff --git a/crates/router/src/types/transformers.rs b/crates/router/src/types/transformers.rs index 404297e4aad6..f754b31e0b03 100644 --- a/crates/router/src/types/transformers.rs +++ b/crates/router/src/types/transformers.rs @@ -578,7 +578,8 @@ impl ForeignFrom for Option None, + | storage_enums::PayoutStatus::RequiresVendorAccountCreation + | storage_enums::PayoutStatus::RequiresConfirmation => None, } } } diff --git a/crates/router/src/workflows/outgoing_webhook_retry.rs b/crates/router/src/workflows/outgoing_webhook_retry.rs index c6d02d9aab2d..05bd978e12c0 100644 --- a/crates/router/src/workflows/outgoing_webhook_retry.rs +++ b/crates/router/src/workflows/outgoing_webhook_retry.rs @@ -141,13 +141,13 @@ impl ProcessTrackerWorkflow for OutgoingWebhookRetryWorkflow { .await?; // TODO: Add request state for the PT flows as well - let (content, event_type) = get_outgoing_webhook_content_and_event_type( + let (content, event_type) = Box::pin(get_outgoing_webhook_content_and_event_type( state.clone(), state.get_req_state(), merchant_account.clone(), key_store.clone(), &tracking_data, - ) + )) .await?; match event_type { @@ -378,6 +378,7 @@ async fn get_outgoing_webhook_content_and_event_type( | ApplicationResponse::TextPlain(_) | ApplicationResponse::JsonForRedirection(_) | ApplicationResponse::Form(_) + | ApplicationResponse::GenericLinkForm(_) | ApplicationResponse::PaymentLinkForm(_) | ApplicationResponse::FileData(_) => { Err(errors::ProcessTrackerError::ResourceFetchingFailed { @@ -433,6 +434,7 @@ async fn get_outgoing_webhook_content_and_event_type( | ApplicationResponse::TextPlain(_) | ApplicationResponse::JsonForRedirection(_) | ApplicationResponse::Form(_) + | ApplicationResponse::GenericLinkForm(_) | ApplicationResponse::PaymentLinkForm(_) | ApplicationResponse::FileData(_) => { Err(errors::ProcessTrackerError::ResourceFetchingFailed { @@ -464,6 +466,7 @@ async fn get_outgoing_webhook_content_and_event_type( | ApplicationResponse::TextPlain(_) | ApplicationResponse::JsonForRedirection(_) | ApplicationResponse::Form(_) + | ApplicationResponse::GenericLinkForm(_) | ApplicationResponse::PaymentLinkForm(_) | ApplicationResponse::FileData(_) => { Err(errors::ProcessTrackerError::ResourceFetchingFailed { diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 3a7cb3ba2a0b..0d146a9eb2bd 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -121,6 +121,8 @@ pub enum Flow { CustomersList, /// Retrieve countries and currencies for connector and payment method ListCountriesCurrencies, + /// Payment method create collect link flow. + PaymentMethodCollectLink, /// Payment methods retrieve flow. PaymentMethodsRetrieve, /// Payment methods update flow. @@ -164,6 +166,8 @@ pub enum Flow { #[cfg(feature = "payouts")] /// Payouts update flow. PayoutsUpdate, + /// Payouts confirm flow. + PayoutsConfirm, #[cfg(feature = "payouts")] /// Payouts cancel flow. PayoutsCancel, @@ -178,7 +182,9 @@ pub enum Flow { PayoutsFilter, /// Payouts accounts flow. PayoutsAccounts, - /// Payments Redirect flow. + /// Payout link initiate flow + PayoutLinkInitiate, + /// Payments Redirect flow PaymentsRedirect, /// Payemnts Complete Authorize Flow PaymentsCompleteAuthorize, diff --git a/crates/storage_impl/src/payouts/payouts.rs b/crates/storage_impl/src/payouts/payouts.rs index c63254d72fd7..69005a27a8b7 100644 --- a/crates/storage_impl/src/payouts/payouts.rs +++ b/crates/storage_impl/src/payouts/payouts.rs @@ -89,6 +89,8 @@ impl PayoutsInterface for KVRouterStore { status: new.status, attempt_count: new.attempt_count, confirm: new.confirm, + payout_link_id: new.payout_link_id.clone(), + client_secret: new.client_secret.clone(), priority: new.priority, }; @@ -689,6 +691,8 @@ impl DataModelExt for Payouts { status: self.status, attempt_count: self.attempt_count, confirm: self.confirm, + payout_link_id: self.payout_link_id, + client_secret: self.client_secret, priority: self.priority, } } @@ -716,6 +720,8 @@ impl DataModelExt for Payouts { status: storage_model.status, attempt_count: storage_model.attempt_count, confirm: storage_model.confirm, + payout_link_id: storage_model.payout_link_id, + client_secret: storage_model.client_secret, priority: storage_model.priority, } } @@ -746,6 +752,8 @@ impl DataModelExt for PayoutsNew { status: self.status, attempt_count: self.attempt_count, confirm: self.confirm, + payout_link_id: self.payout_link_id, + client_secret: self.client_secret, priority: self.priority, } } @@ -773,6 +781,8 @@ impl DataModelExt for PayoutsNew { status: storage_model.status, attempt_count: storage_model.attempt_count, confirm: storage_model.confirm, + payout_link_id: storage_model.payout_link_id, + client_secret: storage_model.client_secret, priority: storage_model.priority, } } @@ -794,6 +804,7 @@ impl DataModelExt for PayoutsUpdate { profile_id, status, confirm, + payout_type, } => DieselPayoutsUpdate::Update { amount, destination_currency, @@ -807,6 +818,7 @@ impl DataModelExt for PayoutsUpdate { profile_id, status, confirm, + payout_type, }, Self::PayoutMethodIdUpdate { payout_method_id } => { DieselPayoutsUpdate::PayoutMethodIdUpdate { payout_method_id } diff --git a/migrations/2024-04-17-084906_add_generic_link_table/down.sql b/migrations/2024-04-17-084906_add_generic_link_table/down.sql new file mode 100644 index 000000000000..ed595dfee599 --- /dev/null +++ b/migrations/2024-04-17-084906_add_generic_link_table/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS generic_link; +DROP TYPE IF EXISTS "GenericLinkType" \ No newline at end of file diff --git a/migrations/2024-04-17-084906_add_generic_link_table/up.sql b/migrations/2024-04-17-084906_add_generic_link_table/up.sql new file mode 100644 index 000000000000..805c762880aa --- /dev/null +++ b/migrations/2024-04-17-084906_add_generic_link_table/up.sql @@ -0,0 +1,18 @@ +CREATE TYPE "GenericLinkType" as ENUM( + 'payment_method_collect', + 'payout_link' +); + +CREATE TABLE generic_link ( + link_id VARCHAR (64) NOT NULL PRIMARY KEY, + primary_reference VARCHAR (64) NOT NULL, + merchant_id VARCHAR (64) NOT NULL, + created_at timestamp NOT NULL DEFAULT NOW():: timestamp, + last_modified_at timestamp NOT NULL DEFAULT NOW():: timestamp, + expiry timestamp NOT NULL, + link_data JSONB NOT NULL, + link_status JSONB NOT NULL, + link_type "GenericLinkType" NOT NULL, + url TEXT NOT NULL, + return_url TEXT NULL +); \ No newline at end of file diff --git a/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/down.sql b/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/down.sql new file mode 100644 index 000000000000..92aaf852afa2 --- /dev/null +++ b/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE merchant_account +DROP COLUMN IF EXISTS pm_collect_link_config; + +ALTER TABLE business_profile +DROP COLUMN IF EXISTS payout_link_config; \ No newline at end of file diff --git a/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/up.sql b/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/up.sql new file mode 100644 index 000000000000..def9f9d5bfaa --- /dev/null +++ b/migrations/2024-04-23-061745_add_pm_collect_link_config_to_merchant_account/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE merchant_account +ADD COLUMN IF NOT EXISTS pm_collect_link_config JSONB NULL; + +ALTER TABLE business_profile +ADD COLUMN IF NOT EXISTS payout_link_config JSONB NULL; \ No newline at end of file diff --git a/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/down.sql b/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/down.sql new file mode 100644 index 000000000000..63e124e00222 --- /dev/null +++ b/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE payouts DROP COLUMN IF EXISTS payout_link_id; \ No newline at end of file diff --git a/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/up.sql b/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/up.sql new file mode 100644 index 000000000000..2558248e5715 --- /dev/null +++ b/migrations/2024-05-30-105524_add_payout_link_id_in_payouts/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE payouts ADD COLUMN IF NOT EXISTS payout_link_id VARCHAR(255); \ No newline at end of file diff --git a/migrations/2024-06-04-074145_add_client_secret_in_payouts/down.sql b/migrations/2024-06-04-074145_add_client_secret_in_payouts/down.sql new file mode 100644 index 000000000000..57a54ceaafc4 --- /dev/null +++ b/migrations/2024-06-04-074145_add_client_secret_in_payouts/down.sql @@ -0,0 +1 @@ +ALTER TABLE payouts DROP COLUMN IF EXISTS client_secret; \ No newline at end of file diff --git a/migrations/2024-06-04-074145_add_client_secret_in_payouts/up.sql b/migrations/2024-06-04-074145_add_client_secret_in_payouts/up.sql new file mode 100644 index 000000000000..e000e6e454f6 --- /dev/null +++ b/migrations/2024-06-04-074145_add_client_secret_in_payouts/up.sql @@ -0,0 +1,4 @@ +-- Your SQL goes here +ALTER TABLE payouts ADD COLUMN IF NOT EXISTS client_secret VARCHAR(128) DEFAULT NULL; + +ALTER TYPE "PayoutStatus" ADD VALUE IF NOT EXISTS 'requires_confirmation'; \ No newline at end of file