WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Conversation

@bsayak03
Copy link
Contributor

@bsayak03 bsayak03 commented Dec 3, 2025

Type of Change

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

Description

In this PR, we have added a state_metadata column in payment_intent table which stores the state of the total_refunded_amount and total_disputed_amount for that particular payment_intent. The goal is to add a validation check whenever refunds are being attempted after a chargeback has already occured.

In cases where refund occurs first and then chargeback we cannot stop this event from happening but to flag these cases out in the dashboard, we have added a query param expand_all which can be set to true for the Dispute Sync API. Once this is called from the FE, BE will send the total_refunded_amount and total_disputed_amount Dispute Sync which will allow the dashboard to flag such cases.

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?

Case 1: Chargeback happens and then Refund

Send a chargeback webhook to HS Server with the help of Novalnet Webhook Simulator (you being the connector) :

Step 1: Make a SEPA Payment Create + Confirm

cURL :

curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_vmH2ODTMsgGhaMjFxgdSPihDLrO1Mb0TV6fFX6qChSIYPIHmR95cibcE9unQhmwG' \
--data-raw '{
    "amount": 1000,
    "currency": "EUR",
    "confirm": true,
    "payment_link": false,
    "capture_method": "automatic",
    "capture_on": "2022-09-10T10:11:12Z",
    "amount_to_capture": 1000,
    "customer_id": "StripeCustomer",
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "999999999",
    "phone_country_code": "+1",
    "description": "Its my first payment request",
    "authentication_type": "three_ds",
    "return_url": "https://google.com",
    "payment_method": "bank_debit",
    "payment_method_type": "sepa",
    "payment_method_data": {
        "bank_debit": {
            "sepa_bank_debit": {
                "iban": "DE24300209002411761956",
                "bank_account_holder_name": "Joseph Doe"
            }
        }
    },
    "billing": {
        "address": {
            "line1": "1467",
            "line2": "CA",
            "line3": "CA",
            "city": "Musterhausen",
            "state": "California",
            "zip": "12345",
            "country": "DE",
            "first_name": "Max",
            "last_name": "Mustermann"
        },
        "email": "[email protected]",
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        }
    },
    "shipping": {
        "address": {
            "line1": "Musterstr",
            "line2": "CA",
            "line3": "CA",
            "city": "Musterhausen",
            "state": "California",
            "zip": "94122",
            "country": "DE",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        }
    },
    "browser_info": {
        "user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/70.0.3538.110 Safari\/537.36",
        "accept_header": "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/webp,image\/apng,\/;q=0.8",
        "language": "nl-NL",
        "color_depth": 24,
        "ip_address": "103.77.139.95",
        "screen_height": 723,
        "screen_width": 1536,
        "time_zone": 0,
        "java_enabled": true,
        "java_script_enabled": true
    },
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}'

Response :

