Files
shuffle_and_skirmish_website/wp-content/plugins/woocommerce-payments/includes/class-wc-payments-order-service.php
2025-11-24 21:33:55 +00:00

2435 lines
84 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Class WC_Payments_Order_Service
*
* @package WooCommerce\Payments
*/
use WCPay\Constants\Fraud_Meta_Box_Type;
use WCPay\Constants\Order_Status;
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Method;
use WCPay\Constants\Refund_Status;
use WCPay\Constants\Refund_Failure_Reason;
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Fraud_Prevention\Models\Rule;
use WCPay\Logger;
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Core\Server\Request\Cancel_Intention;
use WCPay\Core\Server\Request\Capture_Intention;
defined( 'ABSPATH' ) || exit;
/**
* Class handling order functionality.
*/
class WC_Payments_Order_Service {
const ADD_FEE_BREAKDOWN_TO_ORDER_NOTES = 'wcpay_add_fee_breakdown_to_order_notes';
/**
* Meta key used to store intent Id.
*
* @const string
*/
const INTENT_ID_META_KEY = '_intent_id';
/**
* Meta key used to store payment method Id.
*
* @const string
*/
const PAYMENT_METHOD_ID_META_KEY = '_payment_method_id';
/**
* Meta key used to store charge Id.
*
* @const string
*/
const CHARGE_ID_META_KEY = '_charge_id';
/**
* Meta key used to store intention status.
*
* @const string
*/
const INTENTION_STATUS_META_KEY = '_intention_status';
/**
* Meta key used to store the charge risk level.
*
* @const string
*/
const CHARGE_RISK_LEVEL_META_KEY = '_charge_risk_level';
/**
* Meta key used to store customer Id.
*
* @const string
*/
const CUSTOMER_ID_META_KEY = '_stripe_customer_id';
/**
* Meta key used to store WCPay fraud meta box type.
*
* @const string
*/
const WCPAY_FRAUD_META_BOX_TYPE_META_KEY = '_wcpay_fraud_meta_box_type';
/**
* Meta key used to store WCPay fraud outcome status.
*
* @const string
*/
const WCPAY_FRAUD_OUTCOME_STATUS_META_KEY = '_wcpay_fraud_outcome_status';
/**
* Meta key used to store WCPay intent currency.
*
* @const string
*/
const WCPAY_INTENT_CURRENCY_META_KEY = '_wcpay_intent_currency';
/**
* Meta key used to store WCPay refund id.
*
* @const string
*/
const WCPAY_REFUND_ID_META_KEY = '_wcpay_refund_id';
/**
* Meta key used to store WCPay refund transaction id.
*
* @const string
*/
const WCPAY_REFUND_TRANSACTION_ID_META_KEY = '_wcpay_refund_transaction_id';
/**
* Meta key used to store WCPay refund status.
*
* @const string
*/
const WCPAY_REFUND_STATUS_META_KEY = '_wcpay_refund_status';
/**
* Meta key used to store WCPay transaction fee.
*
* @const string
*/
const WCPAY_TRANSACTION_FEE_META_KEY = '_wcpay_transaction_fee';
/**
* Meta key used to store the mode, either 'test', or 'prod' of order.
*
* @see Order_Mode
*
* @const string
*/
const WCPAY_MODE_META_KEY = '_wcpay_mode';
/**
* Meta key used to store payment transaction Id.
*
* @const string
*/
const WCPAY_PAYMENT_TRANSACTION_ID_META_KEY = '_wcpay_payment_transaction_id';
/**
* Meta key used to store the Multibanco entity.
*
* @const string
*/
const WCPAY_MULTIBANCO_ENTITY_META_KEY = '_wcpay_multibanco_entity';
/**
* Meta key used to store the Multibanco reference.
*
* @const string
*/
const WCPAY_MULTIBANCO_REFERENCE_META_KEY = '_wcpay_multibanco_reference';
/**
* Meta key used to store the Multibanco expiry.
*
* @const string
*/
const WCPAY_MULTIBANCO_EXPIRY_META_KEY = '_wcpay_multibanco_expiry';
/**
* Meta key used to store the Multibanco URL.
*
* @const string
*/
const WCPAY_MULTIBANCO_URL_META_KEY = '_wcpay_multibanco_url';
/**
* Meta key for cached payment method details.
*
* @const string
*/
const PAYMENT_METHOD_DETAILS_META_KEY = '_wcpay_payment_method_details';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
protected $api_client;
/**
* WC_Payments_Order_Service constructor.
*
* @param WC_Payments_API_Client $api_client - WooCommerce Payments API client.
*/
public function __construct( WC_Payments_API_Client $api_client ) {
$this->api_client = $api_client;
}
/**
* Parse the payment intent data and add any necessary notes to the order and update the order status accordingly.
*
* @param WC_Order $order The order to update.
* @param WC_Payments_API_Abstract_Intention $intent Setup or payment intent to pull the data from.
*/
public function update_order_status_from_intent( $order, $intent ) {
$intent_data = $this->get_intent_data( $intent );
if ( ! isset( $intent_data['intent_id'] ) || ! $this->order_prepared_for_processing( $order, $intent_data['intent_id'] ) ) {
return;
}
switch ( $intent_data['intent_status'] ) {
case Intent_Status::CANCELED:
$this->mark_payment_capture_cancelled( $order, $intent_data );
break;
case Intent_Status::SUCCEEDED:
if ( Intent_Status::REQUIRES_CAPTURE === $this->get_intention_status_for_order( $order ) ) {
$this->mark_payment_capture_completed( $order, $intent );
} else {
$this->mark_payment_completed( $order, $intent_data );
}
break;
case Intent_Status::PROCESSING:
case Intent_Status::REQUIRES_CAPTURE:
if ( Rule::FRAUD_OUTCOME_REVIEW === $intent_data['fraud_outcome'] ) {
$this->mark_order_held_for_review_for_fraud( $order, $intent_data );
} else {
$this->mark_payment_authorized( $order, $intent_data );
}
break;
case Intent_Status::REQUIRES_ACTION:
case Intent_Status::REQUIRES_PAYMENT_METHOD:
if ( ! empty( $intent_data['error'] ) ) {
$this->unlock_order_payment( $order );
$this->mark_payment_failed( $order, $intent_data['intent_id'], $intent_data['intent_status'], $intent_data['charge_id'], $intent_data['error']['message'] );
} elseif ( in_array( $intent->get_payment_method_type(), Payment_Method::OFFLINE_PAYMENT_METHODS, true ) ) {
$this->mark_payment_on_hold( $order, $intent_data );
} else {
$this->mark_payment_started( $order, $intent_data );
}
break;
default:
Logger::error( 'Uncaught payment intent status of ' . $intent_data['intent_status'] . ' passed for order id: ' . $order->get_id() );
break;
}
$this->complete_order_processing( $order );
}
/**
* Handles the order state when a payment is captured successfully.
* Unlike `update_order_status_from_intent`, this method does not check the current order status or skip processing
* if the order is already in the "processing" state. This ensures the order status is updated correctly upon a
* successful capture, preventing issues where the capture is not reflected in the order details or transaction screens
* due to the order status being in the processing state.
*
* @param WC_Order $order The order to update.
* @param WC_Payments_API_Abstract_Intention $intent The intent object containing payment or setup data.
*/
public function process_captured_payment( $order, $intent ) {
$this->mark_payment_capture_completed( $order, $intent );
$this->complete_order_processing( $order, $intent->get_status() );
}
/**
* Updates an order to failed status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $intent_status The status of the intent related to this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the failed note.
*
* @return void
*/
public function mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $message = '' ) {
if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) {
return;
}
$note = $this->generate_payment_failure_note( $intent_id, $charge_id, $message, $this->get_order_amount( $order ) );
if ( $this->order_note_exists( $order, $note )
|| $order->has_status( [ Order_Status::FAILED ] ) ) {
$this->complete_order_processing( $order );
return;
}
$this->update_order_status( $order, Order_Status::FAILED );
$order->add_order_note( $note );
$this->complete_order_processing( $order, $intent_status );
}
/**
* Leaves order in current status (should be on-hold), adds a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string|null $intent_status The status of the intent related to this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the note.
*
* @return void
*/
public function mark_payment_capture_failed( $order, $intent_id, $intent_status, $charge_id, $message = '' ) {
if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) {
return;
}
$note = $this->generate_capture_failed_note( $order, $intent_id, $charge_id, $message );
if ( $this->order_note_exists( $order, $note ) ) {
$this->complete_order_processing( $order );
return;
}
if ( Rule::FRAUD_OUTCOME_REVIEW === $this->get_fraud_outcome_status_for_order( $order ) ) {
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::REVIEW_FAILED );
}
$order->add_order_note( $note );
$this->complete_order_processing( $order, $intent_status );
}
/**
* Update an order to failed status, and add note with a link to the transaction.
*
* Context - when a Payment Intent expires. Changing the status to failed will enable the buyer to re-attempt payment.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $intent_status The status of the intent related to this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return void
*/
public function mark_payment_capture_expired( $order, $intent_id, $intent_status, $charge_id ) {
if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) {
return;
}
$note = $this->generate_capture_expired_note( $intent_id, $charge_id );
if ( $this->order_note_exists( $order, $note ) ) {
$this->complete_order_processing( $order );
return;
}
if ( Rule::FRAUD_OUTCOME_REVIEW === $this->get_fraud_outcome_status_for_order( $order ) ) {
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::REVIEW_EXPIRED );
}
$this->update_order_status( $order, Order_Status::FAILED );
$order->add_order_note( $note );
$this->complete_order_processing( $order, $intent_status );
}
/**
* Leaves order status as Pending, adds fraud meta data, and adds the fraud blocked note.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $intent_status The status of the intent related to this order.
*
* @return void
*/
public function mark_order_blocked_for_fraud( $order, $intent_id, $intent_status ) {
if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) {
return;
}
$note = $this->generate_fraud_blocked_note( $order );
if ( $this->order_note_exists( $order, $note ) ) {
$this->complete_order_processing( $order );
return;
}
$this->set_fraud_outcome_status_for_order( $order, Rule::FRAUD_OUTCOME_BLOCK );
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::BLOCK );
$order->add_order_note( $note );
$this->complete_order_processing( $order, $intent_status );
}
/**
* Updates the order to on-hold status and adds a note about the dispute.
*
* @param WC_Order $order Order object.
* @param string $charge_id The ID of the disputed charge associated with this order.
* @param string $amount The disputed amount formatted currency value.
* @param string $reason The reason for the dispute human-readable text.
* @param string $due_by The deadline for responding to the dispute - formatted date string.
* @param string $status The status of the dispute.
*
* @return void
*/
public function mark_payment_dispute_created( $order, $charge_id, $amount, $reason, $due_by, $status = '' ) {
if ( ! is_a( $order, 'WC_Order' ) ) {
return;
}
$is_inquiry = strpos( $status, 'warning_' ) === 0;
$note = $this->generate_dispute_created_note( $charge_id, $amount, $reason, $due_by, $is_inquiry );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
$this->update_order_status( $order, Order_Status::ON_HOLD );
$order->add_order_note( $note );
$order->save();
}
/**
* Updates the order status based on dispute status and adds a note about the dispute.
*
* @param WC_Order $order Order object.
* @param string $charge_id The ID of the disputed charge associated with this order.
* @param string $status The status of the dispute.
*
* @return void
*/
public function mark_payment_dispute_closed( $order, $charge_id, $status ) {
if ( ! is_a( $order, 'WC_Order' ) ) {
return;
}
$is_inquiry = strpos( $status, 'warning_' ) === 0;
$note = $this->generate_dispute_closed_note( $charge_id, $status, $is_inquiry );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
// Order `completed` and `refunded` emails should both be blocked when disputes are closed.
add_filter( 'woocommerce_email_enabled_customer_completed_order', '__return_false' );
add_filter( 'woocommerce_email_enabled_customer_refunded_order', '__return_false' );
add_filter( 'woocommerce_email_enabled_customer_completed_renewal_order', '__return_false' );
if ( 'lost' === $status ) {
wc_create_refund(
[
'amount' => $order->get_total(),
'reason' => __( 'Dispute lost.', 'woocommerce-payments' ),
'order_id' => $order->get_id(),
'line_items' => $order->get_items(),
]
);
} else {
// TODO: This should revert to the status the order was in before the dispute was created.
$this->update_order_status( $order, Order_Status::COMPLETED );
$order->save();
}
// Restore completed and refunded order emails.
remove_filter( 'woocommerce_email_enabled_customer_completed_order', '__return_false' );
remove_filter( 'woocommerce_email_enabled_customer_refunded_order', '__return_false' );
remove_filter( 'woocommerce_email_enabled_customer_completed_renewal_order', '__return_false' );
$order->add_order_note( $note );
}
/**
* Updates a terminal order to completed status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $intent_status The status of the intent related to this order.
*
* @return void
*/
public function mark_terminal_payment_completed( $order, $intent_id, $intent_status ) {
/**
* Filters the order status value after a successful terminal payment.
*
* This filter can be used to override the order status from `completed` to `processing` after a successful terminal charge.
*
* @since 6.7.0
*/
$order_status = apply_filters( 'wcpay_terminal_payment_completed_order_status', Order_Status::COMPLETED );
$this->update_order_status( $order, $order_status, $intent_id );
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::TERMINAL_PAYMENT );
$this->complete_order_processing( $order, $intent_status );
}
/**
* Mark terminal payment failed function.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $intent_status The status of the intent related to this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the failed note.
*
* @return void
*/
public function mark_terminal_payment_failed( $order, string $intent_id, string $intent_status, string $charge_id, string $message ) {
if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) {
return;
}
$order_status_before_update = $order->get_status();
$this->update_order_status( $order, Order_Status::FAILED );
$note = $this->generate_terminal_payment_failure_note( $intent_id, $charge_id, $message, $this->get_order_amount( $order ) );
if ( $this->order_note_exists( $order, $note ) ) {
$this->complete_order_processing( $order );
return;
}
$order->add_order_note( $note );
$this->complete_order_processing( $order, $intent_status );
// Trigger the failed order status hook to send notifications etc only if the order status was not already failed to avoid duplicate notifications.
if ( Order_Status::FAILED === $order_status_before_update ) {
do_action( 'woocommerce_order_status_pending_to_failed_notification', $order->get_id(), $order );
do_action( 'woocommerce_order_status_failed_notification', $order->get_id(), $order );
}
}
/**
* Check if a note content has already existed in the order.
*
* @param WC_Order $order The order object to add the note.
* @param string $note_content Note content.
*
* @return bool true if the note content exists, false otherwise.
*/
public function order_note_exists( WC_Order $order, string $note_content ): bool {
// Get current notes of the order.
$current_notes = wc_get_order_notes(
[ 'order_id' => $order->get_id() ]
);
foreach ( $current_notes as $current_note ) {
if ( $current_note->content === $note_content ) {
return true;
}
}
return false;
}
/**
* Adds a note with the fee breakdown for the order.
*
* @param string $order_id WC Order Id.
* @param string $intent_id The intent id for the payment.
* @param bool $is_test_mode Whether to run the CRON job in test mode.
*/
public function add_fee_breakdown_to_order_notes( $order_id, $intent_id, $is_test_mode = false ) {
// Since this CRON job may have been created in test_mode, when the CRON job runs, it
// may lose the test_mode context. So, instead, we pass that context when creating
// the CRON job and apply the context here.
$apply_test_mode_context = function () use ( $is_test_mode ) {
return $is_test_mode;
};
add_filter( 'wcpay_test_mode', $apply_test_mode_context );
$order = wc_get_order( $order_id );
try {
$events = $this->api_client->get_timeline( $intent_id );
$captured_event = current(
array_filter(
$events['data'],
function ( array $event ) {
return 'captured' === $event['type'];
}
)
);
$details = ( new WC_Payments_Captured_Event_Note( $captured_event ) )->generate_html_note();
// Add fee breakdown details to the note.
$title = WC_Payments_Utils::esc_interpolated_html(
// phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings
__( '<strong>Fee details:</strong>', 'woocommerce-payments' ),
[
'strong' => '<strong>',
]
);
$note = $title . $details;
// Update the order with the new note.
$order->add_order_note( $note );
$order->save();
} catch ( Exception $e ) {
Logger::log( sprintf( 'Can not generate the detailed note for intent_id %1$s. Reason: %2$s', $intent_id, $e->getMessage() ) );
}
}
/**
* Get the payment metadata for intent id.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_intent_id_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::INTENT_ID_META_KEY, true );
}
/**
* Set the payment metadata for intent id.
*
* @param WC_Order $order The order object.
* @param string $intent_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_intent_id_for_order( $order, $intent_id ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::INTENT_ID_META_KEY, $intent_id );
$order->save_meta_data();
/**
* Hook: When the order meta data _intent_id is updated.
*
* @since 5.4.0
*/
do_action( 'wcpay_order_intent_id_updated' );
}
/**
* Get the payment metadata for payment method id.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_payment_method_id_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::PAYMENT_METHOD_ID_META_KEY, true );
}
/**
* Set the payment metadata for payment method id.
*
* @param mixed $order The order.
* @param string $payment_method_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_payment_method_id_for_order( $order, $payment_method_id ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::PAYMENT_METHOD_ID_META_KEY, $payment_method_id );
$order->save_meta_data();
/**
* Hook: When the order meta data _payment_method_id is updated.
*
* @since 5.4.0
*/
do_action( 'wcpay_order_payment_method_id_updated' );
}
/**
* Set the payment metadata for charge id.
*
* @param mixed $order The order.
* @param string $charge_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_charge_id_for_order( $order, $charge_id ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::CHARGE_ID_META_KEY, $charge_id );
$order->save_meta_data();
}
/**
* Set the payment metadata for payment transaction id.
*
* @param mixed $order The order.
* @param string $payment_transaction_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_payment_transaction_id_for_order( $order, $payment_transaction_id ) {
if ( ! isset( $payment_transaction_id ) || null === $payment_transaction_id ) {
return;
}
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_PAYMENT_TRANSACTION_ID_META_KEY, $payment_transaction_id );
$order->save_meta_data();
}
/**
* Set the payment metadata for risk level.
*
* @param mixed $order The order.
* @param string $risk_level The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_charge_risk_level_for_order( $order, $risk_level ) {
if ( ! isset( $risk_level ) || null === $risk_level ) {
return;
}
$order = $this->get_order( $order );
$order->update_meta_data( self::CHARGE_RISK_LEVEL_META_KEY, $risk_level );
$order->save_meta_data();
}
/**
* Get the risk level for an order.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_charge_risk_level_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::CHARGE_RISK_LEVEL_META_KEY, true );
}
/**
* Get the payment metadata for charge id.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_charge_id_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::CHARGE_ID_META_KEY, true );
}
/**
* Set the payment metadata for intention status.
*
* @param mixed $order The order.
* @param string $intention_status The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_intention_status_for_order( $order, $intention_status ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::INTENTION_STATUS_META_KEY, $intention_status );
$order->save_meta_data();
}
/**
* Get the payment metadata for intention status.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_intention_status_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::INTENTION_STATUS_META_KEY, true );
}
/**
* Checks if order has an open (uncaptured) authorization.
*
* @param mixed $order The order Id or order object.
*
* @return bool
*
* @throws Order_Not_Found_Exception
*/
public function has_open_authorization( $order ): bool {
$order = $this->get_order( $order );
return Intent_Status::REQUIRES_CAPTURE === $order->get_meta( self::INTENTION_STATUS_META_KEY, true );
}
/**
* Set the payment metadata for customer id.
*
* @param mixed $order The order.
* @param string $customer_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_customer_id_for_order( $order, $customer_id ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::CUSTOMER_ID_META_KEY, $customer_id );
$order->save_meta_data();
}
/**
* Get the payment metadata for customer id.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_customer_id_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::CUSTOMER_ID_META_KEY, true );
}
/**
* Set the payment metadata for intent currency.
*
* @param mixed $order The order.
* @param string $wcpay_intent_currency The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_wcpay_intent_currency_for_order( $order, $wcpay_intent_currency ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_INTENT_CURRENCY_META_KEY, $wcpay_intent_currency );
$order->save_meta_data();
}
/**
* Get the payment metadata for intent currency.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_wcpay_intent_currency_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::WCPAY_INTENT_CURRENCY_META_KEY, true );
}
/**
* Set WCPay refund ID as metadata for refund object.
*
* @param WC_Order_Refund $wc_refund The refund instance.
* @param string $wcpay_refund_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_wcpay_refund_id_for_refund( $wc_refund, $wcpay_refund_id ) {
$wc_refund = $this->get_order( $wc_refund );
$wc_refund->update_meta_data( self::WCPAY_REFUND_ID_META_KEY, $wcpay_refund_id );
$wc_refund->save_meta_data();
}
/**
* Set the payment metadata for refund transaction id.
*
* @param WC_Order_Refund $order The order.
* @param string $wcpay_transaction_id The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_wcpay_refund_transaction_id_for_order( WC_Order_Refund $order, string $wcpay_transaction_id ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_REFUND_TRANSACTION_ID_META_KEY, $wcpay_transaction_id );
$order->save_meta_data();
}
/**
* Get the payment metadata for refund id.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_wcpay_refund_id_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::WCPAY_REFUND_ID_META_KEY, true );
}
/**
* Set the payment metadata for refund status.
*
* @param mixed $order The order.
* @param string $wcpay_refund_status The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_wcpay_refund_status_for_order( $order, $wcpay_refund_status ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_REFUND_STATUS_META_KEY, $wcpay_refund_status );
$order->save_meta_data();
}
/**
* Get the payment metadata for refund status.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_wcpay_refund_status_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::WCPAY_REFUND_STATUS_META_KEY, true );
}
/**
* Set the fraud_outcome_status for an order.
*
* @param mixed $order The order.
* @param string $fraud_outcome_status The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_fraud_outcome_status_for_order( $order, $fraud_outcome_status ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_FRAUD_OUTCOME_STATUS_META_KEY, $fraud_outcome_status );
$order->save_meta_data();
}
/**
* Get the fraud_outcome_status for an order.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_fraud_outcome_status_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::WCPAY_FRAUD_OUTCOME_STATUS_META_KEY, true );
}
/**
* Set the fraud_meta_box_type for an order.
*
* @param mixed $order The order.
* @param string $fraud_meta_box_type The value to be set.
*
* @throws Order_Not_Found_Exception
*/
public function set_fraud_meta_box_type_for_order( $order, $fraud_meta_box_type ) {
$order = $this->get_order( $order );
$order->update_meta_data( self::WCPAY_FRAUD_META_BOX_TYPE_META_KEY, $fraud_meta_box_type );
$order->save_meta_data();
}
/**
* Get the fraud_meta_box_type for an order.
*
* @param mixed $order The order Id or order object.
*
* @return string
*
* @throws Order_Not_Found_Exception
*/
public function get_fraud_meta_box_type_for_order( $order ): string {
$order = $this->get_order( $order );
return $order->get_meta( self::WCPAY_FRAUD_META_BOX_TYPE_META_KEY, true );
}
/**
* Given the payment intent data, adds it to the given order as metadata and parses any notes that need to be added
*
* @param WC_Order $order The order.
* @param WC_Payments_API_Payment_Intention|WC_Payments_API_Setup_Intention $intent The payment or setup intention object.
* @param bool $allow_update_on_success Whether the payment is being changed for a subscription.
*
* @throws Order_Not_Found_Exception
*/
public function attach_intent_info_to_order( WC_Order $order, $intent, $allow_update_on_success = false ) {
// We don't want to allow metadata for a successful payment to be disrupted (except for when changing payment method for subscription or renewing subscription).
if ( Intent_Status::SUCCEEDED === $this->get_intention_status_for_order( $order ) && ! $allow_update_on_success ) {
return;
}
// first, let's prepare all the metadata needed for refunds, required for status change etc.
$intent_id = $intent->get_id();
$intent_status = $intent->get_status();
$payment_method = $intent->get_payment_method_id();
$customer_id = $intent->get_customer_id();
$currency = $intent instanceof WC_Payments_API_Payment_Intention ? $intent->get_currency() : $order->get_currency();
$charge = $intent instanceof WC_Payments_API_Payment_Intention ? $intent->get_charge() : null;
$charge_id = $charge ? $charge->get_id() : null;
$payment_transaction = $charge ? $charge->get_balance_transaction() : null;
$payment_transaction_id = $payment_transaction['id'] ?? '';
$outcome = $charge ? $charge->get_outcome() : null;
$risk_level = $outcome ? $outcome['risk_level'] : null;
// next, save it in order meta.
$this->attach_intent_info_to_order__legacy( $order, $intent_id, $intent_status, $payment_method, $customer_id, $charge_id, $currency, $payment_transaction_id, $risk_level );
// Store payment method details when available.
if ( null !== $charge ) {
$payment_method_details = $charge->get_payment_method_details();
if ( $payment_method_details ) {
$this->store_payment_method_details( $order, $payment_method_details );
}
}
}
/**
* Legacy version of the attach_intent_info_to_order method.
*
* TODO: This method should ultimately be merged with `attach_intent_info_to_order` and then removed.
*
* @param WC_Order $order The order.
* @param string $intent_id The intent ID.
* @param string $intent_status Intent status.
* @param string $payment_method Payment method ID.
* @param string $customer_id Customer ID.
* @param string $charge_id Charge ID.
* @param string $currency Currency code.
* @param string $payment_transaction_id The transaction ID of the linked charge.
* @param string $risk_level The risk level of the payment.
*
* @throws Order_Not_Found_Exception
*/
public function attach_intent_info_to_order__legacy( $order, $intent_id, $intent_status, $payment_method, $customer_id, $charge_id, $currency, $payment_transaction_id = null, $risk_level = null ) {
// first, let's save all the metadata that needed for refunds, required for status change etc.
$order->set_transaction_id( $intent_id );
$this->set_intent_id_for_order( $order, $intent_id );
$this->set_payment_method_id_for_order( $order, $payment_method );
$this->set_charge_id_for_order( $order, $charge_id );
$this->set_intention_status_for_order( $order, $intent_status );
$this->set_customer_id_for_order( $order, $customer_id );
$this->set_wcpay_intent_currency_for_order( $order, $currency );
$this->set_payment_transaction_id_for_order( $order, $payment_transaction_id );
$this->set_charge_risk_level_for_order( $order, $risk_level );
$order->save();
}
/**
* Create the shipping data array to send to Stripe when making a purchase.
*
* @param WC_Order $order The order that is being paid for.
* @return array The shipping data to send to Stripe.
*/
public function get_shipping_data_from_order( WC_Order $order ): array {
return [
'name' => implode(
' ',
array_filter(
[
$order->get_shipping_first_name(),
$order->get_shipping_last_name(),
]
)
),
'address' => [
'line1' => $order->get_shipping_address_1(),
'line2' => $order->get_shipping_address_2(),
'postal_code' => $order->get_shipping_postcode(),
'city' => $order->get_shipping_city(),
'state' => $order->get_shipping_state(),
'country' => $order->get_shipping_country(),
],
];
}
/**
* Create the billing data array to send to Stripe when making a purchase, based on order's billing data.
* It only returns the fields that are present in the billing section of the checkout.
*
* @param WC_Order $order The order that is being paid for.
* @return array The shipping data to send to Stripe.
*/
public function get_billing_data_from_order( WC_Order $order ): array {
$billing_fields = array_keys( WC()->countries->get_address_fields( $order->get_billing_country() ) );
$address_field_to_key = [
'billing_city' => 'city',
'billing_country' => 'country',
'billing_address_1' => 'line1',
'billing_address_2' => 'line2',
'billing_postcode' => 'postal_code',
'billing_state' => 'state',
];
$field_to_key = [
'billing_email' => 'email',
'billing_phone' => 'phone',
];
$billing_details = [ 'address' => [] ];
foreach ( $billing_fields as $field ) {
if ( isset( $address_field_to_key[ $field ] ) ) {
$billing_details['address'][ $address_field_to_key[ $field ] ] = $order->{"get_{$field}"}();
} elseif ( isset( $field_to_key[ $field ] ) ) {
$billing_details[ $field_to_key[ $field ] ] = $order->{"get_{$field}"}();
}
}
if ( in_array( 'billing_first_name', $billing_fields, true ) && in_array( 'billing_last_name', $billing_fields, true ) ) {
$billing_details['name'] = trim( $order->get_formatted_billing_full_name() );
}
// The country field can't ever be empty, so we remove it if it is.
if ( empty( $billing_details['address']['country'] ) ) {
unset( $billing_details['address']['country'] );
}
return $billing_details;
}
/**
* Creates an "authorization cancelled" order note if not already present.
*
* @param WC_Order $order The order.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
* @return boolean True if the note was added, false otherwise.
*/
public function post_unique_capture_cancelled_note( $order, $intent_id, $charge_id ): bool {
$note = $this->generate_capture_cancelled_note( $intent_id, $charge_id );
if ( ! $this->order_note_exists( $order, $note ) ) {
$order->add_order_note( $note );
return true;
}
return false;
}
/**
* Creates an "authorization captured" order note if not already present.
*
* @param WC_Order $order The order.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
* @return boolean True if the note was added, false otherwise.
*/
public function post_unique_capture_complete_note( $order, $intent_id, $charge_id ) {
$note = $this->generate_capture_success_note( $order, $intent_id, $charge_id );
if ( ! $this->order_note_exists( $order, $note ) ) {
$order->add_order_note( $note );
return true;
}
return false;
}
/**
* Updates an order to cancelled status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param array $intent_data The intent data associated with this order.
*
* @return void
*/
private function mark_payment_capture_cancelled( $order, $intent_data ) {
if ( false === $this->post_unique_capture_cancelled_note( $order, $intent_data['intent_id'], $intent_data['charge_id'] ) ) {
$this->complete_order_processing( $order );
return;
}
/**
* If we have a status for the fraud outcome, we want to add the proper meta data.
*/
if ( isset( $intent_data['fraud_outcome'] )
&& Rule::is_valid_fraud_outcome_status( $intent_data['fraud_outcome'] )
&& Rule::FRAUD_OUTCOME_ALLOW !== $intent_data['fraud_outcome'] ) {
$this->set_fraud_outcome_status_for_order( $order, $intent_data['fraud_outcome'] );
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::REVIEW_BLOCKED );
}
$this->update_order_status( $order, Order_Status::CANCELLED );
$this->complete_order_processing( $order, $intent_data['intent_status'] );
}
/**
* Updates an order to processing/completed status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param array $intent_data The data of the intent associated with this order.
*
* @return void
*/
private function mark_payment_completed( $order, $intent_data ) {
// Need to have a check for the intention status of `requires_capture`.
$note = $this->generate_payment_success_note( $intent_data['intent_id'], $intent_data['charge_id'], $this->get_order_amount( $order ) );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
// Update the note with the fee breakdown details async, update order status, add order note.
$this->enqueue_add_fee_breakdown_to_order_notes( $order, $intent_data['intent_id'] );
/**
* If we have a status for the fraud outcome, we want to add the proper meta data.
* If auth/capture is enabled and the transaction is allowed, it will be 'allow'.
* If it was held for review for any reason, it will be 'review'.
*/
if ( '' !== $intent_data['fraud_outcome'] && Rule::is_valid_fraud_outcome_status( $intent_data['fraud_outcome'] ) ) {
$fraud_meta_box_type = Order_Status::ON_HOLD === $order->get_status() ? Fraud_Meta_Box_Type::REVIEW_ALLOWED : Fraud_Meta_Box_Type::ALLOW;
$this->set_fraud_outcome_status_for_order( $order, $intent_data['fraud_outcome'] );
$this->set_fraud_meta_box_type_for_order( $order, $fraud_meta_box_type );
}
if ( ! $this->intent_has_card_payment_type( $intent_data ) ) {
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::NOT_CARD );
}
$this->update_order_status( $order, 'payment_complete', $intent_data['intent_id'] );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent_data['intent_status'] );
}
/**
* Updates an order to on-hold status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param array $intent_data The intent data associated with this order.
*
* @return void
*/
private function mark_payment_authorized( $order, $intent_data ) {
$note = $this->generate_payment_authorized_note( $order, $intent_data['intent_id'], $intent_data['charge_id'] );
if ( $this->order_note_exists( $order, $note )
|| $order->has_status( [ Order_Status::ON_HOLD ] ) ) {
return;
}
if ( Rule::FRAUD_OUTCOME_ALLOW === $intent_data['fraud_outcome'] ) {
$this->set_fraud_outcome_status_for_order( $order, Rule::FRAUD_OUTCOME_ALLOW );
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::ALLOW );
}
$this->update_order_status( $order, Order_Status::ON_HOLD );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent_data['intent_status'] );
}
/**
* Updates an order to on-hold status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param array $intent_data The intent data associated with this order.
*
* @return void
*/
private function mark_payment_on_hold( $order, $intent_data ) {
$note = $this->generate_payment_started_note( $order, $intent_data['intent_id'] );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
$fraud_meta_box_type = $this->intent_has_card_payment_type( $intent_data ) ? Fraud_Meta_Box_Type::PAYMENT_STARTED : Fraud_Meta_Box_Type::NOT_CARD;
$this->set_fraud_meta_box_type_for_order( $order, $fraud_meta_box_type );
$this->update_order_status( $order, Order_Status::ON_HOLD );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent_data['intent_status'] );
}
/**
* Updates an order to processing/completed status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param WC_Payments_API_Payment_Intention $intent The intent instance.
*
* @return void
*/
private function mark_payment_capture_completed( $order, $intent ) {
$intent_id = $intent->get_id();
$note = $this->generate_capture_success_note( $order, $intent_id, $intent->get_charge()->get_id() );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
// Update the note with the fee breakdown details async.
$this->enqueue_add_fee_breakdown_to_order_notes( $order, $intent_id );
/**
* If we have a status for the fraud outcome, we want to add the proper meta data.
* If auth/capture is enabled and the transaction is allowed, it will be 'allow'.
* If it was held for review for any reason, it will be 'review'.
*/
$fraud_outcome = $intent->get_metadata()['fraud_outcome'] ?? '';
if ( '' !== $fraud_outcome && Rule::is_valid_fraud_outcome_status( $fraud_outcome ) ) {
$fraud_meta_box_type = Rule::FRAUD_OUTCOME_REVIEW === $this->get_fraud_outcome_status_for_order( $order ) ? Fraud_Meta_Box_Type::REVIEW_ALLOWED : Fraud_Meta_Box_Type::ALLOW;
$this->set_fraud_outcome_status_for_order( $order, $fraud_outcome );
$this->set_fraud_meta_box_type_for_order( $order, $fraud_meta_box_type );
}
$this->attach_transaction_fee_to_order( $order, $intent->get_charge() );
$this->update_order_status( $order, 'payment_complete', $intent_id );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent->get_status() );
}
/**
* Leaves an order in pending status, while adding a note with a link to the transaction.
*
* @param WC_Order $order Order object.
* @param array $intent_data The intent data associated with this order.
*
* @return void
*/
private function mark_payment_started( $order, $intent_data ) {
$note = $this->generate_payment_started_note( $order, $intent_data['intent_id'] );
if ( $this->order_note_exists( $order, $note )
|| ! $order->has_status( [ Order_Status::PENDING ] ) ) {
return;
}
$fraud_meta_box_type = $this->intent_has_card_payment_type( $intent_data ) ? Fraud_Meta_Box_Type::PAYMENT_STARTED : Fraud_Meta_Box_Type::NOT_CARD;
$this->set_fraud_meta_box_type_for_order( $order, $fraud_meta_box_type );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent_data['intent_status'] );
}
/**
* Changes status to On-Hold, adds fraud meta data, and adds the fraud held for review note.
*
* @param WC_Order $order Order object.
* @param array $intent_data The intent data associated with this order.
*
* @return void
*/
private function mark_order_held_for_review_for_fraud( $order, $intent_data ) {
$note = $this->generate_fraud_held_for_review_note( $order, $intent_data['intent_id'], $intent_data['charge_id'] );
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
$this->update_order_status( $order, Order_Status::ON_HOLD );
$this->set_fraud_outcome_status_for_order( $order, Rule::FRAUD_OUTCOME_REVIEW );
$this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::REVIEW );
$order->add_order_note( $note );
$this->set_intention_status_for_order( $order, $intent_data['intent_status'] );
}
/**
* Given the charge, adds the application_fee_amount from the charge to the given order as metadata.
*
* @param WC_Order $order The order to update.
* @param WC_Payments_API_Charge|null $charge The charge to get the application_fee_amount from.
*/
public function attach_transaction_fee_to_order( $order, $charge ) {
try {
if ( $charge && null !== $charge->get_application_fee_amount() ) {
$order->update_meta_data(
self::WCPAY_TRANSACTION_FEE_META_KEY,
WC_Payments_Utils::interpret_stripe_amount( $charge->get_application_fee_amount(), $charge->get_currency() )
);
$order->save_meta_data();
}
} catch ( Exception $e ) {
// Log the error and don't block checkout.
Logger::log( 'Error saving transaction fee into metadata for the order ' . $order->get_id() . ': ' . $e->getMessage() );
}
}
/**
* Cancels uncaptured authorizations on order cancel.
*
* @param int $order_id - Order ID.
*/
public function cancel_authorizations_on_order_status_change( $order_id ) {
$order = new WC_Order( $order_id );
if ( null !== $order ) {
$intent_id = $this->get_intent_id_for_order( $order );
if ( null !== $intent_id && '' !== $intent_id ) {
try {
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$charge = $intent->get_charge();
/**
* Successful but not captured Charge is an authorization
* that needs to be cancelled.
*/
if ( null !== $charge
&& false === $charge->is_captured()
&& Intent_Status::SUCCEEDED === $charge->get_status()
&& Intent_Status::REQUIRES_CAPTURE === $intent->get_status()
) {
$request = Cancel_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$this->post_unique_capture_cancelled_note( $order, $intent_id, $charge->get_id() );
}
$this->set_intention_status_for_order( $order, $intent->get_status() );
$order->save();
} catch ( \Exception $e ) {
$order->add_order_note(
WC_Payments_Utils::esc_interpolated_html(
__( 'Canceling authorization <strong>failed</strong> to complete.', 'woocommerce-payments' ),
[ 'strong' => '<strong>' ]
)
);
}
}
}
}
/**
* Handles the change of an order status.
*
* This function is triggered when the status of an order is changed.
* It performs necessary actions based on the new status of the order.
*
* @param int $order_id The ID of the order.
* @return void
*/
public function capture_authorization_on_order_status_change( int $order_id ) {
$order = new WC_Order( $order_id );
if ( null !== $order ) {
$intent_id = $this->get_intent_id_for_order( $order );
if ( null !== $intent_id && '' !== $intent_id ) {
try {
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$charge = $intent->get_charge();
/**
* Successful but not captured Charge is an authorization
* that needs to be captured.
*/
if ( null !== $charge
&& false === $charge->is_captured()
&& Intent_Status::SUCCEEDED === $charge->get_status()
&& Intent_Status::REQUIRES_CAPTURE === $intent->get_status()
) {
$request = Capture_Intention::create( $intent_id );
$request->set_amount_to_capture( WC_Payments_Utils::prepare_amount( $order->get_total(), $order->get_currency() ) );
$request->set_hook_args( $order );
$intent = $request->send();
$this->post_unique_capture_complete_note( $order, $intent_id, $charge->get_id() );
$this->enqueue_add_fee_breakdown_to_order_notes( $order, $intent_id );
}
$this->set_intention_status_for_order( $order, $intent->get_status() );
$order->save();
} catch ( \Exception $e ) {
$order->add_order_note(
WC_Payments_Utils::esc_interpolated_html(
__( 'Capture authorization <strong>failed</strong> to complete.', 'woocommerce-payments' ),
[ 'strong' => '<strong>' ]
)
);
}
}
}
}
/**
* Creates a refund for the given order.
*
* @param WC_Order $order The order to refund.
* @param float $amount The amount to refund.
* @param string $reason The reason for the refund.
* @param array $line_items The line items to refund.
*
* @throws Exception If the refund creation fails.
*/
public function create_refund_for_order( WC_Order $order, float $amount, string $reason = '', array $line_items = [] ) {
$refund_params = [
'amount' => wc_format_decimal( $amount, wc_get_price_decimals() ),
'reason' => $reason,
'order_id' => $order->get_id(),
];
if ( $line_items ) {
$refund_params['line_items'] = $line_items;
}
$refund = wc_create_refund(
$refund_params
);
if ( is_wp_error( $refund ) ) {
throw new Exception( esc_html( $refund->get_error_message() ) );
}
return $refund;
}
/**
* Adds a note and metadata for a refund.
*
* @param WC_Order $order The order to refund.
* @param WC_Order_Refund $wc_refund The WC refund object.
* @param string $refund_id The refund ID.
* @param string|null $refund_balance_transaction_id The balance transaction ID of the refund.
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
* @throws Order_Not_Found_Exception
* @throws Exception
*/
public function add_note_and_metadata_for_created_refund( WC_Order $order, WC_Order_Refund $wc_refund, string $refund_id, ?string $refund_balance_transaction_id, bool $is_pending = false ): void {
$note = $this->generate_payment_created_refund_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order, $is_pending );
if ( ! $this->order_note_exists( $order, $note ) ) {
$order->add_order_note( $note );
}
// Use `successful` to maintain the backward compatibility with the previous WooPayments versions.
$this->set_wcpay_refund_status_for_order( $order, $is_pending ? Refund_Status::PENDING : 'successful' );
$this->set_wcpay_refund_id_for_refund( $wc_refund, $refund_id );
if ( isset( $refund_balance_transaction_id ) ) {
$this->set_wcpay_refund_transaction_id_for_order( $wc_refund, $refund_balance_transaction_id );
}
$order->save();
}
/**
* Handle a failed refund by adding a note, updating metadata, and optionally deleting the refund.
*
* @param WC_Order $order The order to add the note to.
* @param string $refund_id The ID of the failed refund.
* @param int $amount The refund amount in cents.
* @param string $currency The currency code.
* @param WC_Order_Refund|null $wc_refund The WC refund object to delete if provided.
* @param bool $is_cancelled Whether this is a cancellation rather than a failure. Default false.
* @param string|null $failure_reason The reason for the refund failure. Default null.
* @return void
*/
public function handle_failed_refund( WC_Order $order, string $refund_id, int $amount, string $currency, ?WC_Order_Refund $wc_refund = null, bool $is_cancelled = false, ?string $failure_reason = null ): void {
// Delete the refund if it exists.
if ( $wc_refund ) {
$wc_refund->delete();
}
$formatted_amount = WC_Payments_Explicit_Price_Formatter::get_explicit_price(
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
$order
);
// Handle insufficient balance case first to avoid duplicate notes.
if ( Refund_Failure_Reason::INSUFFICIENT_FUNDS === $failure_reason ) {
$this->handle_insufficient_balance_for_refund( $order, $amount );
} else {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1$s: the refund amount, %2$s: status (cancelled/unsuccessful), %3$s: WooPayments, %4$s: ID of the refund, %5$s: failure message or period */
__( 'A refund of %1$s was <strong>%2$s</strong> using %3$s (<code>%4$s</code>)%5$s', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'code' => '<code>',
]
),
$formatted_amount,
$is_cancelled ? __( 'cancelled', 'woocommerce-payments' ) : __( 'unsuccessful', 'woocommerce-payments' ),
'WooPayments',
$refund_id,
$is_cancelled ? '.' : ': ' . Refund_Failure_Reason::get_failure_message( $failure_reason ?? Refund_Failure_Reason::UNKNOWN ),
);
if ( $this->order_note_exists( $order, $note ) ) {
return;
}
$order->add_order_note( $note );
}
// If order has been fully refunded, change status to failed.
if ( Order_Status::REFUNDED === $order->get_status() ) {
$order->update_status( Order_Status::FAILED );
}
$this->set_wcpay_refund_status_for_order( $order, Refund_Status::FAILED );
$order->save();
}
/**
* Get content for the success order note.
*
* @param string $intent_id The payment intent ID related to the intent/order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $formatted_amount The formatted order total.
*
* @return string Note content.
*/
private function generate_payment_success_note( $intent_id, $charge_id, $formatted_amount ) {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the successfully charged amount, %2: WooPayments, %3: transaction ID of the payment */
__( 'A payment of %1$s was <strong>successfully charged</strong> using %2$s (<a>%3$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$formatted_amount,
'WooPayments',
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
}
/**
* Get content for the failure order note and additional message, if included.
*
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the note.
* @param string $formatted_amount The formatted order total.
*
* @return string Note content.
*/
private function generate_payment_failure_note( $intent_id, $charge_id, $message, $formatted_amount ) {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: WooPayments, %3: transaction ID of the payment */
__( 'A payment of %1$s <strong>failed</strong> using %2$s (<a>%3$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$formatted_amount,
'WooPayments',
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
if ( ! empty( $message ) ) {
$note .= ' ' . $message;
}
return $note;
}
/**
* Get content for the failure order note and additional message, if included.
*
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the note.
* @param string $formatted_amount The formatted order total.
*
* @return string Note content.
*/
private function generate_terminal_payment_failure_note( $intent_id, $charge_id, $message, $formatted_amount ) {
// Add charge_id to the transaction URL instead of intent_id for uniqueness.
$transaction_url = WC_Payments_Utils::compose_transaction_url( '', $charge_id );
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: WooPayments, %3: transaction ID of the payment, %4: timestamp */
__( 'A terminal payment of %1$s <strong>failed</strong> using %2$s (<a>%3$s</a>)', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$formatted_amount,
'WooPayments',
$intent_id ?? $charge_id
);
if ( ! empty( $message ) ) {
$note .= ' ' . $message;
}
return $note;
}
/**
* Generates the payment authorized order note.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return string
*/
private function generate_payment_authorized_note( $order, $intent_id, $charge_id ): string {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: WooPayments, %3: transaction ID of the payment */
__( 'A payment of %1$s was <strong>authorized</strong> using %2$s (<a>%3$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$this->get_order_amount( $order ),
'WooPayments',
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
return $note;
}
/**
* Generates the payment started order note.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
*
* @return string
*/
private function generate_payment_started_note( $order, $intent_id ): string {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: WooPayments, %3: intent ID of the payment */
__( 'A payment of %1$s was <strong>started</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'code' => '<code>',
]
),
$this->get_order_amount( $order ),
'WooPayments',
$intent_id
);
return $note;
}
/**
* Generates the successful capture order note.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return string
*/
private function generate_capture_success_note( $order, $intent_id, $charge_id ) {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the successfully charged amount, %2: WooPayments, %3: transaction ID of the payment */
__( 'A payment of %1$s was <strong>successfully captured</strong> using %2$s (<a>%3$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$this->get_order_amount( $order ),
'WooPayments',
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
return $note;
}
/**
* Generates the failure order note and additional message, if included.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
* @param string $message Optional message to add to the note.
*
* @return string
*/
private function generate_capture_failed_note( $order, $intent_id, $charge_id, $message ): string {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: WooPayments, %3: transaction ID of the payment */
__( 'A capture of %1$s <strong>failed</strong> to complete using %2$s (<a>%3$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$this->get_order_amount( $order ),
'WooPayments',
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
if ( ! empty( $message ) ) {
$note .= ' ' . $message;
}
return $note;
}
/**
* Get content for the capture expired note.
*
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return string Note content.
*/
private function generate_capture_expired_note( $intent_id, $charge_id ) {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: transaction ID of the payment */
__( 'Payment authorization has <strong>expired</strong> (<a>%1$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
}
/**
* Generates the capture cancelled order note.
*
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return string
*/
private function generate_capture_cancelled_note( $intent_id, $charge_id ): string {
$transaction_url = WC_Payments_Utils::compose_transaction_url( $intent_id, $charge_id );
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: transaction ID of the payment */
__( 'Payment authorization was successfully <strong>cancelled</strong> (<a>%1$s</a>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
WC_Payments_Utils::get_transaction_url_id( $intent_id, $charge_id )
);
}
/**
* Generates the fraud held for review order note.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
* @param string $charge_id The charge ID related to the intent/order.
*
* @return string
*/
private function generate_fraud_held_for_review_note( $order, $intent_id, $charge_id ): string {
$transaction_url = WC_Payments_Utils::compose_transaction_url(
$intent_id,
$charge_id,
[
'status_is' => Rule::FRAUD_OUTCOME_REVIEW,
'type_is' => 'order_note',
]
);
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the authorized amount, %2: transaction ID of the payment */
__( '&#x26D4; A payment of %1$s was <strong>held for review</strong> by one or more risk filters.<br><br><a>View more details</a>.', 'woocommerce-payments' ),
[
'&#x26D4;' => '&#x26D4;',
'strong' => '<strong>',
'br' => '<br>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$this->get_order_amount( $order )
);
return $note;
}
/**
* Generates the fraud blocked order note.
*
* @param WC_Order $order Order object.
*
* @return string
*/
private function generate_fraud_blocked_note( $order ): string {
$transaction_url = WC_Payments_Utils::compose_transaction_url(
$order->get_id(),
'',
[
'status_is' => Rule::FRAUD_OUTCOME_BLOCK,
'type_is' => 'order_note',
]
);
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the blocked amount, %2: transaction ID of the payment */
__( '&#x1F6AB; A payment of %1$s was <strong>blocked</strong> by one or more risk filters.<br><br><a>View more details</a>.', 'woocommerce-payments' ),
[
'&#x1F6AB;' => '&#x1F6AB;',
'strong' => '<strong>',
'br' => '<br>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$this->get_order_amount( $order )
);
return $note;
}
/**
* Get content for the dispute created order note.
*
* @param string $charge_id The ID of the disputes charge associated with this order.
* @param string $amount The disputed amount formatted currency value.
* @param string $reason The reason for the dispute human-readable text.
* @param string $due_by The deadline for responding to the dispute - formatted date string.
* @param bool $is_inquiry Whether the dispute is an inquiry or not.
*
* @return string Note content.
*/
private function generate_dispute_created_note( $charge_id, $amount, $reason, $due_by, $is_inquiry = false ) {
$dispute_url = $this->compose_dispute_url( $charge_id );
// Get merchant-friendly dispute reason description.
$reason = WC_Payments_Utils::get_dispute_reason_description( $reason );
if ( $is_inquiry ) {
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the disputed amount and currency; %2: the dispute reason; %3 the deadline date for responding to the inquiry */
__( 'A payment inquiry has been raised for %1$s with reason "%2$s". <a>Response due by %3$s</a>.', 'woocommerce-payments' ),
[
'a' => '<a href="%4$s" target="_blank" rel="noopener noreferrer">',
]
),
$amount,
$reason,
$due_by,
$dispute_url
);
}
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the disputed amount and currency; %2: the dispute reason; %3 the deadline date for responding to dispute */
__( 'Payment has been disputed for %1$s with reason "%2$s". <a>Response due by %3$s</a>.', 'woocommerce-payments' ),
[
'a' => '<a href="%4$s" target="_blank" rel="noopener noreferrer">',
]
),
$amount,
$reason,
$due_by,
$dispute_url
);
}
/**
* Get content for the dispute closed order note.
*
* @param string $charge_id The ID of the disputed charge associated with this order.
* @param string $status The status of the dispute.
* @param bool $is_inquiry Whether the dispute is an inquiry or not.
*
* @return string Note content.
*/
private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry = false ) {
$dispute_url = $this->compose_dispute_url( $charge_id );
if ( $is_inquiry ) {
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the dispute status */
__( 'Payment inquiry has been closed with status %1$s. See <a>payment status</a> for more details.', 'woocommerce-payments' ),
[
'a' => '<a href="%2$s" target="_blank" rel="noopener noreferrer">',
]
),
$status,
$dispute_url
);
}
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the dispute status */
__( 'Dispute has been closed with status %1$s. See <a>dispute overview</a> for more details.', 'woocommerce-payments' ),
[
'a' => '<a href="%2$s" target="_blank" rel="noopener noreferrer">',
]
),
$status,
$dispute_url
);
}
/**
* Generates the HTML note for a refunded payment.
*
* @param float $refunded_amount Amount refunded.
* @param string $refunded_currency Refund currency.
* @param string $wcpay_refund_id WCPay Refund ID.
* @param string $refund_reason Refund reason.
* @param WC_Order $order Order object.
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
*
* @return string HTML note.
*/
private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
$multi_currency_instance = WC_Payments_Multi_Currency();
$formatted_price = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );
$status_text = $is_pending ?
sprintf(
'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
__( 'is pending', 'woocommerce-payments' )
)
: __( 'was successfully processed', 'woocommerce-payments' );
if ( empty( $refund_reason ) ) {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund, %4: status text */
__( 'A refund of %1$s %4$s using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'code' => '<code>',
]
),
$formatted_price,
'WooPayments',
$wcpay_refund_id,
$status_text
);
} else {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: reason, %4: refund id, %5: status text */
__( 'A refund of %1$s %5$s using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
[
'code' => '<code>',
]
),
$formatted_price,
'WooPayments',
$refund_reason,
$wcpay_refund_id,
$status_text
);
}
return $note;
}
/**
* Composes url for dispute details page.
*
* @param string $charge_id The disputed charge ID.
*
* @return string Transaction details page url.
*/
private function compose_dispute_url( $charge_id ) {
return add_query_arg(
[
'page' => 'wc-admin',
'path' => rawurlencode( '/payments/transactions/details' ),
'id' => $charge_id,
],
admin_url( 'admin.php' )
);
}
/**
* Check if order is locked for payment processing
*
* @param WC_Order $order The order that is being paid.
* @param string $intent_id The id of the intent that is being processed.
*
* @return bool A flag that indicates whether the order is already locked.
*/
private function is_order_locked( $order, $intent_id = null ) {
$order_id = $order->get_id();
$transient_name = 'wcpay_processing_intent_' . $order_id;
$processing = get_transient( $transient_name );
// Block the process if the same intent is already being handled.
return ( '-1' === $processing || ( isset( $intent_id ) && $processing === $intent_id ) );
}
/**
* Lock an order for payment intent processing for 5 minutes.
*
* @param WC_Order $order The order that is being paid.
* @param string $intent_id The id of the intent that is being processed.
*
* @return void
*/
private function lock_order_payment( $order, $intent_id = null ) {
$order_id = $order->get_id();
$transient_name = 'wcpay_processing_intent_' . $order_id;
set_transient( $transient_name, empty( $intent_id ) ? '-1' : $intent_id, 5 * MINUTE_IN_SECONDS );
}
/**
* Unlocks an order for processing by payment intents.
*
* @param WC_Order $order The order that is being unlocked.
*
* @return void
*/
private function unlock_order_payment( $order ) {
$order_id = $order->get_id();
delete_transient( 'wcpay_processing_intent_' . $order_id );
}
/**
* Refreshes the order from the database, checks if it is locked, and locks it.
*
* TODO: Update to throw exceptions so try/catch can be used.
* TODO: Maybe add checks to see if there is already a successful intent, or the intent status passed is already set.
*
* @param WC_Order $order Order object.
* @param string $intent_id The ID of the intent associated with this order.
*
* @return bool
*/
private function order_prepared_for_processing( $order, $intent_id ) {
if ( ! is_a( $order, 'WC_Order' ) ) {
return false;
}
if ( $this->is_order_paid( $order ) ) {
return false;
}
if ( $this->is_order_locked( $order, $intent_id ) ) {
return false;
}
// Lock the order.
$this->lock_order_payment( $order, $intent_id );
return true;
}
/**
* Checks to see if the current order, and a fresh copy of the order from the database are paid.
*
* @param WC_Order $order The order being checked.
*
* @return boolean True if it has a paid status, false if not.
*/
private function is_order_paid( $order ) {
wp_cache_delete( $order->get_id(), 'posts' );
// Read the latest order properties from the database to avoid race conditions if webhook was handled during this request.
$clone_order = clone $order;
$clone_order->get_data_store()->read( $clone_order );
// Check if the order is already complete.
if ( function_exists( 'wc_get_is_paid_statuses' ) ) {
if ( $order->has_status( wc_get_is_paid_statuses() )
|| $clone_order->has_status( wc_get_is_paid_statuses() ) ) {
return true;
}
}
return false;
}
/**
* Completes order processing by updating the intent meta, unlocking the order, and saving the order.
*
* @param WC_Order $order Order object.
* @param string|null $intent_status The status of the intent related to this order.
*
* @return void
*/
private function complete_order_processing( $order, $intent_status = null ) {
if ( ! empty( $intent_status ) ) {
$this->set_intention_status_for_order( $order, $intent_status );
}
$this->unlock_order_payment( $order );
$order->save();
}
/**
* Gets the total for the order in explicit format.
*
* @param WC_Order $order Order object.
*
* @return string The formatted order total.
*/
private function get_order_amount( $order ) {
$multi_currency_instance = WC_Payments_Multi_Currency();
$order_price = $order->get_total();
$formatted_price = $multi_currency_instance->get_backend_formatted_wc_price( $order_price, [ 'currency' => $order->get_currency() ] );
return WC_Payments_Explicit_Price_Formatter::get_explicit_price( $formatted_price, $order );
}
/**
* Updates the order status and catches any exceptions so that processing can continue.
*
* @param WC_Order $order Order object.
* @param string $order_status The status to change the order to.
* @param null|string $intent_id The ID of the intent associated with this order.
*
* @throws Exception Throws exception if intent id is not included if order needs to be marked as paid.
*
* @return void
*/
private function update_order_status( $order, $order_status, $intent_id = '' ) {
try {
/**
* In this instance payment_complete is not an order status, but a flag to mark the order as paid. In a default WooCommerce store, the order
* may move to Processing or Completed status depending on the contents of the cart, so we let WooCommerce core decide what to do.
*/
if ( 'payment_complete' === $order_status ) {
if ( empty( $intent_id ) ) {
throw new Exception( __( 'Intent id was not included for payment complete status change.', 'woocommerce-payments' ) );
}
$order->payment_complete( $intent_id );
} else {
$order->update_status( $order_status );
}
} catch ( Exception $e ) {
// Continue further, something unexpected happened, but we can't really do anything with that.
Logger::log( 'Error when updating status for order ' . $order->get_id() . ': ' . $e->getMessage() );
}
}
/**
* Takes an intent object or array and returns our needed data as an array.
* This is needed due to intents can either be objects or arrays.
*
* @param WC_Payments_API_Abstract_Intention $intent Setup or payment intent to pull the data from.
*
* @return array The data we need to continue processing.
*/
private function get_intent_data( WC_Payments_API_Abstract_Intention $intent ): array {
$intent_data = [
'intent_id' => $intent->get_id(),
'intent_status' => $intent->get_status(),
'charge_id' => '',
'fraud_outcome' => $intent->get_metadata()['fraud_outcome'] ?? '',
'payment_method_type' => $intent->get_payment_method_type(),
];
if ( $intent instanceof WC_Payments_API_Payment_Intention ) {
$charge = $intent->get_charge();
$intent_data['charge_id'] = $charge ? $charge->get_id() : null;
$intent_data['error'] = $intent->get_last_payment_error();
}
return $intent_data;
}
/**
* Schedules an action to add the fee breakdown to order notes.
*
* @param WC_Order $order The order to add the note to.
* @param string $intent_id The intent ID for the order.
*
* @return void
*/
private function enqueue_add_fee_breakdown_to_order_notes( WC_Order $order, string $intent_id ) {
WC_Payments::get_action_scheduler_service()->schedule_job(
time(),
self::ADD_FEE_BREAKDOWN_TO_ORDER_NOTES,
[
'order_id' => $order->get_id(),
'intent_id' => $intent_id,
'is_test_mode' => WC_Payments::mode()->is_test(),
]
);
}
/**
* If an order object is passed in, return that, else try to get the order.
* This is needed due to mocked orders cannot be retrieved from the database in tests.
*
* @param mixed $order The order to be returned.
*
* @return WC_Order|WC_Order_Refund
*
* @throws Order_Not_Found_Exception
*/
private function get_order( $order ) {
$order = $this->is_order_type_object( $order ) ? $order : wc_get_order( $order );
if ( ! $this->is_order_type_object( $order ) ) {
throw new Order_Not_Found_Exception(
esc_html__( 'The requested order was not found.', 'woocommerce-payments' ),
'order_not_found'
);
}
return $order;
}
/**
* Checks to see if the given argument is an order type object.
*
* @param mixed $order The order to be checked.
*
* @return bool
*/
private function is_order_type_object( $order ): bool {
if ( is_a( $order, 'WC_Order' ) || is_a( $order, 'WC_Order_Refund' ) ) {
return true;
}
return false;
}
/**
* Checks to see if the intent data has just card set as the payment method type.
*
* @param array $intent_data The intent data obtained from get_intent_data.
*
* @return bool
*/
private function intent_has_card_payment_type( $intent_data ): bool {
return isset( $intent_data['payment_method_type'] ) && 'card' === $intent_data['payment_method_type'];
}
/**
* Countries where FROD balance is not supported.
*
* @var array
*/
const FROD_UNSUPPORTED_COUNTRIES = [ 'HK', 'SG', 'AE' ];
/**
* Handle insufficient balance for refund.
*
* @param WC_Order $order The order being refunded.
* @param int $stripe_amount The refund amount.
*/
public function handle_insufficient_balance_for_refund( WC_Order $order, int $stripe_amount ) {
$account_country = WC_Payments::get_account_service()->get_account_country();
$formatted_amount = wc_price(
WC_Payments_Utils::interpret_stripe_amount( $stripe_amount, $order->get_currency() ),
[ 'currency' => $order->get_currency() ]
);
if ( $this->is_frod_supported( $account_country ) ) {
$order->add_order_note( $this->get_frod_support_note( $formatted_amount ) );
} else {
$order->add_order_note( $this->get_insufficient_balance_note( $formatted_amount ) );
}
}
/**
* Attach Multibanco information to the order.
*
* @param WC_Order $order The order being paid.
* @param string $reference The Multibanco reference.
* @param string $entity The Multibanco entity.
* @param string $url The Multibanco URL.
* @param int $expiry The Multibanco expiry.
*/
public function attach_multibanco_info_to_order( WC_Order $order, string $reference, string $entity, string $url, int $expiry ): void {
$order->update_meta_data( self::WCPAY_MULTIBANCO_REFERENCE_META_KEY, $reference );
$order->update_meta_data( self::WCPAY_MULTIBANCO_ENTITY_META_KEY, $entity );
$order->update_meta_data( self::WCPAY_MULTIBANCO_URL_META_KEY, $url );
$order->update_meta_data( self::WCPAY_MULTIBANCO_EXPIRY_META_KEY, $expiry );
}
/**
* Get Multibanco information from the order.
*
* @param WC_Order $order The order.
* @return array
*/
public function get_multibanco_info_from_order( WC_Order $order ): array {
return [
'reference' => $order->get_meta( self::WCPAY_MULTIBANCO_REFERENCE_META_KEY ),
'entity' => $order->get_meta( self::WCPAY_MULTIBANCO_ENTITY_META_KEY ),
'url' => $order->get_meta( self::WCPAY_MULTIBANCO_URL_META_KEY ),
'expiry' => $order->get_meta( self::WCPAY_MULTIBANCO_EXPIRY_META_KEY ),
];
}
/**
* Store payment method details in the order meta.
*
* @param WC_Order $order The order.
* @param array $payment_method_details The payment method details.
* @return void
*/
public function store_payment_method_details( WC_Order $order, array $payment_method_details ): void {
$order->update_meta_data( self::PAYMENT_METHOD_DETAILS_META_KEY, wp_json_encode( $payment_method_details ) );
$order->save_meta_data();
}
/**
* Get cached payment method details from the order meta.
*
* @param WC_Order $order The order.
* @return array The payment method details.
*/
public function get_payment_method_details( WC_Order $order ): ?array {
$json = $order->get_meta( self::PAYMENT_METHOD_DETAILS_META_KEY );
if ( '' === $json ) {
return null;
}
return json_decode( $json, true );
}
/**
* Check if FROD is supported for the given country.
*
* @param string $country_code Two-letter country code.
* @return bool
*/
private function is_frod_supported( $country_code ) {
return ! in_array(
$country_code,
self::FROD_UNSUPPORTED_COUNTRIES,
true
);
}
/**
* Get the order note for FROD supported countries.
*
* @param string $formatted_amount The formatted refund amount.
* @return string
*/
private function get_frod_support_note( $formatted_amount ) {
$learn_more_url = 'https://woocommerce.com/document/woopayments/fees-and-debits/preventing-negative-balances/#adding-funds';
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %s: Formatted refund amount */
__( 'Refund of %s <strong>failed</strong> due to insufficient funds in your WooPayments balance. To prevent delays in refunding customers, please consider adding funds to your Future Refunds or Disputes (FROD) balance. <a>Learn more</a>.', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => '<a href="' . $learn_more_url . '" target="_blank" rel="noopener noreferrer">',
]
),
$formatted_amount
);
}
/**
* Get the order note for countries without FROD support.
*
* @param string $formatted_amount The formatted refund amount.
* @return string
*/
private function get_insufficient_balance_note( $formatted_amount ) {
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1$s: Formatted refund amount */
__( 'Refund of %1$s <strong>failed</strong> due to insufficient funds in your WooPayments balance.', 'woocommerce-payments' ),
[
'strong' => '<strong>',
]
),
$formatted_amount
);
}
}