Bug: [FEATURE] display DuitNow QR code in expected color



### Feature Description

DuitNow is a realtime payment method offered in Malaysia. This works using a QR code which is scanned for completing the payment transactions.

Displaying the QR code has guidelines mentioned in - https://docs.developer.paynet.my/branding/duitnow/DuitNow%20Brand.pdf

Currently, HyperSwitch displays a basic QR code (in black), without following any guidelines. This needs to be rendered correctly on the payment links.

- Rendering the QR code in required color (#ED2E67) requires modifying the QR code's image data sent by the connector
- Other UI refactors will be made in SDK

### Possible Implementation

Confirm API response sends back `qr_code_information` as next_action. This field to contain below items

- Modified QR data in specified color
- QR image border color
- QR image display text

### Have you spent some time checking if this feature request has been raised before?

- [x] I checked and didn't find a similar issue

### Have you read the Contributing Guidelines?

- [x] I have read the [Contributing Guidelines](https://github.com/juspay/hyperswitch/blob/main/docs/CONTRIBUTING.md)

### Are you willing to submit a PR?

Yes, I am willing to submit a PR!
diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs
index 59ea6e97fbb..12da310ac61 100644
--- a/crates/api_models/src/payments.rs
+++ b/crates/api_models/src/payments.rs
@@ -4406,6 +4406,8 @@ pub enum NextActionData {
         #[schema(value_type = String)]
         /// The url for Qr code given by the connector
         qr_code_url: Option<Url>,
+        display_text: Option<String>,
+        border_color: Option<String>,
     },
     /// Contains url to fetch Qr code data
     FetchQrCodeInformation {
@@ -4493,6 +4495,12 @@ pub enum QrCodeInformation {
         qr_code_url: Url,
         display_to_timestamp: Option<i64>,
     },
+    QrColorDataUrl {
+        color_image_data_url: Url,
+        display_to_timestamp: Option<i64>,
+        display_text: Option<String>,
+        border_color: Option<String>,
+    },
 }
 
 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq, ToSchema)]
diff --git a/crates/common_utils/src/errors.rs b/crates/common_utils/src/errors.rs
index e62606b4585..9f653616738 100644
--- a/crates/common_utils/src/errors.rs
+++ b/crates/common_utils/src/errors.rs
@@ -103,6 +103,9 @@ pub enum QrCodeError {
     /// Failed to encode data into Qr code
     #[error("Failed to create Qr code")]
     FailedToCreateQrCode,
+    /// Failed to parse hex color
+    #[error("Invalid hex color code supplied")]
+    InvalidHexColor,
 }
 
 /// Api Models construction error
