Bug: [FEATURE] Add refunds update flow in v2 apis



Add refunds update flow in v2 apis
diff --git a/api-reference-v2/openapi_spec.json b/api-reference-v2/openapi_spec.json
index c6b6f01fb0a..12c30cc61f0 100644
--- a/api-reference-v2/openapi_spec.json
+++ b/api-reference-v2/openapi_spec.json
@@ -3265,6 +3265,64 @@
         ]
       }
     },
+    "/v2/refunds/{id}/update_metadata": {
+      "put": {
+        "tags": [
+          "Refunds"
+        ],
+        "summary": "Refunds - Metadata Update",
+        "description": "Updates the properties of a Refund object. This API can be used to attach a reason for the refund or metadata fields",
+        "operationId": "Update Refund Metadata and Reason",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "description": "The identifier for refund",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/RefundMetadataUpdateRequest"
+              },
+              "examples": {
+                "Update refund reason": {
+                  "value": {
+                    "reason": "Paid by mistake"
+                  }
+                }
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "Refund updated",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/RefundResponse"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Missing Mandatory fields"
+          }
+        },
+        "security": [
+          {
+            "api_key": []
+          }
+        ]
+      }
+    },
     "/v2/refunds/{id}": {
       "get": {
         "tags": [
@@ -20629,6 +20687,24 @@
           }
         }
       },
+      "RefundMetadataUpdateRequest": {
+        "type": "object",
+        "properties": {
+          "reason": {
+            "type": "string",
+            "description": "An arbitrary string attached to the object. Often useful for displaying to users and your customer support executive",
+            "example": "Customer returned the product",
+            "nullable": true,
+            "maxLength": 255
+          },
+          "metadata": {
+            "type": "object",
+            "description": "You can specify up to 50 keys, with key names up to 40 characters long and values up to 500 characters long. Metadata is useful for storing additional, structured information on an object.",
+            "nullable": true
+          }
+        },
+        "additionalProperties": false
+      },
       "RefundResponse": {
         "type": "object",
         "required": [
@@ -20737,24 +20813,6 @@
           "instant"
         ]
       },
-      "RefundUpdateRequest": {
-        "type": "object",
-        "properties": {
-          "reason": {
-            "type": "string",
-            "description": "An arbitrary string attached to the object. Often useful for displaying to users and your customer support executive",
-            "example": "Customer returned the product",
-            "nullable": true,
-            "maxLength": 255
-          },
-          "metadata": {
-            "type": "object",
-            "description": "You can specify up to 50 keys, with key names up to 40 characters long and values up to 500 characters long. Metadata is useful for storing additional, structured information on an object.",
-            "nullable": true
-          }
-        },
-        "additionalProperties": false
-      },
       "RefundsCreateRequest": {
         "type": "object",
         "required": [
diff --git a/crates/api_models/src/refunds.rs b/crates/api_models/src/refunds.rs
index bb20978e0d2..e556c7eccce 100644
--- a/crates/api_models/src/refunds.rs
+++ b/crates/api_models/src/refunds.rs
@@ -166,6 +166,19 @@ pub struct RefundUpdateRequest {
     pub metadata: Option<pii::SecretSerdeValue>,
 }
 
+#[cfg(all(feature = "v2", feature = "refunds_v2"))]
+#[derive(Default, Debug, ToSchema, Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct RefundMetadataUpdateRequest {
+    /// An arbitrary string attached to the object. Often useful for displaying to users and your customer support executive
+    #[schema(max_length = 255, example = "Customer returned the product")]
+    pub reason: Option<String>,
+
+    /// You can specify up to 50 keys, with key names up to 40 characters long and values up to 500 characters long. Metadata is useful for storing additional, structured information on an object.
+    #[schema(value_type  = Option<Object>, example = r#"{ "city": "NY", "unit": "245" }"#)]
+    pub metadata: Option<pii::SecretSerdeValue>,
+}
+
 #[derive(Default, Debug, ToSchema, Clone, Deserialize, Serialize)]
 #[serde(deny_unknown_fields)]
 pub struct RefundManualUpdateRequest {
diff --git a/crates/openapi/src/openapi_v2.rs b/crates/openapi/src/openapi_v2.rs
index 95b6c3d7df9..6057797e200 100644
--- a/crates/openapi/src/openapi_v2.rs
+++ b/crates/openapi/src/openapi_v2.rs
@@ -148,6 +148,7 @@ Never share your secret api keys. Keep them guarded and secure.
 
         //Routes for refunds
         routes::refunds::refunds_create,
+        routes::refunds::refunds_metadata_update,
         routes::refunds::refunds_retrieve,
         routes::refunds::refunds_list,
 
@@ -193,7 +194,7 @@ Never share your secret api keys. Keep them guarded and secure.
         api_models::refunds::RefundType,
         api_models::refunds::RefundResponse,
         api_models::refunds::RefundStatus,
-        api_models::refunds::RefundUpdateRequest,
+        api_models::refunds::RefundMetadataUpdateRequest,
         api_models::organization::OrganizationCreateRequest,
         api_models::organization::OrganizationUpdateRequest,
         api_models::organization::OrganizationResponse,
diff --git a/crates/openapi/src/routes/refunds.rs b/crates/openapi/src/routes/refunds.rs
index 8fb6d4f1835..6628d432203 100644
--- a/crates/openapi/src/routes/refunds.rs
+++ b/crates/openapi/src/routes/refunds.rs
@@ -112,6 +112,7 @@ pub async fn refunds_retrieve_with_body() {}
     operation_id = "Update a Refund",
     security(("api_key" = []))
 )]