{
    "payment_id": "pay_DFz8lo6XhewftJXAxnao",
    "merchant_id": "merchant_1764789228",
    "status": "succeeded",
    "amount": 1000,
    "net_amount": 1000,
    "shipping_cost": null,
    "amount_capturable": 0,
    "amount_received": 1000,
    "connector": "novalnet",
    "client_secret": "pay_DFz8lo6XhewftJXAxnao_secret_lerSGNPwm12EFYMk9S07",
    "created": "2025-12-03T20:17:20.978Z",
    "currency": "EUR",
    "customer_id": "StripeCustomer",
    "customer": {
        "id": "StripeCustomer",
        "name": "John Doe",
        "email": "[email protected]",
        "phone": "999999999",
        "phone_country_code": "+1"
    },
    "description": "Its my first payment request",
    "refunds": null,
    "disputes": null,
    "mandate_id": null,
    "mandate_data": null,
    "setup_future_usage": null,
    "off_session": null,
    "capture_on": null,
    "capture_method": "automatic",
    "payment_method": "bank_debit",
    "payment_method_data": {
        "bank_debit": {
            "sepa": {
                "iban": "DE243************61956",
                "bank_account_holder_name": "Joseph Doe"
            }
        },
        "billing": null
    },
    "payment_token": null,
    "shipping": {
        "address": {
            "city": "Musterhausen",
            "country": "DE",
            "line1": "Musterstr",
            "line2": "CA",
            "line3": "CA",
            "zip": "94122",
            "state": "California",
            "first_name": "joseph",
            "last_name": "Doe",
            "origin_zip": null
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": null
    },
    "billing": {
        "address": {
            "city": "Musterhausen",
            "country": "DE",
            "line1": "1467",
            "line2": "CA",
            "line3": "CA",
            "zip": "12345",
            "state": "California",
            "first_name": "Max",
            "last_name": "Mustermann",
            "origin_zip": null
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "[email protected]"
    },
    "order_details": null,
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "999999999",
    "return_url": "https://google.com/",
    "authentication_type": "three_ds",
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "next_action": null,
    "cancellation_reason": null,
    "error_code": null,
    "error_message": null,
    "error_reason": null,
    "unified_code": null,
    "unified_message": null,
    "payment_experience": null,
    "payment_method_type": "sepa",
    "connector_label": null,
    "business_country": null,
    "business_label": "default",
    "business_sub_label": null,
    "allowed_payment_method_types": null,
    "ephemeral_key": {
        "customer_id": "StripeCustomer",
        "created_at": 1764793040,
        "expires": 1764796640,
        "secret": "epk_75f47f74560a48c8a820fd5f10710955"
    },
    "manual_retry_allowed": null,
    "connector_transaction_id": "15273900098406366",
    "frm_message": null,
    "metadata": {
        "udf1": "value1",
        "login_date": "2019-09-10T10:11:12Z",
        "new_customer": "true"
    },
    "connector_metadata": null,
    "feature_metadata": {
        "redirect_response": null,
        "search_tags": null,
        "apple_pay_recurring_details": null,
        "gateway_system": "direct"
    },
    "reference_id": "15273900098406366",
    "payment_link": null,
    "profile_id": "pro_rNXbTCvri3z1wDnDtg3A",
    "surcharge_details": null,
    "attempt_count": 1,
    "merchant_decision": null,
    "merchant_connector_id": "mca_3pkVGfpeeMzeBxUoAM2h",
    "incremental_authorization_allowed": false,
    "authorization_count": null,
    "incremental_authorizations": null,
    "external_authentication_details": null,
    "external_3ds_authentication_attempted": false,
    "expires_on": "2025-12-03T20:32:20.978Z",
    "fingerprint": null,
    "browser_info": {
        "language": "nl-NL",
        "time_zone": 0,
        "ip_address": "103.77.139.95",
        "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36",
        "color_depth": 24,
        "java_enabled": true,
        "screen_width": 1536,
        "accept_header": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8",
        "screen_height": 723,
        "java_script_enabled": true
    },
    "payment_channel": null,
    "payment_method_id": null,
    "network_transaction_id": null,
    "payment_method_status": null,
    "updated": "2025-12-03T20:17:22.396Z",
    "split_payments": null,
    "frm_metadata": null,
    "extended_authorization_applied": null,
    "extended_authorization_last_applied_at": null,
    "request_extended_authorization": null,
    "capture_before": null,
    "merchant_order_reference_id": null,
    "order_tax_amount": null,
    "connector_mandate_id": null,
    "card_discovery": null,
    "force_3ds_challenge": false,
    "force_3ds_challenge_trigger": false,
    "issuer_error_code": null,
    "issuer_error_message": null,
    "is_iframe_redirection_enabled": null,
    "whole_connector_response": null,
    "enable_partial_authorization": null,
    "enable_overcapture": null,
    "is_overcapture_enabled": null,
    "network_details": null,
    "is_stored_credential": null,
    "mit_category": null,
    "billing_descriptor": null,
    "tokenization": null,
    "partner_merchant_identifier_details": null
}

Step 2: Make a POST callback to HS server. Take Connector Transaction Id from Response and paste in Novalnet's webhook Simulator. Paste payment access key which is merchant secret :

cURL :

curl --location 'https://7cca5bee7f7a.ngrok-free.app/webhooks/merchant_1764789228/mca_3pkVGfpeeMzeBxUoAM2h' \
--header 'Content-Type: application/json' \
--data-raw '{
    "event": {
        "checksum": "f1f39c8287b6302fabe4cae1d3b0a6cd534ff1d1f4bc90ec5c45cd2c453c185d",
        "parent_tid": 15273900098406366,
        "tid": 12273900098406366,
        "type": "CHARGEBACK"
    },
    "result": {
        "status": "SUCCESS",
        "status_code": 100,
        "status_text": "Successful"
    },
    "transaction": {
        "amount": 990,
        "currency": "EUR",
        "order_no": "pay_DFz8lo6XhewftJXAxnao_1",
        "payment_type": "RETURN_DEBIT_SEPA",
        "reason": "Fraud",
        "status": "CONFIRMED",
        "status_code": 100,
        "test_mode": 1,
        "tid": 12273900098406366
    },
    "merchant": {
        "project": 6120,
        "project_name": "Developer Portal",
        "project_url": "https://developer.novalnet.de",
        "vendor": 4
    },
    "customer": {
        "billing": {
            "city": "Musterhausen",
            "country_code": "DE",
            "house_no": "1467",
            "street": "CA",
            "zip": "12345"
        },
        "birth_date": "1992-06-10",
        "customer_ip": "103.175.62.75",
        "email": "[email protected]",
        "first_name": "Max",
        "gender": "u",
        "last_name": "Mustermann",
        "mobile": "8056594427"
    }
}'

Check Intent and Dispute Table :

Step 3: Initiate a Partial Refund.

cURL :

curl --location 'http://localhost:8080/refunds' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ZTohyal9SQ3irvu8wMOCye51zdbTiVLQbQTFWUtPa7AqAwFk3w132OcAPuFDt4NK' \
--data '{
    "payment_id": "pay_K34sF7itgPr8NaLQA4gk",
    "amount": 9,
    "reason": "RETURN",
    "refund_type": "instant",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}
'

Response :

{
    "refund_id": "ref_Gl41cePRfwNjJCcTmLQ5",
    "payment_id": "pay_K34sF7itgPr8NaLQA4gk",
    "amount": 9,
    "currency": "EUR",
    "status": "succeeded",
    "reason": "RETURN",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    },
    "error_message": null,
    "error_code": null,
    "unified_code": null,
    "unified_message": null,
    "created_at": "2025-12-05T06:06:34.644Z",
    "updated_at": "2025-12-05T06:06:36.810Z",
    "connector": "novalnet",
    "profile_id": "pro_pt4AieiyIuLxXBSDkEMv",
    "merchant_connector_id": "mca_VDmq2efBTEBh4CXxtxDN",
    "split_refunds": null,
    "issuer_error_code": null,
    "issuer_error_message": null,
    "raw_connector_response": null
}

