prepare(), with %i for table names and %s/%d/%f for values, so nothing is ever interpolated into SQL. /** * Creates and adjusts referral commissions. */ class Commissions_Service { /** * Referral repository. * * @var Referral_Repository */ private $referrals; /** * Affiliate repository. * * @var Affiliate_Repository */ private $affiliates; /** * Visit repository. * * @var Visit_Repository */ private $visits; /** * Settings. * * @var Settings */ private $settings; /** * Rate resolver. * * @var Rate_Resolver */ private $resolver; /** * Tracking service. * * @var Tracking_Service */ private $tracking; /** * Constructor. * * @param Referral_Repository $referrals Referral repository. * @param Affiliate_Repository $affiliates Affiliate repository. * @param Visit_Repository $visits Visit repository. * @param Settings $settings Settings service. * @param Tracking_Service $tracking Tracking service. */ public function __construct( Referral_Repository $referrals, Affiliate_Repository $affiliates, Visit_Repository $visits, Settings $settings, Tracking_Service $tracking ) { $this->referrals = $referrals; $this->affiliates = $affiliates; $this->visits = $visits; $this->settings = $settings; $this->tracking = $tracking; $this->resolver = new Rate_Resolver( $settings ); } /** * Hooks WooCommerce order lifecycle. * * @since 1.0.0 */ public function register() { add_action( 'woocommerce_checkout_order_processed', array( $this, 'capture_order' ), 20, 1 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'capture_order_object' ), 20, 1 ); add_action( 'woocommerce_order_status_completed', array( $this, 'maybe_approve_order' ), 20, 1 ); add_action( 'woocommerce_order_status_refunded', array( $this, 'reverse_order' ), 20, 1 ); add_action( 'woocommerce_order_status_cancelled', array( $this, 'reverse_order' ), 20, 1 ); add_action( 'woocommerce_order_refunded', array( $this, 'partial_refund' ), 20, 2 ); add_action( 'ppl_afw_daily_maintenance', array( $this, 'release_locked' ) ); } /** * Returns the rate resolver. * * @since 1.0.0 * @return Rate_Resolver */ public function resolver() { return $this->resolver; } /** * Classic checkout entry point. * * @since 1.0.0 * @param int $order_id Order id. */ public function capture_order( $order_id ) { $order = wc_get_order( $order_id ); if ( $order ) { $this->capture_order_object( $order ); } } /** * Rate sources that pay a flat amount per unit sold. Every other flat * rate (the store default, a per affiliate rate, a tier or a purchase * sequence rule) is a fixed amount per order. */ const ITEM_RATE_SOURCES = array( 'product', 'variation', 'category', 'affiliate_product' ); /** * Creates the commission referral for an order. * * @since 1.0.0 * @param \WC_Order $order Order object. */ public function capture_order_object( $order ) { if ( ! $order instanceof \WC_Order || $this->referrals->for_order( $order->get_id() ) ) { return; } $attribution = $this->resolve_attribution( $order ); if ( ! $attribution ) { return; } $affiliate = $this->affiliates->find( $attribution['affiliate_id'] ); $this->record_order_referral( $order, $affiliate, $attribution ); } /** * Creates the direct commission of an order for one affiliate, through * the same checks, rates and hooks every commission uses: eligibility, * the commission base settings, the rate cascade per line item, fraud * scoring, network overrides, emails and notifications (the last ones * through ppl_afw_referral_created). * * @since 1.3.0 * @param \WC_Order $order Order object. * @param object|null $affiliate Affiliate row. * @param array $attribution visit_id, type and source. * @param array $args Optional: rate (array, one rate for every line), renewal (bool, skips the new customers only check), description (string). * @return int Referral id, 0 when no commission was created. */ public function record_order_referral( $order, $affiliate, array $attribution, array $args = array() ) { if ( ! $order instanceof \WC_Order || ! $affiliate || 'active' !== $affiliate->status ) { return 0; } if ( ! $this->order_eligible( $order, $affiliate, ! empty( $args['renewal'] ) ) ) { return 0; } $attribution = wp_parse_args( $attribution, array( 'visit_id' => 0, 'type' => 'sale', 'source' => 'link', ) ); $email_hash = Crypto::hash( strtolower( $order->get_billing_email() ) ); $purchase_number = $this->purchase_number( $order, $affiliate ); $calc = $this->calculate_order( $order, $affiliate, $purchase_number, isset( $args['rate'] ) && is_array( $args['rate'] ) ? $args['rate'] : null ); $rate = $calc['rate']; /** * Filters the final commission amount before the referral is stored. * * @since 1.0.0 * @param float $amount Commission amount. * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. * @param array $rate Resolved rate (of the highest value line). */ $amount = round( (float) apply_filters( 'ppl_afw_commission_amount', $calc['amount'], $order, $affiliate, $rate ), 4 ); if ( $amount <= 0 ) { return 0; } $grace = max( 0, (int) $this->settings->get( 'grace_period_days' ) ); $campaign_id = 0; if ( ! empty( $attribution['visit_id'] ) ) { $visit = $this->visits->find( (int) $attribution['visit_id'] ); $campaign_id = $visit ? (int) $visit->campaign_id : 0; } $referral_id = $this->referrals->insert( array( 'affiliate_id' => (int) $affiliate->id, 'visit_id' => (int) $attribution['visit_id'], 'order_id' => $order->get_id(), 'campaign_id' => $campaign_id, 'customer_email_hash' => $email_hash, 'type' => sanitize_key( $attribution['type'] ), 'description' => isset( $args['description'] ) ? sanitize_text_field( $args['description'] ) : sprintf( 'order:%d purchase_no:%d via:%s', $order->get_id(), $purchase_number, $attribution['source'] ), 'amount' => $amount, 'currency' => $order->get_currency(), 'base_amount' => $calc['base'], 'rate_snapshot' => wp_json_encode( array( 'rate' => $rate, 'purchase_number' => $purchase_number, 'basis' => array( 'include_tax' => (bool) $this->settings->get( 'include_tax' ), 'include_shipping' => (bool) $this->settings->get( 'include_shipping' ), 'exclude_discounts' => (bool) $this->settings->get( 'exclude_discounts' ), ), 'lines' => $calc['lines'], 'shipping' => $calc['shipping'], 'order_flat' => $calc['order_flat'], 'computed_amount' => $calc['amount'], 'original_amount' => $amount, ) ), 'status' => 'pending', 'locked_until' => $grace ? gmdate( 'Y-m-d H:i:s', time() + $grace * DAY_IN_SECONDS ) : null, ) ); if ( ! $referral_id ) { return 0; } if ( ! empty( $attribution['visit_id'] ) ) { $this->visits->mark_converted( (int) $attribution['visit_id'], $order->get_id() ); } if ( empty( $args['renewal'] ) ) { $order->update_meta_data( '_ppl_afw_affiliate_id', (int) $affiliate->id ); $order->save(); if ( $this->settings->get( 'cookie_clear_on_conversion' ) ) { $this->tracking->clear_cookie(); } } /** * Fires after a referral commission is created. * * @since 1.0.0 * @param int $referral_id Referral id. * @param object $affiliate Affiliate row. * @param \WC_Order $order Order object. */ do_action( 'ppl_afw_referral_created', $referral_id, $affiliate, $order ); return $referral_id; } /** * Calculates an order's commission line by line. Each line item gets * its own rate (the product, variation and category rates apply to that * line only); percentages apply to the line's base, per product flat * rates pay per unit, and a flat rate that is not product specific pays * once per order. * * @since 1.3.0 * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. * @param int $purchase_number Customer purchase number. * @param array|null $forced_rate One rate for every line, or null for the cascade. * @return array{amount: float, base: float, rate: array, lines: array[], shipping: array|null, order_flat: array|null} */ public function calculate_order( $order, $affiliate, $purchase_number = 1, $forced_rate = null ) { $lines = array(); $base_total = 0.0; $order_flat = null; $primary = null; $best_base = -1.0; foreach ( $order->get_items() as $item_id => $item ) { if ( ! $item instanceof \WC_Order_Item_Product ) { continue; } $product_id = (int) $item->get_product_id(); if ( $this->product_excluded( $product_id ) ) { continue; } $line_base = $this->line_base( $item ); $rate = $forced_rate ? $forced_rate : $this->resolver->resolve( $affiliate, $product_id, $purchase_number, $item ); $qty = max( 1, (int) $item->get_quantity() ); $line = $this->line_amount( $line_base, $rate, $qty ); if ( null === $line ) { // An order level flat rate: paid once, below. $order_flat = $order_flat ? $order_flat : $rate; $line = 0.0; } $lines[] = array( 'item_id' => (int) $item_id, 'product_id' => $product_id, 'variation_id' => (int) $item->get_variation_id(), 'qty' => $qty, 'base' => round( $line_base, 4 ), 'rate' => $rate, 'amount' => $line, ); $base_total += $line_base; if ( $line_base > $best_base ) { $best_base = $line_base; $primary = $rate; } } $shipping = null; if ( $this->settings->get( 'include_shipping' ) && $lines ) { $shipping_base = (float) $order->get_shipping_total(); if ( $this->settings->get( 'include_tax' ) ) { $shipping_base += (float) $order->get_shipping_tax(); } if ( $shipping_base > 0 ) { $rate = $forced_rate ? $forced_rate : $this->resolver->resolve( $affiliate, 0, $purchase_number, null ); $amount = $this->line_amount( $shipping_base, $rate, 1 ); if ( null === $amount ) { $order_flat = $order_flat ? $order_flat : $rate; $amount = 0.0; } $shipping = array( 'base' => round( $shipping_base, 4 ), 'rate' => $rate, 'amount' => $amount, ); $base_total += $shipping_base; } } /** * Filters the commission base amount, including for currency conversion. * * When the filtered base differs from the sum of the lines, every * percentage commission is scaled by the same factor. * * @since 1.0.0 * @param float $base Base amount in order currency. * @param \WC_Order $order Order object. */ $filtered = (float) apply_filters( 'ppl_afw_commission_base', max( 0, $base_total ), $order ); $factor = $base_total > 0 ? max( 0, $filtered ) / $base_total : 1.0; $amount = 0.0; foreach ( $lines as $i => $line ) { if ( 'flat' !== ( $line['rate']['type'] ?? '' ) && 1.0 !== $factor ) { $lines[ $i ]['amount'] = round( $line['amount'] * $factor, 4 ); } $amount += $lines[ $i ]['amount']; } if ( $shipping ) { if ( 'flat' !== ( $shipping['rate']['type'] ?? '' ) && 1.0 !== $factor ) { $shipping['amount'] = round( $shipping['amount'] * $factor, 4 ); } $amount += $shipping['amount']; } if ( $order_flat ) { $amount += round( (float) $order_flat['value'], 4 ); } if ( ! $primary ) { $primary = $forced_rate ? $forced_rate : $this->resolver->resolve( $affiliate, 0, $purchase_number, null ); } return array( 'amount' => round( $amount, 4 ), 'base' => round( $lines ? max( 0, $filtered ) : 0, 4 ), 'rate' => $primary, 'lines' => $lines, 'shipping' => $shipping, 'order_flat' => $order_flat, ); } /** * Commission for one line: a percentage of its base, or a flat amount * per unit for product specific flat rates. Null means the rate is an * order level flat rate, paid once per order by the caller. * * @since 1.3.0 * @param float $base Line base. * @param array $rate type, value, source. * @param int $qty Quantity. * @return float|null */ private function line_amount( $base, array $rate, $qty ) { if ( 'flat' !== ( $rate['type'] ?? '' ) ) { return round( $base * (float) ( $rate['value'] ?? 0 ) / 100, 4 ); } if ( in_array( (string) ( $rate['source'] ?? '' ), self::ITEM_RATE_SOURCES, true ) ) { return round( (float) $rate['value'] * $qty, 4 ); } return null; } /** * Whether a product never earns commission (excluded product or category). * * @since 1.3.0 * @param int $product_id Product id. * @return bool */ private function product_excluded( $product_id ) { $excluded_products = array_map( 'absint', (array) $this->settings->get( 'excluded_products' ) ); if ( in_array( (int) $product_id, $excluded_products, true ) ) { return true; } $excluded_categories = array_map( 'absint', (array) $this->settings->get( 'excluded_categories' ) ); if ( $excluded_categories && array_intersect( $excluded_categories, array_map( 'absint', wc_get_product_term_ids( $product_id, 'product_cat' ) ) ) ) { return true; } return false; } /** * A line item's commission base per the settings. * * @since 1.3.0 * @param \WC_Order_Item_Product $item Line item. * @return float */ private function line_base( $item ) { $line = $this->settings->get( 'exclude_discounts' ) ? (float) $item->get_total() : (float) $item->get_subtotal(); if ( $this->settings->get( 'include_tax' ) ) { $line += (float) $item->get_total_tax(); } return max( 0, $line ); } /** * The customer's purchase number for the purchase sequence rules: their * paid orders before this one, plus one, whether or not an affiliate * referred those orders. * * @since 1.3.0 * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. * @return int */ private function purchase_number( $order, $affiliate ) { $email = $order->get_billing_email(); $count = 0; if ( $email ) { $query = array( 'billing_email' => $email, 'status' => array_map( static function ( $status ) { return 'wc-' . $status; }, wc_get_is_paid_statuses() ), 'type' => 'shop_order', 'limit' => 1, 'paginate' => true, 'return' => 'ids', ); $created = $order->get_date_created(); if ( $created ) { $query['date_created'] = '<' . $created->getTimestamp(); } $result = wc_get_orders( $query ); $count = is_object( $result ) && isset( $result->total ) ? (int) $result->total : 0; // Without a creation date the order itself may be in the count. if ( ! $created && $count > 0 && $order->is_paid() ) { --$count; } } /** * Filters the customer's purchase number used by sequence rules. * * @since 1.3.0 * @param int $number 1 for a first purchase. * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. */ return max( 1, (int) apply_filters( 'ppl_afw_purchase_number', $count + 1, $order, $affiliate ) ); } /** * Resolves attribution: tracking cookie/fingerprint, then affiliate * plus anything other attribution sources contribute. * * @since 1.0.0 * @param \WC_Order $order Order object. * @return array{affiliate_id: int, visit_id: int, type: string, source: string}|null */ private function resolve_attribution( $order ) { $cookie = $this->tracking->current_attribution(); $attribution = $cookie ? $cookie + array( 'type' => 'sale', 'source' => 'link', ) : null; /** * Filters which affiliate an order is credited to. * * Runs after link attribution so other sources can claim an order * that no referral link accounted for. Return an array of * affiliate_id, visit_id, type, and source. * * @since 1.0.0 * @param array|null $attribution Attribution so far, or null. * @param \WC_Order $order Order object. */ return apply_filters( 'ppl_afw_order_attribution', $attribution, $order ); } /** * Eligibility checks: threshold, exclusions, self referral. * * @since 1.0.0 * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. * @param bool $renewal A subscription renewal: the new customers only rule does not apply. * @return bool */ private function order_eligible( $order, $affiliate, $renewal = false ) { $minimum = (float) $this->settings->get( 'minimum_order_amount' ); if ( $minimum > 0 && (float) $order->get_total() < $minimum ) { return false; } if ( $this->settings->get( 'block_self_referrals' ) ) { $affiliate_user = get_userdata( (int) $affiliate->user_id ); if ( $affiliate_user ) { if ( (int) $order->get_customer_id() === (int) $affiliate->user_id ) { return false; } if ( strtolower( $order->get_billing_email() ) === strtolower( $affiliate_user->user_email ) ) { return false; } } } $excluded_roles = (array) $this->settings->get( 'excluded_roles' ); if ( $excluded_roles && $order->get_customer_id() ) { $customer = get_userdata( $order->get_customer_id() ); if ( $customer && array_intersect( $excluded_roles, (array) $customer->roles ) ) { return false; } } // Optionally only commission a customer's first purchase. if ( ! $renewal && $this->settings->get( 'block_existing_customers' ) && $this->is_returning_customer( $order ) ) { return false; } /** * Filters whether an order is eligible for commission. * * @since 1.0.0 * @param bool $eligible Eligibility. * @param \WC_Order $order Order object. * @param object $affiliate Affiliate row. */ return (bool) apply_filters( 'ppl_afw_order_eligible', true, $order, $affiliate ); } /** * Whether an order's customer has bought before, for the new customers * only setting. An earlier order counts when it is paid, or on hold * awaiting a manual payment, and has the same billing email, or belongs * to the same customer account (by account or account email), so guest * checkouts count too. Cancelled, failed, pending and fully refunded * orders are not purchases; a partly refunded order still is. Orders * placed after this one are ignored. Works with both order storages. * * @since 1.3.1 * @param \WC_Order $order Order object. * @return bool */ public function is_returning_customer( $order ) { /** * Filters the order statuses that make a customer a returning one. * * @since 1.3.1 * @param string[] $statuses Statuses without the wc- prefix. * @param \WC_Order $order Order object. */ $statuses = (array) apply_filters( 'ppl_afw_returning_customer_statuses', array_merge( wc_get_is_paid_statuses(), array( 'on-hold' ) ), $order ); $base = array( 'type' => 'shop_order', 'status' => array_map( static function ( $status ) { return 'wc-' . preg_replace( '/^wc-/', '', (string) $status ); }, array_unique( $statuses ) ), 'limit' => 2, 'return' => 'ids', ); $created = $order->get_date_created(); if ( $created ) { $base['date_created'] = '<=' . $created->getTimestamp(); } $lookups = array(); $emails = array( strtolower( (string) $order->get_billing_email() ) ); $user_id = (int) $order->get_customer_id(); if ( $user_id > 0 ) { $lookups[] = array( 'customer_id' => $user_id ); $user = get_userdata( $user_id ); if ( $user && $user->user_email ) { $emails[] = strtolower( $user->user_email ); } } foreach ( array_unique( array_filter( $emails ) ) as $email ) { $lookups[] = array( 'billing_email' => $email ); } $returning = false; foreach ( $lookups as $lookup ) { $ids = array_diff( array_map( 'absint', (array) wc_get_orders( $base + $lookup ) ), array( (int) $order->get_id() ) ); if ( $ids ) { $returning = true; break; } } /** * Filters whether an order's customer counts as a returning customer * for the new customers only setting. * * @since 1.3.1 * @param bool $returning True when the customer bought before. * @param \WC_Order $order Order object. */ return (bool) apply_filters( 'ppl_afw_is_returning_customer', $returning, $order ); } /** * Approves an order's pending referrals when the lock allows. * * @since 1.0.0 * @param int $order_id Order id. */ public function maybe_approve_order( $order_id ) { foreach ( $this->referrals->for_order( $order_id ) as $referral ) { if ( 'pending' === $referral->status && ( null === $referral->locked_until || strtotime( $referral->locked_until . ' UTC' ) <= time() ) ) { $this->approve( (int) $referral->id ); } } } /** * Approves a referral and credits earnings. * * @since 1.0.0 * @param int $referral_id Referral id. */ public function approve( $referral_id ) { $referral = $this->referrals->find( $referral_id ); if ( ! $referral || 'pending' !== $referral->status ) { return; } $this->referrals->update( $referral_id, array( 'status' => 'approved' ) ); $this->affiliates->adjust_earnings( (int) $referral->affiliate_id, (float) $referral->amount ); /** * Fires when a referral is approved and becomes payable. * * @since 1.0.0 * @param int $referral_id Referral id. * @param object $referral Referral row. */ do_action( 'ppl_afw_referral_approved', $referral_id, $referral ); } /** * Rejects a referral, reversing earnings when it was approved, together * with the network overrides it created. * * @since 1.3.0 * @param int $referral_id Referral id. * @return bool */ public function reject( $referral_id ) { $referral = $this->referrals->find( $referral_id ); if ( ! $referral || ! in_array( $referral->status, array( 'pending', 'approved' ), true ) ) { return false; } $rows = array_merge( array( $referral ), $this->referrals->children( (int) $referral->id, 'tier_override' ) ); foreach ( $rows as $row ) { if ( ! in_array( $row->status, array( 'pending', 'approved' ), true ) ) { continue; } if ( 'approved' === $row->status ) { $this->affiliates->adjust_earnings( (int) $row->affiliate_id, -(float) $row->amount ); } $this->referrals->update( (int) $row->id, array( 'status' => 'rejected' ) ); } /** * Fires after a referral (and its overrides) is rejected. * * @since 1.3.0 * @param int $referral_id Referral id. * @param object $referral Referral row before the change. */ do_action( 'ppl_afw_referral_rejected', (int) $referral->id, $referral ); return true; } /** * Fully reverses commissions on refund or cancellation. Unpaid * commissions are marked refunded; commissions already paid out are * clawed back from the affiliate's next payout. * * @since 1.0.0 * @param int $order_id Order id. */ public function reverse_order( $order_id ) { foreach ( $this->referrals->for_order( $order_id ) as $referral ) { if ( 'clawback' === $referral->type ) { continue; } if ( in_array( $referral->status, array( 'pending', 'approved' ), true ) ) { if ( 'approved' === $referral->status ) { $this->affiliates->adjust_earnings( (int) $referral->affiliate_id, -(float) $referral->amount ); } $this->referrals->update( (int) $referral->id, array( 'status' => 'refunded' ) ); } elseif ( 'paid' === $referral->status ) { $this->claw_back( $referral, 0.0 ); } } } /** * Reduces commission after a partial refund. The new amount is always * computed from the commission as first recorded, never from an * already reduced one, so several refunds do not compound. Refunded line * items reduce only their own line's commission and a shipping refund * only the shipping part; a refund of an amount without items reduces * the rest proportionally. * * @since 1.0.0 * @param int $order_id Order id. * @param int $refund_id Refund id. */ public function partial_refund( $order_id, $refund_id ) { $order = wc_get_order( $order_id ); if ( ! $order || (float) $order->get_total() <= 0 ) { return; } if ( (float) $order->get_total_refunded() >= (float) $order->get_total() - 0.00001 ) { $this->reverse_order( $order_id ); return; } $factors = $this->refund_factors( $order ); $ratios = array(); foreach ( $this->referrals->for_order( $order_id ) as $referral ) { if ( 'clawback' === $referral->type || ! in_array( $referral->status, array( 'pending', 'approved', 'paid' ), true ) ) { continue; } $snapshot = json_decode( (string) $referral->rate_snapshot, true ); $snapshot = is_array( $snapshot ) ? $snapshot : array(); $original = isset( $snapshot['original_amount'] ) ? (float) $snapshot['original_amount'] : (float) $referral->amount; $parent = (int) $referral->parent_referral_id; if ( $parent && isset( $ratios[ $parent ] ) ) { // An override follows its parent commission. $target = round( $original * $ratios[ $parent ], 4 ); } else { $target = $this->amount_after_refund( $snapshot, $original, $factors ); } $ratios[ (int) $referral->id ] = $original > 0 ? $target / $original : 0.0; if ( ! isset( $snapshot['original_amount'] ) ) { $snapshot['original_amount'] = $original; $this->referrals->update( (int) $referral->id, array( 'rate_snapshot' => wp_json_encode( $snapshot ) ) ); } if ( 'paid' === $referral->status ) { $this->claw_back( $referral, $target ); continue; } $delta = round( $target - (float) $referral->amount, 4 ); if ( abs( $delta ) < 0.00005 ) { continue; } if ( 'approved' === $referral->status ) { $this->affiliates->adjust_earnings( (int) $referral->affiliate_id, $delta ); } $this->referrals->update( (int) $referral->id, array( 'amount' => $target ) ); } } /** * How much of each line, the shipping and the rest of the order is * left after the refunds so far. * * @since 1.3.0 * @param \WC_Order $order Order object. * @return array{items: array, shipping: float, rest: float, overall: float} */ private function refund_factors( $order ) { $items = array(); $itemized = 0.0; foreach ( $order->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) { $line = abs( (float) $item->get_total() ); $refunded = abs( (float) $order->get_total_refunded_for_item( $item_id, $item->get_type() ) ); $itemized += $refunded; if ( $line > 0 ) { $items[ (int) $item_id ] = max( 0.0, 1 - $refunded / $line ); } else { $qty = max( 1, (int) $item->get_quantity() ); $items[ (int) $item_id ] = max( 0.0, 1 - abs( (int) $order->get_qty_refunded_for_item( $item_id ) ) / $qty ); } } $shipping_total = (float) $order->get_shipping_total(); $shipping_refunded = abs( (float) $order->get_total_shipping_refunded() ); $itemized += $shipping_refunded + abs( (float) $order->get_total_tax_refunded() ); $total = (float) $order->get_total(); $refunded = abs( (float) $order->get_total_refunded() ); $unitemized = max( 0.0, $refunded - $itemized ); $remaining = $total - min( $total, $itemized ); return array( 'items' => $items, 'shipping' => $shipping_total > 0 ? max( 0.0, 1 - $shipping_refunded / $shipping_total ) : 1.0, 'rest' => $remaining > 0 ? max( 0.0, 1 - $unitemized / $remaining ) : 0.0, 'overall' => $total > 0 ? max( 0.0, 1 - $refunded / $total ) : 0.0, ); } /** * A commission's amount after refunds, from its recorded line * breakdown, or proportionally for commissions recorded without one. * * @since 1.3.0 * @param array $snapshot Decoded rate snapshot. * @param float $original Commission as first recorded. * @param array $factors From refund_factors(). * @return float */ private function amount_after_refund( array $snapshot, $original, array $factors ) { if ( empty( $snapshot['lines'] ) || ! is_array( $snapshot['lines'] ) ) { return round( $original * $factors['overall'], 4 ); } $amount = 0.0; $left = false; foreach ( $snapshot['lines'] as $line ) { $keep = $factors['items'][ (int) ( $line['item_id'] ?? 0 ) ] ?? 1.0; $amount += (float) ( $line['amount'] ?? 0 ) * $keep; $left = $left || $keep > 0; } if ( ! empty( $snapshot['shipping']['amount'] ) ) { $amount += (float) $snapshot['shipping']['amount'] * $factors['shipping']; } if ( ! empty( $snapshot['order_flat']['value'] ) && $left ) { $amount += (float) $snapshot['order_flat']['value']; } $amount *= $factors['rest']; // A filter may have changed the stored amount; keep that proportion. $computed = isset( $snapshot['computed_amount'] ) ? (float) $snapshot['computed_amount'] : $original; if ( $computed > 0 && abs( $computed - $original ) > 0.00005 ) { $amount *= $original / $computed; } return round( min( $original, max( 0.0, $amount ) ), 4 ); } /** * Claws back part of a commission that was already paid out: a * negative approved adjustment the next payout deducts. Only the part * not clawed back before is added, so repeated refunds never double up. * * @since 1.3.0 * @param object $referral Paid referral row. * @param float $target What the commission should be worth now. */ private function claw_back( $referral, $target ) { if ( ! $this->settings->get( 'clawback_paid_refunds' ) ) { return; } $effective = (float) $referral->amount; foreach ( $this->referrals->children( (int) $referral->id, 'clawback' ) as $row ) { if ( 'rejected' !== $row->status ) { $effective += (float) $row->amount; } } $delta = round( (float) $target - $effective, 4 ); if ( $delta > -0.00005 ) { return; } $this->referrals->insert( array( 'affiliate_id' => (int) $referral->affiliate_id, 'order_id' => (int) $referral->order_id, 'customer_email_hash' => (string) $referral->customer_email_hash, 'type' => 'clawback', 'description' => sprintf( 'clawback of referral:%d after refund', (int) $referral->id ), 'amount' => $delta, 'currency' => (string) $referral->currency, 'status' => 'approved', 'parent_referral_id' => (int) $referral->id, ) ); $this->affiliates->adjust_earnings( (int) $referral->affiliate_id, $delta ); $this->affiliates->log_activity( (int) $referral->affiliate_id, 'clawback', sprintf( 'Referral #%d: %s', (int) $referral->id, $delta ) ); } /** * Cron: approve pending referrals whose grace lock has expired, or that * never had one, once their order is paid. * * @since 1.0.0 */ public function release_locked() { $after = 0; do { $batch = $this->referrals->unlockable( 200, $after ); $size = count( $batch ); foreach ( $batch as $referral ) { $after = (int) $referral->id; $order = wc_get_order( (int) $referral->order_id ); if ( $order && in_array( $order->get_status(), array( 'completed', 'processing' ), true ) ) { $this->approve( (int) $referral->id ); } } } while ( 200 === $size ); } }