Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Create Payout Webhook Flow #4696

Merged
merged 26 commits into from
Jun 5, 2024
Merged

feat(core): Create Payout Webhook Flow #4696

merged 26 commits into from
Jun 5, 2024

Conversation

Sakilmostak
Copy link
Contributor

@Sakilmostak Sakilmostak commented May 19, 2024

Type of Change

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

Description

Create core flow for processing payout webhooks and send respective outgoing webhook

Additional Changes

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

Motivation and Context

How did you test it?

Tested through Postman:

Craete an MCA(Adyen Payout):

{
    "connector_type": "payout_processor",
    "connector_name": "adyen",
    "connector_account_details": {
        "auth_type": "SignatureKey",
        "api_key": "{{api_key}}",
        "key1": "{{key1}}",
        "api_secret": "{{api_secret}}"
    },
    "test_mode": false,
    "disabled": false,
    "payment_methods_enabled": [
        {
            "payment_method": "card",
            "payment_method_types": [
                {
                    "payment_method_type": "credit",
                    "card_networks": [
                        "Visa",
                        "Mastercard"
                    ],
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                },
                {
                    "payment_method_type": "debit",
                    "card_networks": [
                        "Visa",
                        "Mastercard"
                    ],
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                }
            ]
        },
        {
            "payment_method": "bank_transfer",
            "payment_method_types": [
                {
                    "payment_method_type": "sepa",
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                },
                {
                    "payment_method_type": "pix",
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                },
                {
                    "payment_method_type": "bacs",
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                }
            ]
        }
    ],
    "metadata": {
        "city": "NY",
        "unit": "245",
        "endpoint_prefix": ""
    },
    "business_country": "US",
    "business_label": "default"
}

Update the MCA with merchant_secret:

{
    "connector_type": "payout_processor",
    "connector_webhook_details": {
        "merchant_secret": "F56C44CD8BCDE3FF0D45DC09E0D494A352B7331EAD78149EB9D8187322BBF8DB"
    }
}

Update the Merchant Account with the outgoing webhook url:

{
    "merchant_id": "{{merchant_id}}",
    "webhook_details": {
        "webhook_version": "1.0.1",
        "webhook_username": "ekart_retail",
        "webhook_password": "password_ekart@123",
        "payment_created_enabled": true,
        "payment_succeeded_enabled": true,
        "payment_failed_enabled": true,
        "webhook_url": "https://webhook.site/c2c092a7-7f8a-45ec-9d83-efdcb3bf87e7"
    }
}

Set up the url at Connector's Dashboard:

Create a payout through Adyen:

{
    "amount": 100,
    "currency": "EUR",
    "customer_id": "{{customer_id}}",
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "999999999",
    "phone_country_code": "+65",
    "description": "Its my first payout request",
    "payout_type": "bank",
    "payout_method_data": {
        "bank": {
            "bic": "ABNANL2A",
            "iban": "NL46TEST0136169112",
            "bank_name": "Deutsche Bank",
            "bank_country_code": "NL",
            "bank_city": "Amsterdam",
            "tax_id": "12345"
        }
    },
    "billing": {
        "address": {
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "city": "San Fransico",
            "state": "NY",
            "zip": "94122",
            "country": "US",
            "first_name": "John",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        }
    },
    "entity_type": "Individual",
    "recurring": false,
    "metadata": {
        "ref": "123"
    },"routing": {
        "type": "single",
        "data": "adyen"
    },
    "confirm": true,
    "auto_fulfill": true
}

The webhook should be received at your defined endpoint with event type as payout_initiated

Checklist

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