Step 4: Intiate another Partial Refund (total disputed amount + previous refunds + new refund amount requested > amount captured => should throw error)

cURL :

curl --location 'http://localhost:8080/refunds' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ZTohyal9SQ3irvu8wMOCye51zdbTiVLQbQTFWUtPa7AqAwFk3w132OcAPuFDt4NK' \
--data '{
    "payment_id": "pay_K34sF7itgPr8NaLQA4gk",
    "amount": 2,
    "reason": "RETURN",
    "refund_type": "instant",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}
'

Response :

{
    "error": {
        "type": "invalid_request",
        "message": "amount contains invalid data. Expected format is refund amount must be less than total amount captured (1000) after considering disputed and refunded amounts",
        "code": "IR_05"
    }
}

Step 5: Intiate a Refund amount which is within the limits (total disputed amount + previous refunds + new refund amount requested <= amount captured => should go through)

cURL :

curl --location 'http://localhost:8080/refunds' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ZTohyal9SQ3irvu8wMOCye51zdbTiVLQbQTFWUtPa7AqAwFk3w132OcAPuFDt4NK' \
--data '{
    "payment_id": "pay_K34sF7itgPr8NaLQA4gk",
    "amount": 1,
    "reason": "RETURN",
    "refund_type": "instant",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}