+#[cfg(feature = "v1")]
 pub async fn refunds_update() {}
 
 /// Refunds - List
@@ -215,6 +216,38 @@ pub async fn refunds_filter_list() {}
 #[cfg(feature = "v2")]
 pub async fn refunds_create() {}
 
+/// Refunds - Metadata Update
+///
+/// Updates the properties of a Refund object. This API can be used to attach a reason for the refund or metadata fields
+#[utoipa::path(
+    put,
+    path = "/v2/refunds/{id}/update_metadata",
+    params(
+        ("id" = String, Path, description = "The identifier for refund")
+    ),
+    request_body(
+        content = RefundMetadataUpdateRequest,
+        examples(
+            (
+                "Update refund reason" = (
+                    value = json!({
+                        "reason": "Paid by mistake"
+                      })
+                )
+            )
+        )
+    ),
+    responses(
+        (status = 200, description = "Refund updated", body = RefundResponse),
+        (status = 400, description = "Missing Mandatory fields")
+    ),
+    tag = "Refunds",
+    operation_id = "Update Refund Metadata and Reason",
+    security(("api_key" = []))
+)]
+#[cfg(feature = "v2")]
+pub async fn refunds_metadata_update() {}
+
 /// Refunds - Retrieve
 ///
 /// Retrieves a Refund. This may be used to get the status of a previously initiated refund
diff --git a/crates/router/src/core/refunds_v2.rs b/crates/router/src/core/refunds_v2.rs
index 8d113d5a329..ee10e1b3081 100644
--- a/crates/router/src/core/refunds_v2.rs
+++ b/crates/router/src/core/refunds_v2.rs
@@ -465,6 +465,42 @@ where
     request.check_integrity(request, connector_refund_id.to_owned())
 }
 