diff --git a/crates/hyperswitch_connectors/src/connectors/fiuu/transformers.rs b/crates/hyperswitch_connectors/src/connectors/fiuu/transformers.rs
index 2f8a979ba99..5c38a55dc0f 100644
--- a/crates/hyperswitch_connectors/src/connectors/fiuu/transformers.rs
+++ b/crates/hyperswitch_connectors/src/connectors/fiuu/transformers.rs
@@ -41,6 +41,7 @@ const GOOGLEPAY_API_VERSION_MINOR: u8 = 0;
 const GOOGLEPAY_API_VERSION: u8 = 2;
 
 use crate::{
+    constants,
     types::{
         PaymentsCancelResponseRouterData, PaymentsCaptureResponseRouterData,
         PaymentsSyncResponseRouterData, RefundsResponseRouterData, ResponseRouterData,
@@ -1725,16 +1726,21 @@ impl From<RefundStatus> for enums::RefundStatus {
 pub fn get_qr_metadata(
     response: &DuitNowQrCodeResponse,
 ) -> CustomResult<Option<serde_json::Value>, errors::ConnectorError> {
-    let image_data = QrImage::new_from_data(response.txn_data.request_data.qr_data.peek().clone())
-        .change_context(errors::ConnectorError::ResponseHandlingFailed)?;
+    let image_data = QrImage::new_colored_from_data(
+        response.txn_data.request_data.qr_data.peek().clone(),
+        constants::DUIT_NOW_BRAND_COLOR,
+    )
+    .change_context(errors::ConnectorError::ResponseHandlingFailed)?;
 
     let image_data_url = Url::parse(image_data.data.clone().as_str()).ok();
     let display_to_timestamp = None;
 
-    if let Some(image_data_url) = image_data_url {
-        let qr_code_info = payments::QrCodeInformation::QrDataUrl {
-            image_data_url,
+    if let Some(color_image_data_url) = image_data_url {
+        let qr_code_info = payments::QrCodeInformation::QrColorDataUrl {
+            color_image_data_url,
             display_to_timestamp,
+            display_text: Some(constants::DUIT_NOW_BRAND_TEXT.to_string()),
+            border_color: Some(constants::DUIT_NOW_BRAND_COLOR.to_string()),
         };
 
         Some(qr_code_info.encode_to_value())
diff --git a/crates/hyperswitch_connectors/src/constants.rs b/crates/hyperswitch_connectors/src/constants.rs
index bf2f4c25843..51767bf327c 100644
--- a/crates/hyperswitch_connectors/src/constants.rs
+++ b/crates/hyperswitch_connectors/src/constants.rs
@@ -43,3 +43,7 @@ pub const CONNECTOR_UNAUTHORIZED_ERROR: &str = "Authentication Error from the co
 pub const REFUND_VOIDED: &str = "Refund request has been voided.";
 
 pub const LOW_BALANCE_ERROR_MESSAGE: &str = "Insufficient balance in the payment method";
+
+pub const DUIT_NOW_BRAND_COLOR: &str = "#ED2E67";
+
+pub const DUIT_NOW_BRAND_TEXT: &str = "MALAYSIA NATIONAL QR";
diff --git a/crates/hyperswitch_connectors/src/utils.rs b/crates/hyperswitch_connectors/src/utils.rs
index 05657216d9d..e2b7c688aa5 100644
--- a/crates/hyperswitch_connectors/src/utils.rs
+++ b/crates/hyperswitch_connectors/src/utils.rs
@@ -51,7 +51,7 @@ use hyperswitch_domain_models::{
     types::OrderDetailsWithAmount,
 };
 use hyperswitch_interfaces::{api, consts, errors, types::Response};
-use image::Luma;
+use image::{DynamicImage, ImageBuffer, ImageFormat, Luma, Rgba};
 use masking::{ExposeInterface, PeekInterface, Secret};
 use once_cell::sync::Lazy;
 use regex::Regex;
@@ -4991,12 +4991,12 @@ impl QrImage {
             .change_context(common_utils::errors::QrCodeError::FailedToCreateQrCode)?;
 
         let qrcode_image_buffer = qr_code.render::<Luma<u8>>().build();
-        let qrcode_dynamic_image = image::DynamicImage::ImageLuma8(qrcode_image_buffer);
+        let qrcode_dynamic_image = DynamicImage::ImageLuma8(qrcode_image_buffer);
 
         let mut image_bytes = std::io::BufWriter::new(std::io::Cursor::new(Vec::new()));
 
         // Encodes qrcode_dynamic_image and write it to image_bytes
-        let _ = qrcode_dynamic_image.write_to(&mut image_bytes, image::ImageFormat::Png);
+        let _ = qrcode_dynamic_image.write_to(&mut image_bytes, ImageFormat::Png);
 
         let image_data_source = format!(
             "{},{}",
@@ -5007,6 +5007,58 @@ impl QrImage {
             data: image_data_source,
         })
     }
+
+    pub fn new_colored_from_data(
+        data: String,
+        hex_color: &str,
+    ) -> Result<Self, error_stack::Report<common_utils::errors::QrCodeError>> {
+        let qr_code = qrcode::QrCode::new(data.as_bytes())
+            .change_context(common_utils::errors::QrCodeError::FailedToCreateQrCode)?;
+
+        let qrcode_image_buffer = qr_code.render::<Luma<u8>>().build();
+        let (width, height) = qrcode_image_buffer.dimensions();
+        let mut colored_image = ImageBuffer::new(width, height);
+        let rgb = Self::parse_hex_color(hex_color)?;
+
+        for (x, y, pixel) in qrcode_image_buffer.enumerate_pixels() {
+            let luminance = pixel.0[0];
+            let color = if luminance == 0 {
+                Rgba([rgb.0, rgb.1, rgb.2, 255])
+            } else {
+                Rgba([255, 255, 255, 255])
+            };
+            colored_image.put_pixel(x, y, color);
+        }
+
+        let qrcode_dynamic_image = DynamicImage::ImageRgba8(colored_image);
+        let mut image_bytes = std::io::Cursor::new(Vec::new());
+        qrcode_dynamic_image
+            .write_to(&mut image_bytes, ImageFormat::Png)
+            .change_context(common_utils::errors::QrCodeError::FailedToCreateQrCode)?;
+
+        let image_data_source = format!(
+            "{},{}",
+            QR_IMAGE_DATA_SOURCE_STRING,
+            BASE64_ENGINE.encode(image_bytes.get_ref())
+        );
+
+        Ok(Self {
+            data: image_data_source,
+        })
+    }
+
+    pub fn parse_hex_color(hex: &str) -> Result<(u8, u8, u8), common_utils::errors::QrCodeError> {
+        let hex = hex.trim_start_matches('#');
+        if hex.len() == 6 {
+            let r = u8::from_str_radix(&hex[0..2], 16).ok();
+            let g = u8::from_str_radix(&hex[2..4], 16).ok();
+            let b = u8::from_str_radix(&hex[4..6], 16).ok();
+            if let (Some(r), Some(g), Some(b)) = (r, g, b) {
+                return Ok((r, g, b));
+            }
+        }
+        Err(common_utils::errors::QrCodeError::InvalidHexColor)
+    }
 }
 
 #[cfg(test)]
diff --git a/crates/router/src/compatibility/stripe/payment_intents/types.rs b/crates/router/src/compatibility/stripe/payment_intents/types.rs
index 545c058ce1c..70051bdd504 100644
--- a/crates/router/src/compatibility/stripe/payment_intents/types.rs
+++ b/crates/router/src/compatibility/stripe/payment_intents/types.rs
@@ -824,6 +824,8 @@ pub enum StripeNextAction {
         image_data_url: Option<url::Url>,
         display_to_timestamp: Option<i64>,
         qr_code_url: Option<url::Url>,
+        border_color: Option<String>,
+        display_text: Option<String>,
     },
     FetchQrCodeInformation {
         qr_code_fetch_url: url::Url,
@@ -869,10 +871,14 @@ pub(crate) fn into_stripe_next_action(
             image_data_url,
             display_to_timestamp,
             qr_code_url,
+            border_color,
+            display_text,
         } => StripeNextAction::QrCodeInformation {
             image_data_url,
             display_to_timestamp,
             qr_code_url,
+            border_color,
+            display_text,
         },
         payments::NextActionData::FetchQrCodeInformation { qr_code_fetch_url } => {
             StripeNextAction::FetchQrCodeInformation { qr_code_fetch_url }
diff --git a/crates/router/src/compatibility/stripe/setup_intents/types.rs b/crates/router/src/compatibility/stripe/setup_intents/types.rs
index 03cf9742f70..0b3c6cdd596 100644
--- a/crates/router/src/compatibility/stripe/setup_intents/types.rs
+++ b/crates/router/src/compatibility/stripe/setup_intents/types.rs
@@ -377,6 +377,8 @@ pub enum StripeNextAction {
         image_data_url: Option<url::Url>,
         display_to_timestamp: Option<i64>,
         qr_code_url: Option<url::Url>,
+        border_color: Option<String>,
+        display_text: Option<String>,
     },
     FetchQrCodeInformation {
         qr_code_fetch_url: url::Url,
@@ -422,10 +424,14 @@ pub(crate) fn into_stripe_next_action(
             image_data_url,
             display_to_timestamp,
             qr_code_url,
+            display_text,
+            border_color,
         } => StripeNextAction::QrCodeInformation {
             image_data_url,
             display_to_timestamp,
             qr_code_url,
+            display_text,
+            border_color,
         },
         payments::NextActionData::FetchQrCodeInformation { qr_code_fetch_url } => {
             StripeNextAction::FetchQrCodeInformation { qr_code_fetch_url }
diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs
index ad56394c5f8..12483aac8e2 100644
--- a/crates/router/src/core/payments/transformers.rs
+++ b/crates/router/src/core/payments/transformers.rs
@@ -2960,6 +2960,8 @@ impl ForeignFrom<api_models::payments::QrCodeInformation> for api_models::paymen
                 image_data_url: Some(image_data_url),
                 qr_code_url: Some(qr_code_url),
                 display_to_timestamp,
+                border_color: None,
+                display_text: None,
             },
             api_models::payments::QrCodeInformation::QrDataUrl {
                 image_data_url,
@@ -2968,6 +2970,8 @@ impl ForeignFrom<api_models::payments::QrCodeInformation> for api_models::paymen
                 image_data_url: Some(image_data_url),
                 display_to_timestamp,
                 qr_code_url: None,
+                border_color: None,
+                display_text: None,
             },
             api_models::payments::QrCodeInformation::QrCodeImageUrl {
                 qr_code_url,
@@ -2976,6 +2980,20 @@ impl ForeignFrom<api_models::payments::QrCodeInformation> for api_models::paymen
                 qr_code_url: Some(qr_code_url),
                 image_data_url: None,
                 display_to_timestamp,
+                border_color: None,
+                display_text: None,
+            },
+            api_models::payments::QrCodeInformation::QrColorDataUrl {
+                color_image_data_url,
+                display_to_timestamp,
+                border_color,
+                display_text,
+            } => Self::QrCodeInformation {
+                qr_code_url: None,
+                image_data_url: Some(color_image_data_url),
+                display_to_timestamp,
+                border_color,
+                display_text,
             },
         }
     }

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

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

## Description
This PR adds functionality to 
- Create QR image data URI for a given hex color
- Send back the border color and display text message along with the QR data in `NextActionData` - which is consumed by SDK to take next action

Described in #7405 


### Additional Changes

- [ ] 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
DuitNow is a realtime payment system which works by scanning QR codes. Displaying these QR codes has certain guidelines mentioned here - https://docs.developer.paynet.my/branding/duitnow/DuitNow%20Brand.pdf

This PR sends required data in the confirm API response which is consumed by SDK.


## How did you test it?
Tested locally by creating payment links and proceeding with DuitNow payment method.

<details>
    <summary>Create a payment link (ensure DuitNow is enabled)</summary>

cURL

    curl --location --request POST 'http://localhost:8080/payments' \
        --header 'Content-Type: application/json' \
        --header 'Accept: application/json' \
        --header 'api-key: dev_mdkyMYu7o5AvOHSe81Y5MYanl7eTn9rFTRg7DdMvIsJm88Iq5QtXrSihb94pVXmW' \
        --data-raw '{"customer_id":"cus_AuvaBi0Ga8Wqx0AyKrYd","profile_id":"pro_7ez6O5T557WOZlcVlidL","customer_acceptance":{"acceptance_type":"online","accepted_at":"1963-05-03T04:07:52.723Z","online":{"ip_address":"127.0.0.1","user_agent":"amet irure esse"}},"amount":100,"currency":"MYR","payment_link":true,"capture_method":"automatic","billing":{"address":{"line1":"1467","line3":"Harrison Street","city":"San Fransico","state":"CA","zip":"94122","country":"MY","first_name":"John","last_name":"Doe"},"phone":{"number":"8056594427","country_code":"+91"}},"email":"john.doe@example.co.in","session_expiry":100000,"return_url":"https://example.com","payment_link_config":{"theme":"#003264","display_sdk_only":true,"hide_card_nickname_field":true,"payment_button_colour":"#FFA445","custom_message_for_card_terms":"Pour vérifier votre compte, un montant de 0,00 € sera débité lorsque vous aurez cliqué sur le bouton « Confirmer »","payment_button_text":"Confirmer","payment_button_text_colour":"#003264","skip_status_screen":true,"background_colour":"#F9F9F9"}}'

Response

    {"payment_id":"pay_o8k60sUsjHfrG7a4gExy","merchant_id":"merchant_1741062746","status":"requires_payment_method","amount":100,"net_amount":100,"shipping_cost":null,"amount_capturable":100,"amount_received":null,"connector":null,"client_secret":"pay_o8k60sUsjHfrG7a4gExy_secret_OVVV51kpUYJUdtXFJz1l","created":"2025-03-04T06:11:28.748Z","currency":"MYR","customer_id":"cus_AuvaBi0Ga8Wqx0AyKrYd","customer":{"id":"cus_AuvaBi0Ga8Wqx0AyKrYd","name":"John Nether","email":"john.doe@example.co.in","phone":"6168205362","phone_country_code":"+1"},"description":null,"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":null,"payment_method_data":null,"payment_token":null,"shipping":null,"billing":{"address":{"city":"San Fransico","country":"MY","line1":"1467","line2":null,"line3":"Harrison Street","zip":"94122","state":"CA","first_name":"John","last_name":"Doe"},"phone":{"number":"8056594427","country_code":"+91"},"email":null},"order_details":null,"email":"john.doe@example.co.in","name":"John Nether","phone":"6168205362","return_url":"https://example.com/","authentication_type":null,"statement_descriptor_name":null,"statement_descriptor_suffix":null,"next_action":null,"cancellation_reason":null,"error_code":null,"error_message":null,"unified_code":null,"unified_message":null,"payment_experience":null,"payment_method_type":null,"connector_label":null,"business_country":null,"business_label":"default","business_sub_label":null,"allowed_payment_method_types":null,"ephemeral_key":{"customer_id":"cus_AuvaBi0Ga8Wqx0AyKrYd","created_at":1741068688,"expires":1741072288,"secret":"epk_2abaa6e62d0d4b43af67b73aa52160c9"},"manual_retry_allowed":null,"connector_transaction_id":null,"frm_message":null,"metadata":null,"connector_metadata":null,"feature_metadata":null,"reference_id":null,"payment_link":{"link":"http://localhost:8080/payment_link/merchant_1741062746/pay_o8k60sUsjHfrG7a4gExy?locale=en","secure_link":null,"payment_link_id":"plink_PLK7sgcmehAkqs93KUAq"},"profile_id":"pro_7ez6O5T557WOZlcVlidL","surcharge_details":null,"attempt_count":1,"merchant_decision":null,"merchant_connector_id":null,"incremental_authorization_allowed":null,"authorization_count":null,"incremental_authorizations":null,"external_authentication_details":null,"external_3ds_authentication_attempted":false,"expires_on":"2025-03-05T09:58:08.746Z","fingerprint":null,"browser_info":null,"payment_method_id":null,"payment_method_status":null,"updated":"2025-03-04T06:11:28.763Z","split_payments":null,"frm_metadata":null,"extended_authorization_applied":null,"capture_before":null,"merchant_order_reference_id":null,"order_tax_amount":null,"connector_mandate_id":null,"card_discovery":null}

</details>

<details>
    <summary>Select and proceed with DuitNow</summary>

<img width="498" alt="Screenshot 2025-03-04 at 11 41 48 AM" src="https://github.com/user-attachments/assets/80b2efcf-db64-49e1-ab9e-ac97ab10be6b" />

</details>

## 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