'

Response :

{
    "refund_id": "ref_LX9Sg0TeIE10xs02m0y4",
    "payment_id": "pay_K34sF7itgPr8NaLQA4gk",
    "amount": 1,
    "currency": "EUR",
    "status": "succeeded",
    "reason": "RETURN",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    },
    "error_message": null,
    "error_code": null,
    "unified_code": null,
    "unified_message": null,
    "created_at": "2025-12-05T06:09:14.744Z",
    "updated_at": "2025-12-05T06:09:17.070Z",
    "connector": "novalnet",
    "profile_id": "pro_pt4AieiyIuLxXBSDkEMv",
    "merchant_connector_id": "mca_VDmq2efBTEBh4CXxtxDN",
    "split_refunds": null,
    "issuer_error_code": null,
    "issuer_error_message": null,
    "raw_connector_response": null
}

Case 2 : Refund happens then Chargeback (can't stop this but can definitely flag these cases in dashboard)

Step 1 : Make a SEPA Payment

cURL :

curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_vmH2ODTMsgGhaMjFxgdSPihDLrO1Mb0TV6fFX6qChSIYPIHmR95cibcE9unQhmwG' \
--data-raw '{
    "amount": 1000,
    
    "currency": "EUR",
    "confirm": true,
    "payment_link": false,
    "capture_method": "automatic",
    "capture_on": "2022-09-10T10:11:12Z",
    "amount_to_capture": 1000,
    "customer_id": "StripeCustomer",
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "999999999",
    "phone_country_code": "+1",
    "description": "Its my first payment request",
    "authentication_type": "three_ds",
    "return_url": "https://google.com",
    "payment_method": "bank_debit",
    "payment_method_type": "sepa",
    "payment_method_data": {
        "bank_debit": {
            "sepa_bank_debit": {
                "iban": "DE24300209002411761956",
                "bank_account_holder_name": "Joseph Doe"
            }
        }
    },
    "billing": {
        "address": {
            "line1": "1467",
            "line2": "CA",
            "line3": "CA",
            "city": "Musterhausen",
            "state": "California",
            "zip": "12345",
            "country": "DE",
            "first_name": "Max",
            "last_name": "Mustermann"
        },
        "email": "[email protected]",
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        }
    },
    "shipping": {
        "address": {
            "line1": "Musterstr",
            "line2": "CA",
            "line3": "CA",
            "city": "Musterhausen",
            "state": "California",
            "zip": "94122",
            "country": "DE",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        }
    },
    "browser_info": {
        "user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/70.0.3538.110 Safari\/537.36",
        "accept_header": "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/webp,image\/apng,\/;q=0.8",
        "language": "nl-NL",
        "color_depth": 24,
        "ip_address": "103.77.139.95",
        "screen_height": 723,
        "screen_width": 1536,
        "time_zone": 0,
        "java_enabled": true,
        "java_script_enabled": true
    },
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}'

Response:

{
    "payment_id": "pay_AtSuCdeivcc49VZRkUyg",
    "merchant_id": "merchant_1764789228",
    "status": "succeeded",
    "amount": 1000,
    "net_amount": 1000,
    "shipping_cost": null,
    "amount_capturable": 0,
    "amount_received": 1000,
    "connector": "novalnet",
    "client_secret": "pay_AtSuCdeivcc49VZRkUyg_secret_GUoU3egUlk0tdfvUPkI6",
    "created": "2025-12-03T20:24:51.694Z",
    "currency": "EUR",
    "customer_id": "StripeCustomer",
    "customer": {
        "id": "StripeCustomer",
        "name": "John Doe",
        "email": "[email protected]",
        "phone": "999999999",
        "phone_country_code": "+1"
    },
    "description": "Its my first payment request",
    "refunds": null,
    "disputes": null,
    "mandate_id": null,
    "mandate_data": null,
    "setup_future_usage": null,
    "off_session": null,
    "capture_on": null,
    "capture_method": "automatic",
    "payment_method": "bank_debit",
    "payment_method_data": {
        "bank_debit": {
            "sepa": {
                "iban": "DE243************61956",
                "bank_account_holder_name": "Joseph Doe"
            }
        },
        "billing": null
    },
    "payment_token": null,
    "shipping": {
        "address": {
            "city": "Musterhausen",
            "country": "DE",
            "line1": "Musterstr",
            "line2": "CA",
            "line3": "CA",
            "zip": "94122",
            "state": "California",
            "first_name": "joseph",
            "last_name": "Doe",
            "origin_zip": null
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": null
    },
    "billing": {
        "address": {
            "city": "Musterhausen",
            "country": "DE",
            "line1": "1467",
            "line2": "CA",
            "line3": "CA",
            "zip": "12345",
            "state": "California",
            "first_name": "Max",
            "last_name": "Mustermann",
            "origin_zip": null
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "[email protected]"
    },
    "order_details": null,
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "999999999",
    "return_url": "https://google.com/",
    "authentication_type": "three_ds",
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "next_action": null,
    "cancellation_reason": null,
    "error_code": null,
    "error_message": null,
    "error_reason": null,
    "unified_code": null,
    "unified_message": null,
    "payment_experience": null,
    "payment_method_type": "sepa",
    "connector_label": null,
    "business_country": null,
    "business_label": "default",
    "business_sub_label": null,
    "allowed_payment_method_types": null,
    "ephemeral_key": {
        "customer_id": "StripeCustomer",
        "created_at": 1764793491,
        "expires": 1764797091,
        "secret": "epk_32af190e47cf401c85e1d5ce610f139a"
    },
    "manual_retry_allowed": null,
    "connector_transaction_id": "15273900098624002",
    "frm_message": null,
    "metadata": {
        "udf1": "value1",
        "login_date": "2019-09-10T10:11:12Z",
        "new_customer": "true"
    },
    "connector_metadata": null,
    "feature_metadata": {
        "redirect_response": null,
        "search_tags": null,
        "apple_pay_recurring_details": null,
        "gateway_system": "direct"
    },
    "reference_id": "15273900098624002",
    "payment_link": null,
    "profile_id": "pro_rNXbTCvri3z1wDnDtg3A",
    "surcharge_details": null,
    "attempt_count": 1,
    "merchant_decision": null,
    "merchant_connector_id": "mca_3pkVGfpeeMzeBxUoAM2h",
    "incremental_authorization_allowed": false,
    "authorization_count": null,
    "incremental_authorizations": null,
    "external_authentication_details": null,
    "external_3ds_authentication_attempted": false,
    "expires_on": "2025-12-03T20:39:51.694Z",
    "fingerprint": null,
    "browser_info": {
        "language": "nl-NL",
        "time_zone": 0,
        "ip_address": "103.77.139.95",
        "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36",
        "color_depth": 24,
        "java_enabled": true,
        "screen_width": 1536,
        "accept_header": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8",
        "screen_height": 723,
        "java_script_enabled": true
    },
    "payment_channel": null,
    "payment_method_id": null,
    "network_transaction_id": null,
    "payment_method_status": null,
    "updated": "2025-12-03T20:24:53.072Z",
    "split_payments": null,
    "frm_metadata": null,
    "extended_authorization_applied": null,
    "extended_authorization_last_applied_at": null,
    "request_extended_authorization": null,
    "capture_before": null,
    "merchant_order_reference_id": null,
    "order_tax_amount": null,
    "connector_mandate_id": null,
    "card_discovery": null,
    "force_3ds_challenge": false,
    "force_3ds_challenge_trigger": false,
    "issuer_error_code": null,
    "issuer_error_message": null,
    "is_iframe_redirection_enabled": null,
    "whole_connector_response": null,
    "enable_partial_authorization": null,
    "enable_overcapture": null,
    "is_overcapture_enabled": null,
    "network_details": null,
    "is_stored_credential": null,
    "mit_category": null,
    "billing_descriptor": null,
    "tokenization": null,
    "partner_merchant_identifier_details": null
}

Step 2 : Make a Refund happen
Note: Refund will not get succeeded directly in test env. Need to submit connector tx id to Novalnet support team before making a refund. They will do some adjustments in their system upon which a refund can be triggered.

cURL :

curl --location 'http://localhost:8080/refunds' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_vmH2ODTMsgGhaMjFxgdSPihDLrO1Mb0TV6fFX6qChSIYPIHmR95cibcE9unQhmwG' \
--data '{
    "payment_id": "pay_AtSuCdeivcc49VZRkUyg",
    "amount": 1000,
    "reason": "RETURN",
    "refund_type": "instant",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}
'

Response :

{
    "refund_id": "ref_9GjyMtyAArpNahlhlrHb",
    "payment_id": "pay_AtSuCdeivcc49VZRkUyg",
    "amount": 1000,
    "currency": "EUR",
    "status": "succeeded",
    "reason": "RETURN",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    },
    "error_message": null,
    "error_code": null,
    "unified_code": null,
    "unified_message": null,
    "created_at": "2025-12-03T20:25:40.613Z",
    "updated_at": "2025-12-03T20:25:42.761Z",
    "connector": "novalnet",
    "profile_id": "pro_rNXbTCvri3z1wDnDtg3A",
    "merchant_connector_id": "mca_3pkVGfpeeMzeBxUoAM2h",
    "split_refunds": null,
    "issuer_error_code": null,
    "issuer_error_message": null,
    "raw_connector_response": null
}

Step 3: Make a POST callback to HS server for Chargeback

cURL :

curl --location 'https://7cca5bee7f7a.ngrok-free.app/webhooks/merchant_1764789228/mca_3pkVGfpeeMzeBxUoAM2h' \
--header 'Content-Type: application/json' \
--data-raw '{
    "event": {
        "checksum": "ccf381f7de734f3f072ec7343be7b0e57620df8a099da54ec3874401b471d1e6",
        "parent_tid": 15273900098624002,
        "tid": 12273900098624002,
        "type": "CHARGEBACK"
    },
    "result": {
        "status": "FAILURE",
        "status_code": 100,
        "status_text": "Successful"
    },
    "transaction": {
        "amount": 0,
        "currency": "EUR",
        "order_no": "pay_AtSuCdeivcc49VZRkUyg_1",
        "payment_type": "RETURN_DEBIT_SEPA",
        "reason": "Fraud",
        "status": "CONFIRMED",
        "status_code": 100,
        "test_mode": 1,
        "tid": 12273900098624002
    },
    "merchant": {
        "project": 6120,
        "project_name": "Developer Portal",
        "project_url": "https://developer.novalnet.de",
        "vendor": 4
    },
    "customer": {
        "billing": {
            "city": "Musterhausen",
            "country_code": "DE",
            "house_no": "1467",
            "street": "CA",
            "zip": "12345"
        },
        "birth_date": "1992-06-10",
        "customer_ip": "103.175.62.75",
        "email": "[email protected]",
        "first_name": "Max",
        "gender": "u",
        "last_name": "Mustermann",
        "mobile": "8056594427"
    }
}'