@Sakilmostak Sakilmostak added A-core Area: Core flows C-feature Category: Feature request or enhancement labels May 19, 2024
@Sakilmostak Sakilmostak self-assigned this May 19, 2024
@Sakilmostak Sakilmostak requested review from a team as code owners May 19, 2024 16:09
pub fn is_payout_event(event_code: &WebhookEventCode) -> bool {
matches!(
event_code,
WebhookEventCode::PayoutThirdparty | WebhookEventCode::PayoutDecline
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we not handling PAYOUT_REVERSED and PAYOUT_EXPIRE?

ref - https://docs.adyen.com/online-payments/online-payouts/payout-webhook/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added support for them

Self::PayoutDetails(payout_payload) => Some(OutgoingWebhookEventContent::Payout {
payout_id: payout_payload.payout_id.clone(),
content: masking::masked_serialize(&payout_payload)
.unwrap_or(serde_json::json!({"error":"failed to serialize"})),
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this what we send to merchants if deserialization fails?

@@ -420,7 +420,7 @@ impl PayoutIndividualDetailsExt for api_models::payouts::PayoutIndividualDetails
#[derive(Clone, Debug, Default)]
pub struct PayoutsResponseData {
pub status: Option<storage_enums::PayoutStatus>,
pub connector_payout_id: String,
pub connector_payout_id: Option<String>,
Copy link
Contributor

Choose a reason for hiding this comment

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

TODO for later: make it non optional, and add new struct for connector integration if this field is not consumed in other flows

@@ -1107,6 +1108,10 @@ pub enum EventType {
DisputeLost,
MandateActive,
MandateRevoked,
PayoutSuccess,
Copy link
Contributor

Choose a reason for hiding this comment

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

Possible to add PayoutCreated? Trigger - when payout is created at connector's end.

Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we add feature flags here as well?

)
.await
}
MerchantStorageScheme::RedisKv => {
Copy link
Contributor

Choose a reason for hiding this comment

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

@dracarys18 can you review the reverse lookup bit?

@@ -68,6 +68,7 @@ pub enum EventObjectType {
RefundDetails,
DisputeDetails,
MandateDetails,
PayoutDetails,
Copy link
Contributor

Choose a reason for hiding this comment

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

feature flag?

@@ -301,7 +301,7 @@ impl<F> TryFrom<types::PayoutsResponseRouterData<F, EbanxFulfillResponse>>
Ok(Self {
response: Ok(types::PayoutsResponseData {
status: Some(storage_enums::PayoutStatus::from(item.response.status)),
connector_payout_id: item.data.request.get_transfer_id()?,
connector_payout_id: item.data.request.get_transfer_id().ok(),
Copy link
Contributor

Choose a reason for hiding this comment

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

why are we ignoring the error in this case?

@@ -1298,7 +1298,7 @@ pub async fn complete_create_payout(
let db = &*state.store;
let payout_attempt = &payout_data.payout_attempt;
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: "".to_string(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

can we map it to payout_data.payout_attempt.connector_payout_id instead to hardcoding to None

@@ -1248,7 +1248,7 @@ pub async fn check_payout_eligibility(
Err(err) => {
let status = storage_enums::PayoutStatus::Failed;
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: String::default(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

@@ -1440,7 +1440,7 @@ pub async fn create_payout(
Err(err) => {
let status = storage_enums::PayoutStatus::Failed;
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: String::default(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

@@ -1563,7 +1563,7 @@ pub async fn create_recipient_disburse_account(
}
Err(err) => {
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: String::default(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

@@ -1659,7 +1659,7 @@ pub async fn cancel_payout(
Err(err) => {
let status = storage_enums::PayoutStatus::Failed;
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: String::default(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

@@ -1799,7 +1799,7 @@ pub async fn fulfill_payout(
Err(err) => {
let status = storage_enums::PayoutStatus::Failed;
let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate {
connector_payout_id: String::default(),
connector_payout_id: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

business_profile,
&key_store,
outgoing_event_type,
enums::EventClass::Refunds,
Copy link
Contributor

Choose a reason for hiding this comment

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

This should not be enums::EventClass::Refunds but event_type

storage_enums::PayoutStatus::Failed => Some(storage_enums::EventType::PayoutFailed),
storage_enums::PayoutStatus::Cancelled => {
Some(storage_enums::EventType::PayoutCancelled)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

can we remove curly braces!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is done by formatter bro, everytime I remove it the formatter puts it back

}
storage_enums::PayoutStatus::Initiated => {
Some(storage_enums::EventType::PayoutInitiated)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

+1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

same with this


#[inline]
#[instrument(skip_all)]
async fn add_connector_payout_id_to_reverse_lookup<T: DatabaseStore>(
Copy link
Contributor

Choose a reason for hiding this comment

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

get these Kv changes reviewed by @dracarys18

@@ -292,6 +292,7 @@ impl ForeignTryFrom<(bool, AdyenWebhookStatus)> for storage_enums::AttemptStatus
AdyenWebhookStatus::CancelFailed => Ok(Self::VoidFailed),
AdyenWebhookStatus::Captured => Ok(Self::Charged),
AdyenWebhookStatus::CaptureFailed => Ok(Self::CaptureFailed),
AdyenWebhookStatus::Reversed => Ok(Self::AutoRefunded),
Copy link
Contributor

Choose a reason for hiding this comment

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

What does Reversed mean here, Is it a Voided payment? AutoRefunded occurs when we initiate a refund from Hyperswitch without merchant initiating it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed it, now it will through error for reversed in payment webhooks

metrics::INCOMING_PAYOUT_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]);
if source_verified {
let db = &*state.store;
//find refund by connector refund id
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add the relevant comment here

if source_verified {
let db = &*state.store;
//find refund by connector refund id
let payout_attempt = match webhook_details.object_reference_id {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we please move this to a fn?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will refactor this in next pr

@@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE payout_attempt ALTER COLUMN connector_payout_id DROP NOT NULL;
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add a query here to turn '' string to NULL

) -> StorageResult<Self> {
generics::generic_find_one::<<Self as HasTable>::Table, _, _>(
conn,
dsl::merchant_id
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we please create an index for merchant_id, connector_payout_id

.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("failed payout status mapping from event type")?,
error_message: None,
error_code: None,
Copy link
Contributor

Choose a reason for hiding this comment

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

We need to capture error_code, error_message at connector level and update them here

status: updated_payout_attempt.status,
})
} else {
metrics::INCOMING_PAYOUT_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Please capture merchant_id / connector here

@sai-harsha-vardhan
Copy link
Contributor

Please handle the payout connectors in fetch_optional_mca_and_connector of webhooks_core @Sakilmostak

Copy link
Contributor

@sahkal sahkal left a comment

Choose a reason for hiding this comment

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

Please address the comments by @sai-harsha-vardhan , other than that LGTM!

@Gnanasundari24 Gnanasundari24 added this pull request to the merge queue Jun 5, 2024
Merged via the queue into main with commit a3183a0 Jun 5, 2024
13 checks passed
@Gnanasundari24 Gnanasundari24 deleted the payouts_webhook branch June 5, 2024 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-core Area: Core flows C-feature Category: Feature request or enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants