*/
private $totals_before_recalculation = array();
public function __construct() {
add_action( 'wp_ajax_apd_set_order_deposit', array( $this, 'ajax_set_order_deposit' ) );
add_action( 'wp_ajax_apd_remove_order_deposit', array( $this, 'ajax_remove_order_deposit' ) );
// Saving the order screen, "Recalculate" and booking plugins that add items all run
// calculate_totals(), which resets the total to the full value.
add_action( 'woocommerce_order_before_calculate_totals', array( $this, 'remember_total_before_recalculation' ), 10, 2 );
add_action( 'woocommerce_order_after_calculate_totals', array( $this, 'restore_deposit_total_after_recalculation' ), 10, 2 );
}
/**
* Whether administrators may set deposits on orders from the order screen.
*
* @return bool
*/
public static function is_enabled() {
return 'yes' === apd_get_option( 'admin_order_deposit', 'yes' );
}
/**
* Whether a deposit can be added to an order that does not have one yet.
*
* Only orders that have not been paid qualify. A new order has to be saved first,
* because reloading the "Add order" screen starts a different draft.
*
* @param WC_Order $order Order object.
* @return bool
*/
public static function can_add_deposit( $order ) {
if ( ! $order instanceof WC_Order || 'shop_order' !== $order->get_type() || APD_Order::is_deposit_order( $order ) ) {
return false;
}
/**
* Order statuses on which an administrator may add a deposit.
*
* @param string[] $statuses Statuses without the wc- prefix.
* @param WC_Order $order Order object.
*/
$statuses = (array) apply_filters( 'apd_admin_order_deposit_statuses', array( 'pending', 'on-hold', 'partially-paid' ), $order );
return in_array( $order->get_status(), $statuses, true ) && ! $order->get_total_refunded();
}
/**
* Whether the deposit on an order can still be changed or removed.
*
* Once any money has been recorded the deposit is part of the payment history, so
* it is left alone.
*
* @param WC_Order $order Order object.
* @return bool
*/
public static function can_edit_deposit( $order ) {
if ( ! $order instanceof WC_Order || ! APD_Order::is_deposit_order( $order ) ) {
return false;
}
return ! APD_Order::is_initial_deposit_paid( $order )
&& floatval( $order->get_meta( '_apd_amount_paid' ) ) <= 0
&& ! APD_Order::has_pending_balance_payment( $order )
&& ! $order->get_total_refunded()
&& ! in_array( $order->get_status(), array( 'cancelled', 'refunded', 'failed' ), true );
}
/**
* Work out the deposit for an order value.
*
* @param float $full_total Full order value.
* @param array $rule Deposit rule: type (fixed|percentage) and value.
* @return float
*/
public static function calculate_deposit( $full_total, $rule ) {
$value = isset( $rule['value'] ) ? floatval( $rule['value'] ) : 0;
if ( isset( $rule['type'] ) && 'percentage' === $rule['type'] ) {
$value = ( $full_total * $value ) / 100;
}
return round( max( 0, $value ), wc_get_price_decimals() );
}
/**
* Render the set / edit deposit controls inside the Deposit Details metabox.
*
* The metabox sits inside the order form, so the inputs carry no name attribute:
* they are sent by AJAX only and never posted with the order itself.
*
* @param WC_Order $order Order object.
*/
public static function render_form( $order ) {
$is_edit = APD_Order::is_deposit_order( $order );
if ( $is_edit ) {
$full_total = floatval( $order->get_meta( '_apd_total_amount' ) );
$rule = $order->get_meta( '_apd_deposit_rule' );
if ( ! is_array( $rule ) || empty( $rule['type'] ) ) {
$rule = array(
'type' => 'fixed',
'value' => floatval( $order->get_meta( '_apd_deposit_amount' ) ),
);
}
} else {
$full_total = floatval( $order->get_total() );
$default_type = apd_get_option( 'deposit_type', 'percentage' );
$rule = array(
'type' => 'fixed' === $default_type ? 'fixed' : 'percentage',
'value' => floatval( apd_get_option( 'deposit_value', 50 ) ),
);
}
$currency_symbol = get_woocommerce_currency_symbol( $order->get_currency() );
$field_style = 'width:100%;max-width:100%;box-sizing:border-box;';
?>
General.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
$order_id = absint( $_POST['order_id'] ?? 0 );
$type = sanitize_key( wp_unslash( $_POST['deposit_type'] ?? '' ) );
$value = round( floatval( wc_format_decimal( wp_unslash( $_POST['deposit_value'] ?? 0 ) ) ), 4 );
$mark_received = 'yes' === sanitize_key( wp_unslash( $_POST['mark_received'] ?? '' ) );
$order = $order_id ? wc_get_order( $order_id ) : false;
$decimals = wc_get_price_decimals();
if ( ! $order instanceof WC_Order || ! in_array( $type, array( 'fixed', 'percentage' ), true ) ) {
wp_send_json_error( __( 'Invalid data.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
$is_edit = APD_Order::is_deposit_order( $order );
if ( $is_edit && ! self::can_edit_deposit( $order ) ) {
wp_send_json_error( __( 'A payment has already been recorded on this order, so its deposit can no longer be changed.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
if ( ! $is_edit && ! self::can_add_deposit( $order ) ) {
wp_send_json_error( __( 'A deposit can only be added to an unpaid order (Pending payment, On hold or Partially Paid).', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
// On a deposit order the order total is the amount due now, not the order value.
$full_total = round( $is_edit ? floatval( $order->get_meta( '_apd_total_amount' ) ) : floatval( $order->get_total() ), $decimals );
if ( $full_total <= 0 ) {
wp_send_json_error( __( 'The order total is zero. Add the order items and save the order first.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
if ( $value <= 0 || ( 'percentage' === $type && $value >= 100 ) ) {
wp_send_json_error( __( 'Enter a deposit greater than zero (a percentage must be below 100).', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
$rule = array(
'type' => $type,
'value' => $value,
);
$deposit = self::calculate_deposit( $full_total, $rule );
if ( $deposit <= 0 || $deposit >= $full_total ) {
wp_send_json_error(
sprintf(
/* translators: %s: full order total. */
__( 'The deposit must be more than zero and less than the order total (%s).', 'advanced-partial-payment-or-deposit-for-woocommerce' ),
wp_strip_all_tags( wc_price( $full_total, array( 'currency' => $order->get_currency() ) ) )
)
);
}
APD_Order::apply_deposit_meta( $order, $full_total, $deposit );
$order->update_meta_data( '_apd_deposit_rule', $rule );
$order->add_order_note(
sprintf(
/* translators: 1: deposit amount, 2: full order total, 3: balance due after the deposit. */
__( 'Deposit of %1$s set by an administrator. Order total %2$s, balance after the deposit %3$s.', 'advanced-partial-payment-or-deposit-for-woocommerce' ),
wc_price( $deposit, array( 'currency' => $order->get_currency() ) ),
wc_price( $full_total, array( 'currency' => $order->get_currency() ) ),
wc_price( $full_total - $deposit, array( 'currency' => $order->get_currency() ) )
),
0,
true
);
$order->save();
if ( $mark_received && ! APD_Order::record_payment( $order->get_id(), $deposit, __( 'Deposit received (recorded by admin)', 'advanced-partial-payment-or-deposit-for-woocommerce' ) ) ) {
wp_send_json_error( __( 'The deposit was set, but recording it as received failed. Use Record Manual Payment.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
wp_send_json_success(
$mark_received
? __( 'Deposit set and recorded as received.', 'advanced-partial-payment-or-deposit-for-woocommerce' )
: __( 'Deposit set.', 'advanced-partial-payment-or-deposit-for-woocommerce' )
);
}
/**
* AJAX: remove an unpaid deposit and return the order to full payment.
*/
public function ajax_remove_order_deposit() {
check_ajax_referer( 'apd_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( __( 'Permission denied.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
$order_id = absint( $_POST['order_id'] ?? 0 );
$order = $order_id ? wc_get_order( $order_id ) : false;
if ( ! self::can_edit_deposit( $order ) ) {
wp_send_json_error( __( 'This deposit cannot be removed because a payment has already been recorded.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
$full_total = round( floatval( $order->get_meta( '_apd_total_amount' ) ), wc_get_price_decimals() );
foreach ( self::get_order_deposit_meta_keys() as $meta_key ) {
$order->delete_meta_data( $meta_key );
}
$order->set_total( $full_total );
$order->add_order_note(
sprintf(
/* translators: %s: full order total. */
__( 'Deposit removed by an administrator. The full order total of %s is now due.', 'advanced-partial-payment-or-deposit-for-woocommerce' ),
wc_price( $full_total, array( 'currency' => $order->get_currency() ) )
),
0,
true
);
$order->save();
wp_send_json_success( __( 'Deposit removed.', 'advanced-partial-payment-or-deposit-for-woocommerce' ) );
}
/**
* Remember a deposit order's payable total before WooCommerce recalculates it.
*
* @param bool $and_taxes Whether taxes are recalculated too.
* @param WC_Order $order Order object.
*/
public function remember_total_before_recalculation( $and_taxes, $order ) {
if ( $order instanceof WC_Order && APD_Order::is_deposit_order( $order ) ) {
$this->totals_before_recalculation[ $order->get_id() ] = (float) $order->get_total();
}
}
/**
* Put a deposit order's payable total back after WooCommerce recalculates it.
*
* calculate_totals() sums the items into the full order value. On a deposit order the
* total must stay the amount due in the current payment (gateways charge it), so:
* - nothing paid yet: the recalculated sum becomes the new order value and the deposit
* is worked out again from its rule, so added or removed items carry through;
* - something paid: the total goes back to what it was, and the order value only
* follows the items while WooCommerce still allows them to be edited.
*
* @param bool $and_taxes Whether taxes were recalculated too.
* @param WC_Order $order Order object.
*/
public function restore_deposit_total_after_recalculation( $and_taxes, $order ) {
if ( ! $order instanceof WC_Order || ! APD_Order::is_deposit_order( $order ) ) {
return;
}
$order_id = $order->get_id();
$before = $this->totals_before_recalculation[ $order_id ] ?? null;
unset( $this->totals_before_recalculation[ $order_id ] );
$decimals = wc_get_price_decimals();
$recalculated = round( (float) $order->get_total(), $decimals );
$full_total = round( floatval( $order->get_meta( '_apd_total_amount' ) ), $decimals );
$amount_paid = round( floatval( $order->get_meta( '_apd_amount_paid' ) ), $decimals );
$currency = array( 'currency' => $order->get_currency() );
if ( self::can_edit_deposit( $order ) ) {
$rule = $order->get_meta( '_apd_deposit_rule' );
// Checkout deposits carry no rule: keep the amount the customer was quoted.
if ( ! is_array( $rule ) || empty( $rule['type'] ) ) {
$rule = array(
'type' => 'fixed',
'value' => floatval( $order->get_meta( '_apd_deposit_amount' ) ),
);
$order->update_meta_data( '_apd_deposit_rule', $rule );
}
// A deposit that no longer leaves anything for later is simply the full amount.
$deposit = min( self::calculate_deposit( $recalculated, $rule ), $recalculated );
if ( 0.0 !== round( $recalculated - $full_total, $decimals ) ) {
$order->add_order_note(
sprintf(
/* translators: 1: new order total, 2: deposit due. */
__( 'Order total recalculated to %1$s. Deposit due: %2$s.', 'advanced-partial-payment-or-deposit-for-woocommerce' ),
wc_price( $recalculated, $currency ),
wc_price( $deposit, $currency )
)
);
}
$order->update_meta_data( '_apd_total_amount', $recalculated );
$order->update_meta_data( '_apd_deposit_amount', $deposit );
$order->update_meta_data( '_apd_balance_due', $recalculated );
$order->set_total( $deposit );
return;
}
$balance_due = round( floatval( $order->get_meta( '_apd_balance_due' ) ), $decimals );
if ( $order->is_editable() && 0.0 !== round( $recalculated - $full_total, $decimals ) ) {
$new_balance = max( 0, round( $recalculated - $amount_paid, $decimals ) );
$order->update_meta_data( '_apd_total_amount', $recalculated );
$order->update_meta_data( '_apd_balance_due', $new_balance );
$order->add_order_note(
sprintf(
/* translators: 1: new order total, 2: balance due. */
__( 'Order total recalculated to %1$s. Balance due: %2$s.', 'advanced-partial-payment-or-deposit-for-woocommerce' ),
wc_price( $recalculated, $currency ),
wc_price( $new_balance, $currency )
)
);
// A total that stood for the balance follows the new balance.
if ( null !== $before && round( $before - $balance_due, $decimals ) === 0.0 && ! APD_Order::has_pending_balance_payment( $order ) ) {
$before = $new_balance;
}
}
if ( null !== $before ) {
$order->set_total( $before );
}
}
/**
* Meta keys that make up an order's deposit data.
*
* @return string[]
*/
private static function get_order_deposit_meta_keys() {
return array(
'_apd_is_deposit',
'_apd_deposit_amount',
'_apd_total_amount',
'_apd_deposit_paid',
'_apd_amount_paid',
'_apd_balance_due',
'_apd_payment_history',
'_apd_deposit_rule',
'_apd_manual_status',
'_apd_balance_payment_pending',
'_apd_balance_payment_awaiting_offline',
);
}
}