Step 4 : Make a Dispute Sync API call with expand_all query param set to true.

cURL :

curl --location 'http://localhost:8080/disputes/dp_THQk76YZECZSpR3uSAr2?expand_all=true' \
--header 'Accept: application/json' \
--header 'api-key: dev_ZTohyal9SQ3irvu8wMOCye51zdbTiVLQbQTFWUtPa7AqAwFk3w132OcAPuFDt4NK' \
--data ''

Response :

{
    "dispute_id": "dp_THQk76YZECZSpR3uSAr2",
    "payment_id": "pay_Btz8r3YntoGwTY60EU8W",
    "attempt_id": "pay_Btz8r3YntoGwTY60EU8W_1",
    "amount": "1000",
    "currency": "EUR",
    "dispute_stage": "dispute",
    "dispute_status": "dispute_lost",
    "connector": "novalnet",
    "connector_status": "DisputeOpened",
    "connector_dispute_id": "12274100024912171",
    "connector_reason": "Fraud",
    "connector_reason_code": null,
    "challenge_required_by": null,
    "connector_created_at": null,
    "connector_updated_at": null,
    "created_at": "2025-12-05T06:12:39.547Z",
    "profile_id": "pro_pt4AieiyIuLxXBSDkEMv",
    "merchant_connector_id": "mca_VDmq2efBTEBh4CXxtxDN",
    "total_refunded_amount": 1000,
    "total_disputed_amount": 1000
}

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

