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 31.25000% with 11 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@fc9566e). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...witch_domain_models/src/payments/payment_intent.rs 41.66% 7 Missing ⚠️
crates/diesel_models/src/payment_intent.rs 0.00% 2 Missing ⚠️
crates/router/src/types/transformers.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #10533   +/-   ##
=======================================
  Coverage        ?    6.46%           
=======================================
  Files           ?     1251           
  Lines           ?   311812           
  Branches        ?        0           
=======================================
  Hits            ?    20169           
  Misses          ?   291643           
  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 up the total refunded amount for a payment
#[serde(skip_serializing_if = "Option::is_none")]
pub total_refunded_amount: Option<MinorUnit>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need to send total_refunded_amount in DisputeResponse?

/// Decider to enable or disable the connector call for dispute retrieve request
pub force_sync: Option<bool>,
/// If enabled provides refunded_amount and disputed_amount linked to the payment intent
pub expand_all: 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.

We shouldn't be having this ideally, because we don't want to show refunded_amount in dispute api

pub tokenization: Option<common_enums::Tokenization>,
pub partner_merchant_identifier_details:
Option<common_types::payments::PartnerMerchantIdentifierDetails>,
pub state_metadata: Option<serde_json::Value>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't we use strict types here?

#[diesel(sql_type = Json)]
pub struct PaymentIntentStateMetadata {
/// Shows up the total refunded amount for a payment
pub total_refunded_amount: Option<MinorUnit>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Shall we add refund_data, dispute_data as an object for extending this for future usecases?

pub tokenization: Option<common_enums::Tokenization>,
pub partner_merchant_identifier_details:
Option<common_types::payments::PartnerMerchantIdentifierDetails>,
pub state_metadata: Option<Value>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use strict types


// Update payment_intent for the refund's payment_id
// Calculate total_refunded_amount based on all succeeded refunds for that payment_id
let all_refunds_for_payment = db
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 have this logic in some central place where refunds core can also utilize this?

)
})?;

let mut current_state: diesel_models::types::PaymentIntentStateMetadata = 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.

please try to avoid mutable objects

platform.get_processor().get_account().storage_scheme,
)
.await
.change_context(subscriptions::errors::ApiErrorResponse::InternalServerError)?;
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 an attach_printable

)?;

// Block refund if amount exceeds total disputed amount or total captured amount
if let Some(state_metadata_value) = &payment_intent.state_metadata {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please move all of this validations to fns, preferably impl based

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