includes/models/model.llms.transaction.php
<?php
/**
* LLMS_Transaction model class file
*
* @package LifterLMS/Models/Classes
*
* @since 3.0.0
* @version 7.0.0
*/
defined( 'ABSPATH' ) || exit;
/**
* LifterLMS order transactions
*
* @since 3.0.0
*
* @property string $api_mode API Mode of the gateway when the transaction was made [test|live].
* @property float $amount Transaction charge amount.
* @property string $currency Transaction's currency code.
* @property string $gateway_completed_date Datetime string when the transaction was completed by the gateway (if gateway supports).
* @property string $gateway_customer_id Gateway's unique ID for the customer who placed the order.
* @property float $gateway_fee_amount Fee charged to the user by the gateway for the transaction (if gateway supports).
* @property string $gateway_source_id Source Identifier from the gateway -- eg: credit card id or account id.
* @property string $gateway_source_description Short description of the source from the gateway. EG: Visa 1234.
* @property string $gateway_transaction_id Gateway's unique ID for the transaction.
* @property int $order_id ID of the related LLMS_Order.
* @property string $payment_type Type of payment. [recurring|single|trial].
* @property string $payment_gateway LifterLMS Payment Gateway ID (eg "paypal" or "stripe").
* @property float $refund_amount Amount refunded, will always be 0 until a refund is actually recorded.
* @property array $refund_data Array of arrays. Contains refund data for each refund recorded for this transaction.
*/
class LLMS_Transaction extends LLMS_Post_Model {
/**
* DB Post Type.
*
* @var string
*/
protected $db_post_type = 'llms_transaction';
/**
* Model Name/Type.
*
* @var string
*/
protected $model_post_type = 'transaction';
/**
* Post model properties.
*
* @var array
*/
protected $properties = array(
'api_mode' => 'text',
'amount' => 'float',
'currency' => 'text',
'gateway_completed_date' => 'text',
'gateway_customer_id' => 'text',
'gateway_fee_amount' => 'float',
'gateway_source_id' => 'text',
'gateway_source_description' => 'text',
'gateway_transaction_id' => 'text',
'order_id' => 'absint',
'payment_type' => 'text',
'payment_gateway' => 'text',
'refund_amount' => 'float',
'refund_data' => 'array',
);
/**
* Determines if the transaction can be refunded.
*
* Status must not be "failed" and total refunded amount must be less than order amount.
*
* @since 3.0.0
* @since 7.0.0 Made the return value filterable via the `llms_transaction_can_be_refunded` hook.
*
* @return boolean
*/
public function can_be_refunded() {
$can_be_refunded = true;
if ( in_array( $this->get( 'status' ), array( 'llms-txn-failed', 'llms-txn-pending' ), true ) ) {
$can_be_refunded = false;
} elseif ( $this->get_refundable_amount( array(), 'float' ) <= 0 ) {
$can_be_refunded = false;
}
/**
* Filters whether or not a transaction can be refunded.
*
* @since 7.0.0
*
* @param boolean $can_be_refunded Whether the transaction can be refunded.
* @param LLMS_Transaction $transaction The transaction object.
*/
return apply_filters( 'llms_transaction_can_be_refunded', $can_be_refunded, $this );
}
/**
* Generates a refund ID based on the refund method.
*
* When manually processing a refund an ID is generated, if processing via a gateway the data
* is passed to the {@see LLMS_Transaction::process_refund_via_gateway()} and ultimately passed
* to the gateway for processing (via the gateway's API). For custom methods processing is handled
* via the `llms_{$method}_refund_id` filter.
*
* @since 7.0.0
*
* @param string $method Refund processing method ID.
* @param float $amount The amount to refund.
* @param string $note Refund notes.
* @return string|boolean|WP_Error Returns the generated refund ID string or an error object. If a falsy value
* is returned the refund processing will fail with a generic error message.
*/
protected function generate_refund_id( $method, $amount, $note = '' ) {
if ( 'manual' === $method ) {
/**
* Filters the refund id for manual refunds.
*
* The default refund ID is a microtime string generated by `uniqid()`.
*
* @since 3.0.0
*
* @param string $refund_id The refund ID.
*/
$refund_id = apply_filters( 'llms_manual_refund_id', (string) uniqid() );
} elseif ( 'gateway' === $method ) {
$refund_id = $this->process_refund_via_gateway( $amount, $note );
} else {
/**
* Filters the refund ID for custom refund methods.
*
* This filter should return a string representing the refund ID as generated by the custom refund method.
*
* The dynamic portion of this hook, `{$method}`, represents the ID of the custom refund method.
*
* @since 3.0.0
*
* @param string|boolean|WP_Error $refund_id The generated refund ID or an error object. Returning a falsy value
* will result in the default error handling and no refund being recorded.
* @param string $method The method ID.
* @param LLMS_Transaction $transaction The transaction object.
* @param float $amount The refund amount.
* @param string $note The user-submitted refund note.
*/
$refund_id = apply_filters( "llms_{$method}_refund_id", false, $method, $this, $amount, $note );
}
return $refund_id;
}
/**
* Retrieves the amount of the transaction that can be refunded.
*
* @since 3.0.0
* @return float
*/
public function get_refundable_amount() {
$amount = $this->get_price( 'amount', array(), 'float' );
$refunded = $this->get_price( 'refund_amount', array(), 'float' );
return $amount - $refunded;
}
/**
* Retrieves the array of default arguments to pass to {@see LLMS_Transaction::create()} when creating a new post.
*
* @since 3.0.0
* @since 3.37.6 Add a default date information using `llms_current_time()`.
* Remove ordering placeholders from strftime().
* @since 5.9.0 Remove usage of deprecated `strftime()`.
*
* @param int $order_id LLMS_Order ID of the related order.
* @return array
*/
protected function get_creation_args( $order_id = 0 ) {
$date = llms_current_time( 'mysql' );
$title = sprintf(
// Translators: %1$d = Order ID; %2$s = Transaction creation date.
__( 'Transaction for Order #%1$d – %2$s', 'lifterlms' ),
$order_id,
date_format( date_create( $date ), 'M d, Y @ h:i A' )
);
// This filter is documented in includes/abstracts/abstract.llms.post.model.php.
return apply_filters(
"llms_{$this->model_post_type}_get_creation_args",
array(
'comment_status' => 'closed',
'ping_status' => 'closed',
'post_author' => 0,
'post_content' => '',
'post_date' => $date,
'post_excerpt' => '',
'post_password' => uniqid( 'order_' ),
'post_status' => 'llms-' . apply_filters( 'llms_default_order_status', 'txn-pending' ),
'post_title' => $title,
'post_type' => $this->get( 'db_post_type' ),
),
$this
);
}
/**
* Get the total amount of the transaction after deducting refunds
*
* @param array $price_args optional array of arguments that can be passed to llms_price()
* @param string $format optional format conversion method [html|raw|float]
* @return mixed
* @since 3.0.0
*/
public function get_net_amount( $price_args = array(), $format = 'html' ) {
$amount = $this->get_price( 'amount', array(), 'float' );
$refund = $this->get_price( 'refund_amount', array(), 'float' );
return llms_price( $amount - $refund, $price_args, $format );
}
/**
* Retrieves an instance of LLMS_Order for the transaction's parent order.
*
* @since 3.0.0
*
* @return LLMS_Order
*/
public function get_order() {
return new LLMS_Order( $this->get( 'order_id' ) );
}
/**
* Retrieves the payment gateway instance for the transactions payment gateway.
*
* @since 3.0.0
*
* @return LLMS_Payment_Gateway|WP_Error
*/
public function get_gateway() {
$gateways = llms()->payment_gateways();
$gateway = $gateways->get_gateway_by_id( $this->get( 'payment_gateway' ) );
if ( $gateway && $gateway->is_enabled() || is_admin() ) {
return $gateway;
}
// Translators: %s = The payment gateway ID.
return new WP_Error(
'error',
sprintf(
__( 'Payment gateway %s could not be located or is no longer enabled', 'lifterlms' ),
$this->get( 'payment_gateway' )
)
);
}
/**
* Retrieves the title of the refund method using during refund processing.
*
* This method records the method used to process a refund in the refund order note.
*
* @since 7.0.0
*
* @param string $method The refund method ID.
* @return string
*/
protected function get_refund_method_title( $method ) {
switch ( $method ) {
case 'manual':
$method_title = __( 'manual refund', 'lifterlms' );
break;
case 'gateway':
$method_title = $this->get_gateway()->get_admin_title();
break;
default:
/**
* Filters the refund method title for custom refund methods.
*
* The dynamic portion of this hook, `{$method}`, represents the ID of the custom refund method.
*
* @since 3.0.0
* @deprecated 7.0.0 Replaced with `llms_{$method}_refund_title`.
*
* @param string $method The method ID.
*/
$method_title = apply_filters_deprecated( "llms_{$method}_title", array( $method ), '7.0.0', "llms_{$method}_refund_title" );
if ( $method_title !== $method ) {
return $method_title;
}
/**
* Filters the refund method title for custom refund methods.
*
* The dynamic portion of this hook, `{$method}`, represents the ID of the custom refund method.
*
* @since Unknown
*
* @param string $method The method ID.
* @param LLMS_Transaction $transaction The transaction object.
*/
$method_title = apply_filters( "llms_{$method}_refund_title", $method, $this );
}
return $method_title;
}
/**
* Retrieves a single refund by ID.
*
* @since 7.0.0
*
* @param string $id The refund ID.
* @return array|boolean {
* An array of refund data. Returns `false` if the ID isn't found.
*
* @type string id The refund ID
* @type string date The date the refund was recorded in MySQL date format `Y-m-d H:i:s`.
* @type string method The processing method ID. Defaults are "manual" or "gateway". Custom values can be implemented via hooks.
* @type float amount The amount of the refund.
* }
*/
public function get_refund( $id ) {
$refunds = $this->get_refunds();
return $refunds[ $id ] ?? false;
}
/**
* Retrieves a list of refunds against the transaction.
*
* @since 7.0.0
*
* @return array[] An array of refund arrays as described by {@see LLMS_Transaction::get_refund()}.
*/
public function get_refunds() {
return $this->get_array( 'refund_data' );
}
/**
* Processes a refund against the transaction.
*
* This method is called called from the admin panel by clicking a refund (manual or gateway) button.
*
* @since 3.0.0
* @since 7.0.0 Refactored code into multiple methods.
*
* @see LLMS_Meta_Box_Order_Transactions::save_refund()
*
* @param float $amount Amount to refund.
* @param string $note Optional note to record as an order note. This is passed to the gateway to do store in the gateway if available.
* @param string $method Method used to refund, either "manual" (available for all transactions) or "gateway" (when supported by the gateway that processed the transaction).
* @return string|WP_Error A refund ID on success or an error object.
*/
public function process_refund( $amount, $note = '', $method = 'manual' ) {
// Ensure the transaction is still eligible for a refund.
if ( ! $this->can_be_refunded() ) {
return new WP_Error(
'llms-txn-refund-not-eligible',
__( 'The selected transaction is not eligible for a refund.', 'lifterlms' )
);
}
$amount = floatval( $amount );
// Ensure we can refund the requested amount.
$refundable = $this->get_refundable_amount();
if ( $amount > $refundable ) {
return new WP_Error(
'llms-txn-refund-amount-too-high',
sprintf(
// Translators: %1$s = The requested refund amount; %2$s = the available refundable amount.
__( 'Requested refund amount was %1$s, the maximum possible refund for this transaction is %2$s.', 'lifterlms' ),
llms_price( $amount ),
llms_price( $refundable )
)
);
}
$id = $this->generate_refund_id( $method, $amount, $note );
if ( is_string( $id ) ) {
$this->record_refund( compact( 'amount', 'id', 'method' ), $note );
} elseif ( ! is_wp_error( $id ) ) {
$id = new WP_Error( 'llms-txn-refund-unknown-error', __( 'An unknown error occurred while processing the refund.', 'lifterlms' ) );
}
return $id;
}
/**
* Processes a refund via the gateway that processed the transaction.
*
* @since 7.0.0
*
* @param float $amount The refund amount.
* @param string $note Refund order note.
* @return string|WP_Error The refund ID or an error object.
*/
protected function process_refund_via_gateway( $amount, $note = '' ) {
$gateway = $this->get_gateway();
if ( is_wp_error( $gateway ) ) {
return new WP_Error(
'llms-txn-refund-gateway-invalid',
sprintf(
// Translators: %s = the payment gateway ID.
__( 'Selected gateway "%s" is inactive or invalid.', 'lifterlms' ),
$this->get( 'payment_gateway' )
)
);
}
if ( ! $gateway->supports( 'refunds' ) ) {
return new WP_Error(
'llms-txn-refund-gateway-support',
sprintf(
// Translators: %s = the payment gateway admin title.
__( 'Selected gateway "%s" does not support refunds.', 'lifterlms' ),
$gateway->get_admin_title()
)
);
}
return $gateway->process_refund( $this, $amount, $note );
}
/**
* Records a refund against the transaction.
*
* This method performs no validations and assumes that the refund has already been verified against
* the refund method and current transaction restrictions.
*
* If the refund data isn't validated, try using {@see LLMS_Transaction::process_refund()} instead.
*
* @since 7.0.0
*
* @param array $refund {
* Refund arguments.
*
* @type float $amount The refund amount.
* @type string $id The generated refund ID.
* @type string $method The refund processing method ID.
* @type string $date The refund date in MySQL date format. If not supplied, the current time is used.
* }
* @param string $note User-submitted refund note to add to the order alongside the refund.
* @return void
*/
public function record_refund( $refund, $note = '' ) {
$refund = wp_parse_args(
$refund,
array(
'amount' => 0.00,
'id' => '',
'method' => '',
'date' => llms_current_time( 'mysql' ),
)
);
// Record the note.
$this->record_refund_note( $note, $refund['amount'], $refund['id'], $refund['method'] );
// Update the refunded amount.
$refund_amount = $this->get( 'refund_amount' );
$new_amount = ! $refund_amount ? $refund['amount'] : $refund_amount + $refund['amount'];
$this->set( 'refund_amount', $new_amount );
// Record refund metadata.
$refund_data = $this->get_refunds();
/**
* Filters the stored refund data before saving it.
*
* @since Unknown
*
* @param array $refund {
* An associative array of refund data.
*
* @type float $amount The refund amount.
* @type string $date The refund date in MySQL date format: `Y-m-d H:i:s`.
* @type string $id The refund ID.
* @type string $method The refund method ID.
* }
* @param LLMS_Transaction $transaction The transaction object.
* @param float $amount The refund amount.
* @param string $method The refund method ID
*/
$refund_data[ $refund['id'] ] = apply_filters( 'llms_transaction_refund_data', $refund, $this, $refund['amount'], $refund['method'] );
$this->set( 'refund_data', $refund_data );
// Update status.
$this->set( 'status', 'llms-txn-refunded' );
}
/**
* Records an order note associated with a refund.
*
* @since 7.0.0
*
* @param string $note User-submitted refund note data to add to the order alongside the refund.
* @param float $amount The refund amount.
* @param string $refund_id The generated refund ID.
* @param string $method The refund processing method ID.
* @return int The WP_Comment ID of the recorded order note.
*/
private function record_refund_note( $note, $amount, $refund_id, $method ) {
/**
* Filters user-submitted transaction refund order note.
*
* @since Unknown.
*
* @param string $note The user-submitted order note text.
* @param LLMS_Transaction $transaction The transaction object.
* @param float $amount The refund amount.
* @param string $method The ID of the refund method.
*/
$orig_note = apply_filters( 'llms_transaction_refund_note', $note, $this, $amount, $method );
$note = sprintf(
// Translators: %1$s = The refund amount; %2$d the transaction ID; %3$s The refund method name; %4$s = the refund ID.
__( 'Refunded %1$s for transaction #%2$d via %3$s [Refund ID: %4$s]', 'lifterlms' ),
wp_strip_all_tags( llms_price( $amount ) ),
$this->get( 'id' ),
$this->get_refund_method_title( $method ),
$refund_id
);
if ( $orig_note ) {
$note .= "\r\n";
$note .= __( 'Refund Notes: ', 'lifterlms' );
$note .= "\r\n";
$note .= $orig_note;
}
// Record the note.
return $this->get_order()->add_note( $note, true );
}
/**
* Translation wrapper for {@see LLMS_Transaction::get()` which enables l10n of database values.
*
* @since 3.0.0
*
* @param string $key Key to retrieve.
* @return string
*/
public function translate( $key ) {
$val = $this->get( $key );
switch ( $key ) {
case 'payment_type':
if ( 'single' === $val ) {
$val = __( 'Single', 'lifterlms' );
} elseif ( 'recurring' === $val ) {
$val = __( 'Recurring', 'lifterlms' );
} elseif ( 'trial' === $val ) {
$val = __( 'Trial', 'lifterlms' );
}
break;
default:
$val = $val;
}
return $val;
}
}