@bsayak03 bsayak03 self-assigned this Dec 3, 2025
@bsayak03 bsayak03 requested review from a team as code owners December 3, 2025 20:14
@semanticdiff-com
Copy link

semanticdiff-com bot commented Dec 3, 2025

@hyperswitch-bot hyperswitch-bot bot added M-database-changes Metadata: This PR involves database schema changes M-api-contract-changes Metadata: This PR involves API contract changes labels Dec 3, 2025
@bsayak03 bsayak03 force-pushed the refund/chargeback/nn branch from 6e6a36b to bfbf4bf Compare December 4, 2025 06:41
@codecov
Copy link

codecov bot commented Dec 5, 2025

Codecov Report

❌ Patch coverage is 17.24138% with 24 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@eb6080f). Learn more about missing BASE report.

Files with missing lines Patch % Lines
crates/common_types/src/payments.rs 0.00% 8 Missing ⚠️
...witch_domain_models/src/payments/payment_intent.rs 41.66% 7 Missing ⚠️
crates/diesel_models/src/dispute.rs 0.00% 6 Missing ⚠️
crates/diesel_models/src/payment_intent.rs 0.00% 2 Missing ⚠️
crates/router/src/types/transformers.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #10533   +/-   ##
=======================================
  Coverage        ?    6.46%           
=======================================
  Files           ?     1251           
  Lines           ?   312647           
  Branches        ?        0           
=======================================
  Hits            ?    20210           
  Misses          ?   292437           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

pub merchant_connector_id: Option<common_utils::id_type::MerchantConnectorAccountId>,
/// Shows if the disputed amount is already refunded in the payment
#[serde(skip_serializing_if = "Option::is_none")]
pub is_already_refunded: Option<bool>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any usecase for this being null? We can try to avoid nullable case for booleans as much as possible

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, we can make it a req field

Eq,
AsExpression,
FromSqlRow,
utoipa::ToSchema,
Copy link
Contributor

Choose a reason for hiding this comment

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

We shouldn't be using utoipa in diesel_models. If you want to use this in API models, please move this to common_types or api_models. If not, please remove utoipa::ToSchema

thiserror = "1.0.69"
time = { version = "0.3.41", features = ["serde", "serde-well-known", "std"] }

utoipa = { version = "4.2.3", features = ["preserve_order", "preserve_path_order"] }
Copy link
Contributor

Choose a reason for hiding this comment

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

diesel_models shouldn't import utoipa

