v = $v; } public function boot() { add_action( 'woocommerce_checkout_order_created', array( $this, 'maybe_split' ), 20 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'maybe_split' ), 20 ); add_action( 'woocommerce_order_status_changed', array( $this, 'on_status' ), 20, 4 ); add_action( 'woocommerce_order_refunded', array( $this, 'on_refund' ), 20, 2 ); add_action( 'frontglut_daily', array( $this, 'auto_requests' ) ); foreach ( array( 'new_order', 'cancelled_order', 'failed_order', 'customer_processing_order', 'customer_completed_order', 'customer_on_hold_order', 'customer_refunded_order', 'customer_invoice' ) as $id ) { add_filter( 'woocommerce_email_enabled_' . $id, array( $this, 'muted' ) ); } Ajax::register( 'withdraw_request', array( $this, 'ajax_request' ), array( 'cap' => 'frontglut_is_vendor' ) ); Ajax::register( 'withdraw_action', array( $this, 'ajax_action' ), array( 'cap' => 'frontglut_manage_vendors' ) ); } public function muted( $enabled ) { return self::$muted ? false : $enabled; } /* ---------------- splitting ---------------- */ public function maybe_split( $order ) { if ( ! $order instanceof \WC_Order ) { $order = wc_get_order( $order ); } if ( ! $order || $order->get_parent_id() || $order->get_meta( '_frontglut_split_done' ) || 'native' !== Settings::get( 'vendors.provider', 'native' ) ) { return; } $groups = array(); $other = 0; foreach ( $order->get_items() as $item ) { $vid = Vendors::product_vendor( $item->get_product_id() ); if ( $vid ) { $groups[ $vid ][] = $item; } else { $other++; } } if ( ! $groups ) { return; } $order->update_meta_data( '_frontglut_split_done', 1 ); if ( 1 === count( $groups ) && 0 === $other ) { $order->update_meta_data( '_frontglut_vendor_id', (int) key( $groups ) ); $order->save(); return; } $order->save(); if ( ! Settings::get( 'vendors.split_orders' ) ) { return; } self::$muted = true; foreach ( $groups as $vid => $items ) { $this->make_child( $order, (int) $vid, $items ); } self::$muted = false; } private function make_child( \WC_Order $parent, $vid, array $items ) { $child = wc_create_order( array( 'parent' => $parent->get_id(), 'customer_id' => $parent->get_customer_id(), 'created_via' => 'frontglut_split', 'status' => 'pending' ) ); if ( is_wp_error( $child ) ) { return; } $child->set_address( $parent->get_address( 'billing' ), 'billing' ); $child->set_address( $parent->get_address( 'shipping' ), 'shipping' ); $child->set_currency( $parent->get_currency() ); $child->set_payment_method( $parent->get_payment_method() ); $child->set_payment_method_title( $parent->get_payment_method_title() ); $child->set_customer_note( $parent->get_customer_note() ); $child->set_prices_include_tax( $parent->get_prices_include_tax() ); foreach ( $items as $item ) { $new = new \WC_Order_Item_Product(); $new->set_props( array( 'name' => $item->get_name(), 'product_id' => $item->get_product_id(), 'variation_id' => $item->get_variation_id(), 'quantity' => $item->get_quantity(), 'tax_class' => $item->get_tax_class(), 'subtotal' => $item->get_subtotal(), 'subtotal_tax' => $item->get_subtotal_tax(), 'total' => $item->get_total(), 'total_tax' => $item->get_total_tax(), 'taxes' => $item->get_taxes() ) ); foreach ( $item->get_meta_data() as $m ) { $new->add_meta_data( $m->key, $m->value ); } $child->add_item( $new ); } foreach ( $parent->get_items( 'shipping' ) as $ship ) { if ( (int) $ship->get_meta( '_frontglut_vendor_id' ) === (int) $vid ) { $ns = new \WC_Order_Item_Shipping(); $ns->set_props( array( 'method_title' => $ship->get_method_title(), 'method_id' => $ship->get_method_id(), 'instance_id' => $ship->get_instance_id(), 'total' => $ship->get_total(), 'taxes' => $ship->get_taxes() ) ); $child->add_item( $ns ); } } $child->update_meta_data( '_frontglut_vendor_id', $vid ); $child->update_meta_data( '_frontglut_split_done', 1 ); $child->calculate_totals( false ); $child->set_status( $parent->get_status() ); $child->save(); /* translators: 1: value, 2: value. */ $parent->add_order_note( sprintf( __( 'Sub-order #%1$d created for vendor %2$s.', 'frontglut' ), $child->get_id(), Vendors::store( $vid )['name'] ) ); } /* ---------------- status & ledger ---------------- */ private function children( $order_id ) { return wc_get_orders( array( 'parent' => $order_id, 'limit' => -1, 'type' => 'shop_order', 'meta_key' => '_frontglut_vendor_id', 'meta_compare' => 'EXISTS', 'status' => array_keys( wc_get_order_statuses() ) ) ); // phpcs:ignore WordPress.DB.SlowDBQuery } public function on_status( $order_id, $from, $to, $order ) { if ( self::$busy ) { return; } if ( ! $order->get_parent_id() ) { $kids = $this->children( $order_id ); if ( $kids ) { self::$busy = true; self::$muted = true; foreach ( $kids as $k ) { if ( $k->get_status() !== $to ) { $k->update_status( $to, __( 'Synced with the main order.', 'frontglut' ) ); } $this->record( $k, $to ); } self::$muted = false; self::$busy = false; return; } } $vid = (int) $order->get_meta( '_frontglut_vendor_id' ); if ( ! $vid ) { return; } $this->record( $order, $to ); if ( $order->get_parent_id() && 'completed' === $to ) { $parent = wc_get_order( $order->get_parent_id() ); if ( $parent && 'completed' !== $parent->get_status() ) { $all = true; foreach ( $this->children( $parent->get_id() ) as $k ) { if ( 'completed' !== $k->get_status() ) { $all = false; } } if ( $all ) { self::$busy = true; $parent->update_status( 'completed', __( 'All vendor sub-orders are completed.', 'frontglut' ) ); self::$busy = false; } } } } public function on_refund( $order_id, $refund_id ) { $o = wc_get_order( $order_id ); if ( $o && $o->get_meta( '_frontglut_vendor_id' ) ) { $this->record( $o, $o->get_status(), true ); } elseif ( $o ) { foreach ( $this->children( $order_id ) as $k ) { $this->record( $k, $k->get_status(), true ); } } } /** Rate rules: product > vendor > category > global. */ public function rate_for( $vendor_id, \WC_Order_Item_Product $item ) { $global = array( 'type' => Settings::get( 'vendors.commission_type', 'percent' ), 'percent' => (float) Settings::get( 'vendors.commission_percent', 10 ), 'flat' => (float) Settings::get( 'vendors.commission_flat', 0 ) ); $global = apply_filters( 'frontglut_commission_rate', $global, $vendor_id, $item ); $pid = $item->get_product_id(); $pm = get_post_meta( $pid, '_frontglut_commission', true ); if ( '' !== $pm && is_numeric( $pm ) ) { return array( 'type' => 'percent', 'percent' => (float) $pm, 'flat' => 0 ); } $ov = Vendors::store( $vendor_id )['commission']; if ( ! empty( $ov['type'] ) ) { return array( 'type' => $ov['type'], 'percent' => (float) ( $ov['percent'] ?? 0 ), 'flat' => (float) ( $ov['flat'] ?? 0 ) ); } foreach ( preg_split( '/\r\n|\r|\n/', (string) Settings::get( 'vendors.cat_overrides', '' ) ) as $l ) { $p = array_map( 'trim', explode( '|', $l, 2 ) ); if ( 2 === count( $p ) && is_numeric( $p[1] ) && has_term( $p[0], 'product_cat', $pid ) ) { return array( 'type' => 'percent', 'percent' => (float) $p[1], 'flat' => 0 ); } } return $global; } public function compute( \WC_Order $o, $vid ) { $gross = 0.0; $comm = 0.0; foreach ( $o->get_items() as $item ) { $total = (float) $item->get_total(); $r = $this->rate_for( $vid, $item ); $gross += $total; $c = 0.0; if ( in_array( $r['type'], array( 'percent', 'combined' ), true ) ) { $c += $total * $r['percent'] / 100; } if ( in_array( $r['type'], array( 'flat', 'combined' ), true ) ) { $c += $r['flat'] * (float) $item->get_quantity(); } $comm += min( $c, max( 0, $total ) ); } $ship = (float) $o->get_shipping_total(); $tot = (float) $o->get_total(); $factor = ( $tot > 0 && (float) $o->get_total_refunded() > 0 ) ? max( 0, 1 - (float) $o->get_total_refunded() / $tot ) : 1; $gross = ( $gross + $ship ) * $factor; $comm = $comm * $factor; $net = $gross - $comm; if ( 'cod' === $o->get_payment_method() ) { $net = -$comm; // Reverse withdrawal: vendor collected the cash and owes the marketplace its commission. } return apply_filters( 'frontglut_commission_compute', array( round( $gross, 4 ), round( $comm, 4 ), round( $net, 4 ) ), $o, $vid ); } private function record( \WC_Order $o, $status, $recalc = false ) { global $wpdb; $vid = (int) $o->get_meta( '_frontglut_vendor_id' ); if ( ! $vid ) { return; } $on = Settings::get( 'vendors.commission_on', 'processing' ); $map = array( 'processing' => 'pending', 'on-hold' => 'pending', 'completed' => 'available', 'cancelled' => 'refunded', 'failed' => 'refunded', 'refunded' => 'refunded' ); if ( ! isset( $map[ $status ] ) ) { return; } if ( 'completed' === $on && 'completed' !== $status && 'refunded' !== $map[ $status ] ) { return; } $tbl = $wpdb->prefix . 'frontglut_commissions'; $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tbl WHERE order_id = %d AND vendor_id = %d", $o->get_id(), $vid ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL $st = $map[ $status ]; list( $g, $c, $n ) = $this->compute( $o, $vid ); if ( $row && 'refunded' === $row['status'] && 'refunded' !== $st ) { // Order resurrected (e.g. cancelled -> processing): recompute from scratch. $row = $row; } $data = array( 'order_id' => $o->get_id(), 'vendor_id' => $vid, 'gross' => $g, 'commission' => $c, 'net' => $n, 'status' => $st ); if ( 'refunded' === $st ) { $data['net'] = 0; $data['commission'] = 0; } if ( $row ) { if ( $row['status'] === $st && ! $recalc ) { return; } if ( 'available' === $st && 'available' !== $row['status'] ) { $data['created'] = current_time( 'mysql', true ); } $wpdb->update( $tbl, $data, array( 'id' => $row['id'] ) ); } else { $data['created'] = current_time( 'mysql', true ); $wpdb->insert( $tbl, $data ); if ( 'refunded' !== $st ) { /* translators: %s: value. */ Notify::send( $vid, sprintf( __( 'New order #%s', 'frontglut' ), $o->get_order_number() ), '', Dashboard::url( 'orders', array( 'a' => 'view', 'id' => $o->get_id() ) ) ); } } do_action( 'frontglut_commission_recorded', $o->get_id(), $vid, $data ); } /* ---------------- balances ---------------- */ public function balance( $vid ) { global $wpdb; $c = $wpdb->prefix . 'frontglut_commissions'; $w = $wpdb->prefix . 'frontglut_withdrawals'; $days = max( 0, (int) Settings::get( 'vendors.withdraw_days', 0 ) ); $cut = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $avail = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(net),0) FROM $c WHERE vendor_id = %d AND status = 'available' AND created <= %s", $vid, $cut ) ); // phpcs:ignore WordPress.DB.PreparedSQL $pending = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(net),0) FROM $c WHERE vendor_id = %d AND ( status = 'pending' OR ( status = 'available' AND created > %s ) )", $vid, $cut ) ); // phpcs:ignore WordPress.DB.PreparedSQL $rows = $wpdb->get_results( $wpdb->prepare( "SELECT status, COALESCE(SUM(amount),0) a FROM $w WHERE vendor_id = %d GROUP BY status", $vid ), OBJECT_K ); // phpcs:ignore WordPress.DB.PreparedSQL $req = isset( $rows['pending'] ) ? (float) $rows['pending']->a : 0; $appr = isset( $rows['approved'] ) ? (float) $rows['approved']->a : 0; $paid = isset( $rows['paid'] ) ? (float) $rows['paid']->a : 0; return array( 'available' => round( $avail - $req - $appr - $paid, 4 ), 'pending' => $pending, 'requested' => $req + $appr, 'withdrawn' => $paid, 'lifetime' => $avail + $pending ); } public function pending_withdrawals() { global $wpdb; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}frontglut_withdrawals WHERE status = 'pending'" ); // phpcs:ignore WordPress.DB.PreparedSQL } /* ---------------- withdrawals ---------------- */ private function payout_details( $vid, $method ) { $s = Vendors::store( $vid ); if ( 'paypal' === $method ) { return $s['payout_paypal']; } if ( 'bank' === $method ) { return $s['payout_bank']; } return $s['payout_bank'] ?: $s['payout_paypal']; } private function create_request( $vid, $amount, $method ) { global $wpdb; $wpdb->insert( $wpdb->prefix . 'frontglut_withdrawals', array( 'vendor_id' => $vid, 'amount' => $amount, 'method' => $method, 'details' => $this->payout_details( $vid, $method ), 'status' => 'pending', 'created' => current_time( 'mysql', true ) ) ); /* translators: %s: value. */ Notify::send_to_cap( 'frontglut_manage_vendors', sprintf( __( 'Withdrawal request: %s', 'frontglut' ), wp_strip_all_tags( wc_price( $amount ) ) ), Vendors::store( $vid )['name'], Dashboard::url( 'withdrawals' ) ); Audit::log( 'withdraw_requested', (string) $vid, (string) $amount, $vid ); return (int) $wpdb->insert_id; } public function ajax_request( $req ) { global $wpdb; $vid = get_current_user_id(); if ( ! Vendors::is_vendor( $vid ) ) { Ajax::fail( __( 'Your store is not active.', 'frontglut' ), 403 ); } $gate = apply_filters( 'frontglut_can_withdraw', true, $vid ); if ( is_wp_error( $gate ) ) { Ajax::fail( $gate->get_error_message() ); } $b = $this->balance( $vid ); $amount = isset( $req['amount'] ) && is_numeric( $req['amount'] ) ? round( (float) $req['amount'], 2 ) : 0; $method = sanitize_key( $req['method'] ?? '' ); $min = (float) Settings::get( 'vendors.withdraw_min', 50 ); if ( ! in_array( $method, (array) Settings::get( 'vendors.withdraw_methods', array() ), true ) ) { Ajax::fail( __( 'Choose a payout method.', 'frontglut' ) ); } if ( $amount < $min ) { /* translators: %s: value. */ Ajax::fail( sprintf( __( 'The minimum withdrawal is %s.', 'frontglut' ), wp_strip_all_tags( wc_price( $min ) ) ) ); } if ( $amount > $b['available'] ) { Ajax::fail( __( 'You cannot withdraw more than your available balance.', 'frontglut' ) ); } if ( ! $this->payout_details( $vid, $method ) ) { Ajax::fail( __( 'Add your payout details under My store → Payout first.', 'frontglut' ) ); } if ( (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}frontglut_withdrawals WHERE vendor_id = %d AND status = 'pending'", $vid ) ) > 0 ) { // phpcs:ignore WordPress.DB.PreparedSQL Ajax::fail( __( 'You already have a pending request.', 'frontglut' ) ); } $this->create_request( $vid, $amount, $method ); return array( 'message' => __( 'Withdrawal requested.', 'frontglut' ) ); } public function ajax_action( $req ) { global $wpdb; $id = (int) ( $req['id'] ?? 0 ); $op = sanitize_key( $req['op'] ?? '' ); $w = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}frontglut_withdrawals WHERE id = %d", $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL if ( ! $w ) { Ajax::fail( __( 'Request not found.', 'frontglut' ), 404 ); } $note = sanitize_textarea_field( $req['reason'] ?? '' ); $map = array( 'approve' => array( 'pending', 'approved' ), 'pay' => array( array( 'pending', 'approved' ), 'paid' ), 'reject' => array( array( 'pending', 'approved' ), 'rejected' ) ); if ( ! isset( $map[ $op ] ) || ! in_array( $w['status'], (array) $map[ $op ][0], true ) ) { Ajax::fail( __( 'This request cannot be changed that way.', 'frontglut' ) ); } $to = $map[ $op ][1]; $wpdb->update( $wpdb->prefix . 'frontglut_withdrawals', array( 'status' => $to, 'note' => $note, 'processed' => in_array( $to, array( 'paid', 'rejected' ), true ) ? current_time( 'mysql', true ) : null ), array( 'id' => $id ) ); $u = get_userdata( $w['vendor_id'] ); if ( $u && 'paid' === $to ) { Mailer::send_template( 'withdraw_paid', $u->user_email, array( 'name' => $u->display_name, 'amount' => wp_strip_all_tags( wc_price( $w['amount'] ) ), 'method' => $w['method'], 'note' => $note ) ); Notify::send( $w['vendor_id'], __( 'Payout sent', 'frontglut' ), wp_strip_all_tags( wc_price( $w['amount'] ) ), Dashboard::url( 'earnings' ), 'ok' ); } if ( $u && 'rejected' === $to ) { Mailer::send_template( 'withdraw_rejected', $u->user_email, array( 'name' => $u->display_name, 'amount' => wp_strip_all_tags( wc_price( $w['amount'] ) ), 'note' => $note ) ); Notify::send( $w['vendor_id'], __( 'Withdrawal declined', 'frontglut' ), $note, Dashboard::url( 'earnings' ), 'warn' ); } Audit::log( 'withdraw_' . $to, (string) $id ); do_action( 'frontglut_withdraw_status', $id, $to, $w ); return array( 'message' => __( 'Withdrawal updated.', 'frontglut' ) ); } public function auto_requests() { global $wpdb; if ( ! Settings::get( 'vendors.auto_request' ) || (int) gmdate( 'N' ) !== 1 ) { return; } $min = (float) Settings::get( 'vendors.withdraw_min', 50 ); foreach ( get_users( array( 'meta_key' => 'frontglut_vendor_status', 'meta_value' => 'approved', 'fields' => 'ID', 'number' => 500 ) ) as $vid ) { $b = $this->balance( (int) $vid ); $method = Vendors::store( (int) $vid )['payout_method']; if ( $b['available'] >= $min && $this->payout_details( (int) $vid, $method ) && ! (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}frontglut_withdrawals WHERE vendor_id = %d AND status = 'pending'", $vid ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL $this->create_request( (int) $vid, $b['available'], $method ); } } } /* ---------------- screens ---------------- */ public function screen_earnings() { global $wpdb; $vid = get_current_user_id(); $b = $this->balance( $vid ); $pg = Request::page(); $c = $wpdb->prefix . 'frontglut_commissions'; $tot = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $c WHERE vendor_id = %d", $vid ) ); // phpcs:ignore WordPress.DB.PreparedSQL $rs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $c WHERE vendor_id = %d ORDER BY id DESC LIMIT 15 OFFSET %d", $vid, ( $pg - 1 ) * 15 ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL $rows = array(); foreach ( $rs as $r ) { $rows[] = array( 'order' => '#' . (int) $r['order_id'] . '', 'gross' => wc_price( $r['gross'] ), 'fee' => wc_price( $r['commission'] ), 'net' => wc_price( $r['net'] ), 'status' => Ui::status_badge( $r['status'] ), 'date' => Ui::date( $r['created'] ) ); } $methods = (array) Settings::get( 'vendors.withdraw_methods', array() ); $labels = array( 'paypal' => 'PayPal', 'bank' => 'Bank transfer', 'manual' => 'Other' ); /* translators: 1: value, 2: value. */ $form = '
' . esc_html( sprintf( __( 'Minimum %1$s. %2$s', 'frontglut' ), wp_strip_all_tags( wc_price( Settings::get( 'vendors.withdraw_min', 50 ) ) ), Settings::get( 'vendors.withdraw_days' ) ? sprintf( __( 'Earnings become available %d days after the order completes.', 'frontglut' ), (int) Settings::get( 'vendors.withdraw_days' ) ) : '' ) ) . '
' . Ui::form_open( 'withdraw_request', array( 'after' => 'reload' ) ) . '' . Ui::form_close( __( 'Request payout', 'frontglut' ) ) . '