+// ********************************************** REFUND UPDATE **********************************************
+
+pub async fn refund_metadata_update_core(
+    state: SessionState,
+    merchant_account: domain::MerchantAccount,
+    req: refunds::RefundMetadataUpdateRequest,
+    global_refund_id: id_type::GlobalRefundId,
+) -> errors::RouterResponse<refunds::RefundResponse> {
+    let db = state.store.as_ref();
+    let refund = db
+        .find_refund_by_id(&global_refund_id, merchant_account.storage_scheme)
+        .await
+        .to_not_found_response(errors::ApiErrorResponse::RefundNotFound)?;
+
+    let response = db
+        .update_refund(
+            refund,
+            storage::RefundUpdate::MetadataAndReasonUpdate {
+                metadata: req.metadata,
+                reason: req.reason,
+                updated_by: merchant_account.storage_scheme.to_string(),
+            },
+            merchant_account.storage_scheme,
+        )
+        .await
+        .change_context(errors::ApiErrorResponse::InternalServerError)
+        .attach_printable_lazy(|| {
+            format!(
+                "Unable to update refund with refund_id: {}",
+                global_refund_id.get_string_repr()
+            )
+        })?;
+
+    refunds::RefundResponse::foreign_try_from(response).map(services::ApplicationResponse::Json)
+}
+
 // ********************************************** REFUND SYNC **********************************************
 
 #[instrument(skip_all)]
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index db65dff64b5..9cb83192f6f 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1187,7 +1187,11 @@ impl Refunds {
         {
             route = route
                 .service(web::resource("").route(web::post().to(refunds::refunds_create)))
-                .service(web::resource("/{id}").route(web::get().to(refunds::refunds_retrieve)));
+                .service(web::resource("/{id}").route(web::get().to(refunds::refunds_retrieve)))
+                .service(
+                    web::resource("/{id}/update_metadata")
+                        .route(web::put().to(refunds::refunds_metadata_update)),
+                );
         }
 
         route
diff --git a/crates/router/src/routes/refunds.rs b/crates/router/src/routes/refunds.rs
index dc92545841e..1ae4412807c 100644
--- a/crates/router/src/routes/refunds.rs
+++ b/crates/router/src/routes/refunds.rs
@@ -12,6 +12,39 @@ use crate::{
     services::{api, authentication as auth, authorization::permissions::Permission},
     types::{api::refunds, domain},
 };
+
+#[cfg(feature = "v2")]
+/// A private module to hold internal types to be used in route handlers.
+/// This is because we will need to implement certain traits on these types which will have the resource id
+/// But the api payload will not contain the resource id
+/// So these types can hold the resource id along with actual api payload, on which api event and locking action traits can be implemented
+mod internal_payload_types {
+    use super::*;
+
+    // Serialize is implemented because of api events
+    #[derive(Debug, serde::Serialize)]
+    pub struct RefundsGenericRequestWithResourceId<T: serde::Serialize> {
+        pub global_refund_id: common_utils::id_type::GlobalRefundId,
+        pub payment_id: Option<common_utils::id_type::GlobalPaymentId>,
+        #[serde(flatten)]
+        pub payload: T,
+    }
+
+    impl<T: serde::Serialize> common_utils::events::ApiEventMetric
+        for RefundsGenericRequestWithResourceId<T>
+    {
+        fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> {
+            let refund_id = self.global_refund_id.clone();
+            self.payment_id
+                .clone()
+                .map(|payment_id| common_utils::events::ApiEventsType::Refund {
+                    payment_id,
+                    refund_id,
+                })
+        }
+    }
+}
+
 /// Refunds - Create
 ///
 /// To create a refund against an already processed payment
@@ -323,6 +356,45 @@ pub async fn refunds_update(
     .await
 }
 
+#[cfg(all(feature = "v2", feature = "refunds_v2"))]
+#[instrument(skip_all, fields(flow = ?Flow::RefundsUpdate))]
+pub async fn refunds_metadata_update(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    json_payload: web::Json<refunds::RefundMetadataUpdateRequest>,
+    path: web::Path<common_utils::id_type::GlobalRefundId>,
+) -> HttpResponse {
+    let flow = Flow::RefundsUpdate;
+
+    let global_refund_id = path.into_inner();
+    let internal_payload = internal_payload_types::RefundsGenericRequestWithResourceId {
+        global_refund_id: global_refund_id.clone(),
+        payment_id: None,
+        payload: json_payload.into_inner(),
+    };
+
+    Box::pin(api::server_wrap(
+        flow,
+        state,
+        &req,
+        internal_payload,
+        |state, auth: auth::AuthenticationData, req, _| {
+            refund_metadata_update_core(
+                state,
+                auth.merchant_account,
+                req.payload,
+                global_refund_id.clone(),
+            )
+        },
+        &auth::V2ApiKeyAuth {
+            is_connected_allowed: false,
+            is_platform_allowed: false,
+        },
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 #[cfg(all(
     any(feature = "v1", feature = "v2"),
     not(feature = "refunds_v2"),
diff --git a/crates/router/src/types/api/refunds.rs b/crates/router/src/types/api/refunds.rs
index 935cb14c1f7..908d8cddaf4 100644
--- a/crates/router/src/types/api/refunds.rs
+++ b/crates/router/src/types/api/refunds.rs
@@ -1,11 +1,11 @@
 #[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
 pub use api_models::refunds::RefundRequest;
-#[cfg(all(feature = "v2", feature = "refunds_v2"))]
-pub use api_models::refunds::RefundsCreateRequest;
 pub use api_models::refunds::{
     RefundListRequest, RefundListResponse, RefundResponse, RefundStatus, RefundType,
     RefundUpdateRequest, RefundsRetrieveBody, RefundsRetrieveRequest,
 };
+#[cfg(all(feature = "v2", feature = "refunds_v2"))]
+pub use api_models::refunds::{RefundMetadataUpdateRequest, RefundsCreateRequest};
 pub use hyperswitch_domain_models::router_flow_types::refunds::{Execute, RSync};
 pub use hyperswitch_interfaces::api::refunds::{Refund, RefundExecute, RefundSync};
 

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
<!-- Describe your changes in detail -->
This PR contains refunds update flow for v2 apis. It is a part of v2 implementation of refunds.

### Additional Changes

- [x] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->
Merging this pr will add refunds update flow to the hyperswitch v2 apis, merchants can update refunds with v2 apis.

## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
- Create Refund API Call
```
curl --location 'http://localhost:8080/v2/refunds' \
--header 'X-Profile-Id: pro_AL59Mhfn1eDbm5FtUXao' \
--header 'Content-Type: application/json' \
--header 'Authorization: api-key=dev_t3GJeRLga4fvpMYfeA26gnjD5jwPWUFA9gbGdUfRwujMbWd54GDi5YjhcbmL5WiQ' \
--data '{
    "payment_id":"12345_pay_01966ca5ba0f781394e31ca377c045c1",
    "merchant_reference_id":"1745579609",
    "reason":"Paid by mistake",
    "metadata":{
        "foo":"bar"
    }
}'
```
- Response from the above call
```
{
    "id": "12345_ref_01966ca5cdd87781af1426c620531fb5",
    "payment_id": "12345_pay_01966ca5ba0f781394e31ca377c045c1",
    "merchant_reference_id": "1745579528",
    "amount": 100,
    "currency": "USD",
    "status": "succeeded",
    "reason": "Paid by mistake",
    "metadata": {
        "foo": "bar"
    },
    "error_details": {
        "code": "",
        "message": ""
    },
    "created_at": "2025-04-25T11:12:07.651Z",
    "updated_at": "2025-04-25T11:12:08.717Z",
    "connector": "stripe",
    "profile_id": "pro_AL59Mhfn1eDbm5FtUXao",
    "merchant_connector_id": "mca_rAbIwlE7su9DkFILFzSO",
    "connector_refund_reference_id": null
}
```
- Update Refund API Call
```
curl --location --request PUT 'http://localhost:8080/v2/refunds/12345_ref_01966ca5cdd87781af1426c620531fb5/update_metadata' \
--header 'x-profile-id: pro_AL59Mhfn1eDbm5FtUXao' \
--header 'Content-Type: application/json' \
--header 'Authorization: api-key=dev_t3GJeRLga4fvpMYfeA26gnjD5jwPWUFA9gbGdUfRwujMbWd54GDi5YjhcbmL5WiQ' \
--data '{
    "reason":"Customer asked for refund",
    "metadata": {
        "customer": "Mr bean from IIT Dholakpur"
    }
}'
```
- Response from the above call
```
{
    "id": "12345_ref_01966ca5cdd87781af1426c620531fb5",
    "payment_id": "12345_pay_01966ca5ba0f781394e31ca377c045c1",
    "merchant_reference_id": "1745579528",
    "amount": 100,
    "currency": "USD",
    "status": "succeeded",
    "reason": "Customer asked for refund",
    "metadata": {
        "customer": "Mr bean from IIT Dholakpur"
    },
    "error_details": {
        "code": "",
        "message": ""
    },
    "created_at": "2025-04-25T11:12:07.651Z",
    "updated_at": "2025-04-25T11:12:10.581Z",
    "connector": "stripe",
    "profile_id": "pro_AL59Mhfn1eDbm5FtUXao",
    "merchant_connector_id": "mca_rAbIwlE7su9DkFILFzSO",
    "connector_refund_reference_id": null
}
```

## Checklist
<!-- Put an `x` in the boxes that apply -->

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