connector.id(),
)
.await?;
if (option_dispute.is_none()
Copy link
Contributor

Choose a reason for hiding this comment

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

Try to use a single .map with a impl on dispute object to derive the condition

let current_state = payment_intent.state_metadata.unwrap_or_default();

dispute_response.is_already_refunded = Some(
payment_intent
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 move all the validation logic into a single function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but dispute response validation and refund request validation are two different cases, they don't coincide with each other, i mean the same code isn't written twice.

}

#[cfg(feature = "v1")]
pub trait PaymentIntentStateMetadataExt {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this trait, because there's no polymorphism here. We always operate on defined type with defined behaviours. We can have impl based functions right, you may construct a struct to unify the behaviours into impls?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but i'm declaring my struct PaymentIntentStateMetadata in common_types crate and if i move these trait fn's inside common_types crates, then i have to import SessionState which depends on router crate. router crate and common_types is inter dependent on each other creating a cyclic dependency. Hence i have to keep these impl's outside of where i declare them. Hence would need a trait.

Copy link
Contributor

Choose a reason for hiding this comment

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

You can actually create a struct wrapping your type in router to have impls on it, traits can create unnecessary overhead in absence of polymorphism

impl PaymentIntentStateMetadataExt for diesel_models::types::PaymentIntentStateMetadata {
fn validate_refund_against_intent_state_metadata(
self,
refund: &api::RefundRequest,
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not advisable to operate on refund_request in payments core flow

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but we would need the requested refund amount in this fn. so instead of the whole object should we accept just refund amount?

Copy link
Contributor

Choose a reason for hiding this comment

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

You can get refund_amount as an input

utils::when(
total_disputed + total_refunded + requested > captured,
|| {
Err(report!(errors::ApiErrorResponse::InvalidDataFormat {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not a InvalidDataFormat error right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changing to InvalidRequestData, that looks to be the most relevant one amongst the variants


let total_refunded_amount: i64 = all_refunds_for_payment
.iter()
.filter(|r| r.refund_status == common_enums::RefundStatus::Success)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can be a impl

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 already inside an impl right?

Copy link
Contributor

@sai-harsha-vardhan sai-harsha-vardhan left a comment

Choose a reason for hiding this comment

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

We would also need changes in refund core flows to update the intent state right? @bsayak03

)
.await?;
if diesel_models::dispute::Dispute::is_not_lost_or_none(&option_dispute)
&& dispute_object.dispute_status == common_enums::DisputeStatus::DisputeLost
Copy link
Contributor

Choose a reason for hiding this comment

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

This is already checked in is_not_lost_or_none right?

option
.as_ref()
.map(|d| d.dispute_status != common_enums::DisputeStatus::DisputeLost)
.unwrap_or(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

If it is None, we do not want to update the intent state right?

)
})?;

payment_intent
Copy link
Contributor

Choose a reason for hiding this comment

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

We may have to call this only when refund reaches success state right?

{
let current_state = payment_intent.state_metadata.unwrap_or_default();

dispute_response.is_already_refunded = payment_intent
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 move this validation logic to a function?

}

#[cfg(feature = "v1")]
pub trait PaymentIntentStateMetadataExt {
Copy link
Contributor

Choose a reason for hiding this comment

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

You can actually create a struct wrapping your type in router to have impls on it, traits can create unnecessary overhead in absence of polymorphism

impl PaymentIntentStateMetadataExt for diesel_models::types::PaymentIntentStateMetadata {
fn validate_refund_against_intent_state_metadata(
self,
refund: &api::RefundRequest,
Copy link
Contributor

Choose a reason for hiding this comment

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

You can get refund_amount as an input

// Block refund if total disputed amount + total refunded amount + requested refund amount > amount captured
if let Some(amount_captured) = payment_intent.amount_captured {
let captured = amount_captured.get_amount_as_i64();
let total_disputed = self
Copy link
Contributor

Choose a reason for hiding this comment

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

We can merge dispute and refund validations into a single function which can be impl on payment_intent
It takes, blocked amount as an input whether it's disputed or refunded, it can take requested_amount as one more paremeter. It validates it with the amount_captured value. Give a though on this to merge behaviours and reuse the functionality

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

M-api-contract-changes Metadata: This PR involves API contract changes M-database-changes Metadata: This PR involves database schema changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants