[ to … ]. * * @return array */ public static function transitions() { return array( 'pending' => array( 'approved', 'received', 'completed', 'rejected', 'cancelled' ), 'approved' => array( 'received', 'completed', 'rejected', 'cancelled' ), 'received' => array( 'completed', 'rejected' ), ); } /** * Can the request move to a status? * * @param Request $request Request. * @param string $to Target status. * @return bool */ public static function can( Request $request, $to ) { $map = self::transitions(); return isset( $map[ $request->status() ] ) && in_array( $to, $map[ $request->status() ], true ); } /** * Approve. * * @param Request $request Request. * @param string $message Message for the customer (optional). * @param string $actor admin | system. * @return Request|\WP_Error */ public static function approve( Request $request, $message = '', $actor = 'admin' ) { if ( $request->needs_shipping_back() && '' === trim( (string) Settings::get( 'return_address' ) ) && '' === trim( (string) $message ) ) { return new \WP_Error( 'rtkt_no_address', __( 'The customer has items to send back, but no return address is set. Add it in ReturnKit → Settings → Sending items back, or write where to send the parcel in the message.', 'returnkit' ) ); } return self::move( $request, 'approved', $message, $actor ); } /** * Decline (a reason is required: the customer is told why). * * @param Request $request Request. * @param string $message Reason for the customer. * @return Request|\WP_Error */ public static function reject( Request $request, $message ) { if ( '' === trim( (string) $message ) ) { return new \WP_Error( 'rtkt_message', __( 'Please tell the customer why the request is declined.', 'returnkit' ) ); } return self::move( $request, 'rejected', $message, 'admin' ); } /** * Customer cancels their own pending request. * * @param Request $request Request. * @return Request|\WP_Error */ public static function cancel( Request $request ) { if ( 'pending' !== $request->status() ) { return new \WP_Error( 'rtkt_status', __( 'This request can no longer be cancelled.', 'returnkit' ) ); } return self::move( $request, 'cancelled', '', 'customer' ); } /** * Mark items received, optionally putting them back in stock. * * @param Request $request Request. * @param array $qtys item row ID => received quantity (missing = all). * @param bool $restock Put received items back in stock. * @return Request|\WP_Error */ public static function receive( Request $request, array $qtys = array(), $restock = true ) { if ( ! self::can( $request, 'received' ) ) { return new \WP_Error( 'rtkt_status', __( 'Items can\'t be marked as received at this stage.', 'returnkit' ) ); } foreach ( $request->items() as $item ) { $qty = array_key_exists( (int) $item['id'], $qtys ) ? absint( $qtys[ (int) $item['id'] ] ) : (int) $item['qty']; Requests::update_item( (int) $item['id'], array( 'received_qty' => min( (int) $item['qty'], $qty ) ) ); } $meta = $request->all_meta(); $meta['received'] = time(); Requests::update( $request->id(), array( 'meta' => $meta ) ); $request = Requests::get( $request->id() ); if ( $restock ) { self::restock( $request ); } return self::move( $request, 'received', '', 'admin' ); } /** * Refund through WooCommerce and complete the request. * * @param Request $request Request. * @param array $args amount (float), restock (bool), gateway (bool), * shipping (bool, include delivery costs), message. * @return Request|\WP_Error */ public static function refund( Request $request, array $args ) { $args = wp_parse_args( $args, array( 'amount' => null, 'restock' => true, 'gateway' => true, 'shipping' => false, 'message' => '', ) ); if ( ! self::can( $request, 'completed' ) ) { return new \WP_Error( 'rtkt_status', __( 'This request can\'t be refunded at this stage.', 'returnkit' ) ); } $order = $request->order(); if ( ! $order ) { return new \WP_Error( 'rtkt_no_order', __( 'The order for this request no longer exists, so it can\'t be refunded here.', 'returnkit' ) ); } $plan = self::refund_plan( $request, $order, (bool) $args['shipping'] ); // No item lines (a withdrawal described in words): any amount up to // what's left on the order, entered by staff. $max = $plan['lines'] ? min( $plan['total'], (float) $order->get_remaining_refund_amount() ) : (float) $order->get_remaining_refund_amount(); if ( ! $plan['lines'] && null === $args['amount'] ) { return new \WP_Error( 'rtkt_amount', __( 'Enter the amount to refund.', 'returnkit' ) ); } $amount = null === $args['amount'] ? $max : round( (float) $args['amount'], wc_get_price_decimals() ); if ( $amount <= 0 ) { return new \WP_Error( 'rtkt_amount', __( 'Enter a refund amount greater than zero.', 'returnkit' ) ); } if ( $amount - $max > 0.0001 ) { /* translators: %s: maximum amount. */ return new \WP_Error( 'rtkt_amount', sprintf( __( 'The refund can be at most %s.', 'returnkit' ), wp_strip_all_tags( wc_price( $max, array( 'currency' => $order->get_currency() ) ) ) ) ); } $gateway = (bool) $args['gateway'] && self::gateway_can_refund( $order ); $lines = self::scale_lines( $plan['lines'], $plan['total'] > 0 ? $amount / $plan['total'] : 0, $amount ); /* translators: %s: request number. */ $reason = sprintf( __( 'ReturnKit %s', 'returnkit' ), $request->number() ); if ( '' !== trim( (string) $args['message'] ) ) { $reason .= ': ' . wp_strip_all_tags( (string) $args['message'] ); } $refund = wc_create_refund( array( 'amount' => $amount, 'reason' => $reason, 'order_id' => $order->get_id(), 'line_items' => $lines, 'refund_payment' => $gateway, 'restock_items' => false, ) ); if ( is_wp_error( $refund ) ) { /* translators: %s: error from WooCommerce or the payment gateway. */ Log::add( $request->id(), 'refund_failed', sprintf( __( 'Refund failed: %s', 'returnkit' ), $refund->get_error_message() ), 'admin' ); return $refund; } // These units now count in WooCommerce's refunded quantities. foreach ( $request->items() as $item ) { if ( isset( $lines[ (int) $item['order_item_id'] ] ) ) { Requests::update_item( (int) $item['id'], array( 'refunded_qty' => (int) $item['qty'] ) ); } } $request = Requests::get( $request->id() ); if ( $args['restock'] ) { self::restock( $request ); } $meta = $request->all_meta(); $meta['refunds'] = array_merge( (array) $request->meta( 'refunds', array() ), array( $refund->get_id() ) ); Requests::update( $request->id(), array( 'refunded' => $request->refunded() + $amount, 'meta' => $meta, ) ); Log::add( $request->id(), 'refunded', sprintf( /* translators: 1: amount, 2: "through the payment gateway" or "manually". */ __( 'Refunded %1$s %2$s.', 'returnkit' ), wp_strip_all_tags( wc_price( $amount, array( 'currency' => $order->get_currency() ) ) ), $gateway ? __( 'through the payment gateway', 'returnkit' ) : __( 'manually (no money moved by the gateway)', 'returnkit' ) ), 'admin', array( 'refund_id' => $refund->get_id(), 'amount' => $amount, 'gateway' => $gateway, ) ); $request = Requests::get( $request->id() ); return self::move( $request, 'completed', (string) $args['message'], 'admin', array( 'outcome' => 'refunded', 'amount' => $amount, 'gateway' => $gateway, ) ); } /** * Complete without a refund (exchange shipped, credit issued, or settled * outside the store). * * @param Request $request Request. * @param string $message Message for the customer. * @param array $data Extra data for listeners (outcome …). * @param string $actor admin | system. * @return Request|\WP_Error */ public static function complete( Request $request, $message = '', array $data = array(), $actor = 'admin' ) { return self::move( $request, 'completed', $message, $actor, $data + array( 'outcome' => 'completed' ) ); } /** * Add a note, private or sent to the customer. * * @param Request $request Request. * @param string $text Note. * @param bool $to_customer Email it to the customer. * @return void */ public static function note( Request $request, $text, $to_customer = false ) { $text = sanitize_textarea_field( (string) $text ); if ( '' === $text ) { return; } Log::add( $request->id(), $to_customer ? 'message' : 'note', $text, 'admin' ); if ( $to_customer ) { /** * A message for the customer was added to a request. * * @param Request $request Request. * @param string $text Message. */ do_action( 'rtkt_customer_message', $request, $text ); } } /** * Change status, log it, note it on the order and announce it. * * @param Request $request Request. * @param string $to Target status. * @param string $message Message for the customer. * @param string $actor Who. * @param array $data Extra data. * @return Request|\WP_Error */ private static function move( Request $request, $to, $message, $actor, array $data = array() ) { if ( ! self::can( $request, $to ) ) { return new \WP_Error( 'rtkt_status', __( 'That action isn\'t possible at this stage. The page may be out of date: reload it and try again.', 'returnkit' ) ); } $from = $request->status(); Requests::update( $request->id(), array( 'status' => $to ) ); $text = sprintf( /* translators: 1: old status, 2: new status. */ __( 'Status changed from %1$s to %2$s.', 'returnkit' ), Labels::status( $from, $request->type() ), Labels::status( $to, $request->type() ) ); Log::add( $request->id(), $to, $text, $actor, array_filter( $data + array( 'message' => (string) $message ) ) ); $order = $request->order(); if ( $order ) { /* translators: 1: request number, 2: new status. */ $order->add_order_note( sprintf( __( 'ReturnKit: request %1$s is now "%2$s".', 'returnkit' ), $request->number(), Labels::status( $to, $request->type() ) ) ); } $fresh = Requests::get( $request->id() ); /** * A request changed status. * * @param Request $request Request (after the change). * @param string $from Previous status. * @param string $to New status. * @param string $message Message for the customer. * @param array $data Extra data (outcome, amount …). */ do_action( 'rtkt_status_changed', $fresh, $from, $to, (string) $message, $data ); return $fresh; } /** * Put received (or, if nothing was marked received, requested) units back * in stock, once: restocked_qty remembers what was done. * * @param Request $request Request. * @return int Units restocked. */ public static function restock( Request $request ) { $total = 0; $order = $request->order(); foreach ( $request->items() as $item ) { // Once items were marked received, only what arrived goes back in // stock (0 = didn't arrive); before that, the requested quantity. $target = $request->meta( 'received' ) ? (int) $item['received_qty'] : (int) $item['qty']; $todo = $target - (int) $item['restocked_qty']; if ( $todo <= 0 ) { continue; } $product = wc_get_product( (int) $item['variation_id'] ? (int) $item['variation_id'] : (int) $item['product_id'] ); if ( $product && $product->managing_stock() ) { $new = wc_update_product_stock( $product, $todo, 'increase' ); if ( $order ) { /* translators: 1: product name, 2: quantity, 3: new stock. */ $order->add_order_note( sprintf( __( 'ReturnKit: %1$s restocked (+%2$d, now %3$s).', 'returnkit' ), $product->get_formatted_name(), $todo, wc_stock_amount( $new ) ) ); } } // Remembered even when stock isn't managed, so it's never retried. Requests::update_item( (int) $item['id'], array( 'restocked_qty' => $target ) ); $total += $todo; } if ( $total ) { /* translators: %d: units. */ Log::add( $request->id(), 'restocked', sprintf( _n( '%d unit put back in stock.', '%d units put back in stock.', $total, 'returnkit' ), $total ), 'admin' ); } return $total; } /** * Refund lines for the request's items (and shipping), at full value. * * @param Request $request Request. * @param \WC_Order $order Order. * @param bool $shipping Include delivery costs not yet refunded. * @param int[]|null $only Only these item row IDs (null: all). * @return array [ lines => WooCommerce line_items, total => float ] */ public static function refund_plan( Request $request, \WC_Order $order, $shipping = false, $only = null ) { $lines = array(); $total = 0.0; $dp = wc_get_price_decimals(); foreach ( $request->items() as $item ) { if ( null !== $only && ! in_array( (int) $item['id'], $only, true ) ) { continue; } $order_item = $order->get_item( (int) $item['order_item_id'] ); if ( ! $order_item instanceof \WC_Order_Item_Product ) { continue; } $ordered = max( 1, (int) $order_item->get_quantity() ); $qty = min( (int) $item['qty'], $ordered ); $ratio = $qty / $ordered; $taxes = $order_item->get_taxes(); $line_tax = array(); foreach ( isset( $taxes['total'] ) ? (array) $taxes['total'] : array() as $rate_id => $tax ) { $line_tax[ $rate_id ] = round( (float) $tax * $ratio, $dp ); } $net = round( (float) $order_item->get_total() * $ratio, $dp ); $lines[ $order_item->get_id() ] = array( 'qty' => $qty, 'refund_total' => $net, 'refund_tax' => $line_tax, ); $total += $net + array_sum( $line_tax ); } if ( $shipping ) { foreach ( $order->get_items( 'shipping' ) as $ship_id => $ship ) { $left = (float) $ship->get_total() - abs( (float) $order->get_total_refunded_for_item( $ship_id, 'shipping' ) ); if ( $left <= 0 ) { continue; } $ratio = (float) $ship->get_total() > 0 ? $left / (float) $ship->get_total() : 0; $taxes = $ship->get_taxes(); $line_tax = array(); foreach ( isset( $taxes['total'] ) ? (array) $taxes['total'] : array() as $rate_id => $tax ) { $line_tax[ $rate_id ] = round( (float) $tax * $ratio, $dp ); } $lines[ $ship_id ] = array( 'qty' => 0, 'refund_total' => round( $left, $dp ), 'refund_tax' => $line_tax, ); $total += $left + array_sum( $line_tax ); } } return array( 'lines' => $lines, 'total' => round( $total, $dp ), ); } /** * Scale refund lines to a partial amount, keeping their sum exact: the * rounding remainder goes on the last line. * * @param array $lines Lines at full value. * @param float $factor 0..1. * @param float $amount The amount the lines must add up to. * @return array */ private static function scale_lines( array $lines, $factor, $amount ) { if ( $factor >= 0.99999 || ! $lines ) { return $lines; } $dp = wc_get_price_decimals(); $sum = 0.0; foreach ( $lines as $id => $line ) { $lines[ $id ]['refund_total'] = round( $line['refund_total'] * $factor, $dp ); $sum += $lines[ $id ]['refund_total']; foreach ( $line['refund_tax'] as $rate => $tax ) { $lines[ $id ]['refund_tax'][ $rate ] = round( $tax * $factor, $dp ); $sum += $lines[ $id ]['refund_tax'][ $rate ]; } } $diff = round( $amount - $sum, $dp ); if ( 0.0 !== $diff ) { $last = array_key_last( $lines ); $lines[ $last ]['refund_total'] = round( $lines[ $last ]['refund_total'] + $diff, $dp ); } return $lines; } /** * Does the order's gateway support automatic refunds? * * @param \WC_Order $order Order. * @return bool */ public static function gateway_can_refund( \WC_Order $order ) { $gateways = WC()->payment_gateways() ? WC()->payment_gateways()->payment_gateways() : array(); $id = $order->get_payment_method(); return $id && isset( $gateways[ $id ] ) && $gateways[ $id ]->supports( 'refunds' ) && method_exists( $gateways[ $id ], 'can_refund_order' ) && $gateways[ $id ]->can_refund_order( $order ); } }