records = $records; $this->settings = $settings; $this->resolver = $resolver; $this->logger = $logger; } /** * Register WooCommerce hooks. */ public function register_hooks(): void { add_action( 'woocommerce_order_status_changed', array( $this, 'on_status_changed' ), 20, 4 ); add_action( 'woocommerce_order_refunded', array( $this, 'on_refunded' ), 20, 2 ); // Clean up when an order is permanently removed. add_action( 'woocommerce_before_delete_order', array( $this, 'on_delete_order' ), 20, 1 ); add_action( 'before_delete_post', array( $this, 'on_delete_post' ), 20, 1 ); } /** * Statuses that count as recognised revenue. * * @return string[] */ public function counted_statuses(): array { $settings = get_option( 'profit_engine_settings', array() ); $statuses = is_array( $settings ) && ! empty( $settings['revenue_statuses'] ) && is_array( $settings['revenue_statuses'] ) ? array_values( array_filter( array_map( 'sanitize_key', $settings['revenue_statuses'] ) ) ) : array(); // Fall back to WooCommerce's own "paid" convention when unset. if ( empty( $statuses ) ) { $statuses = array( 'processing', 'completed' ); } /** * Filter which order statuses count toward realised revenue/profit. * * @param string[] $statuses Statuses without the wc- prefix. */ return (array) apply_filters( 'profit_engine_revenue_statuses', $statuses ); } /** * Handle an order status transition. * * @param int $order_id Order ID. * @param string $from Old status. * @param string $to New status. * @param WC_Order $order Order object. */ public function on_status_changed( int $order_id, string $from, string $to, $order ): void { if ( ! $order instanceof WC_Order ) { $order = wc_get_order( $order_id ); } if ( ! $order instanceof WC_Order ) { return; } if ( in_array( $to, $this->counted_statuses(), true ) ) { $this->snapshot_order( $order ); return; } // Left the counted set (cancelled/refunded/failed): keep records for // audit but update their status so aggregates exclude them. if ( $this->records->order_has_records( $order_id ) ) { $this->records->update_order_status( $order_id, $to ); } } /** * Handle a refund being created against an order. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public function on_refunded( int $order_id, int $refund_id ): void { $order = wc_get_order( $order_id ); if ( $order instanceof WC_Order ) { $this->snapshot_order( $order ); } } /** * HPOS delete hook. * * @param int $order_id Order ID. */ public function on_delete_order( int $order_id ): void { $this->records->delete_for_order( $order_id ); } /** * Legacy post delete hook (only acts on shop_order posts). * * @param int $post_id Post ID. */ public function on_delete_post( int $post_id ): void { if ( 'shop_order' === get_post_type( $post_id ) ) { $this->records->delete_for_order( $post_id ); } } /** * (Re)compute and persist cost records for every line of an order. * * @param WC_Order $order Order. * @param string $source 'live' or 'backfill'. * @return int Number of line records written. */ public function snapshot_order( WC_Order $order, string $source = 'live' ): int { $order_id = $order->get_id(); $model = new CostModel( $this->config() ); // --- Pass 1: resolve per-line net figures. --------------------------- $lines = array(); $total_revenue = 0.0; foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) { $variation_id = (int) $item->get_variation_id(); $product_id = (int) $item->get_product_id(); $unit_id = $variation_id > 0 ? $variation_id : $product_id; $qty_ordered = (float) $item->get_quantity(); $qty_refunded = abs( (float) $order->get_qty_refunded_for_item( $item_id ) ); $qty_net = max( 0.0, $qty_ordered - $qty_refunded ); $line_total = (float) $item->get_total(); // After discount, excl tax. $line_subtotal = (float) $item->get_subtotal(); // Before discount, excl tax. $refunded_amt = abs( (float) $order->get_total_refunded_for_item( $item_id ) ); $revenue_net = max( 0.0, $line_total - $refunded_amt ); // Net tax is scaled by the un-refunded quantity ratio. WooCommerce's // per-item tax-refund API requires a specific tax-rate id, so a // proportional reduction is the reliable way to get total net tax // across all rates (and tax is pass-through / excluded by default). $line_tax = (float) $item->get_total_tax(); $qty_ratio = $qty_ordered > 0 ? ( $qty_net / $qty_ordered ) : 0.0; $tax_net = max( 0.0, $line_tax * $qty_ratio ); $unit_cost = $this->resolve_unit_cost( (int) $item_id, $unit_id ); // Snapshot product identity at sale time so historical reports survive a // later rename or deletion. Resolve the variation-specific label/SKU; // fall back to the order item's own stored name if the product is gone. $product_name = $this->resolver->product( $unit_id ) ? $this->resolver->unit_label( $unit_id ) : (string) $item->get_name(); $sku = $this->resolver->product( $unit_id ) ? $this->resolver->sku( $unit_id ) : ''; $lines[] = array( 'item_id' => (int) $item_id, 'unit_id' => $unit_id, 'qty_net' => $qty_net, 'revenue_net' => $revenue_net, 'discount' => max( 0.0, $line_subtotal - $line_total ), 'tax_net' => $tax_net, 'unit_cost' => $unit_cost, 'product_name' => $product_name, 'sku' => $sku, ); $total_revenue += $revenue_net; } if ( empty( $lines ) ) { return 0; } // --- Order-level costs to allocate across lines. --------------------- $order_total_net = (float) $order->get_total() - (float) $order->get_total_refunded(); $order_payment_fee = $model->order_payment_fee( max( 0.0, $order_total_net ) ); $order_other_cost = $model->order_other_cost( max( 0.0, $total_revenue ) ); $order_shipping_net = $model->allocates_shipping() ? max( 0.0, (float) $order->get_shipping_total() - (float) $order->get_total_shipping_refunded() ) : 0.0; $status = $order->get_status(); $order_date = $order->get_date_created() ? gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); // --- Pass 2: allocate + compute profit + persist. -------------------- $written = 0; foreach ( $lines as $line ) { // In backfill mode never overwrite an existing (typically live) record. if ( 'backfill' === $source && $this->records->find_by_item( $line['item_id'] ) ) { continue; } $shipping_alloc = $model->allocate_by_share( $order_shipping_net, $line['revenue_net'], $total_revenue ); $payment_fee = $model->allocate_by_share( $order_payment_fee, $line['revenue_net'], $total_revenue ); $other_cost = $model->allocate_by_share( $order_other_cost, $line['revenue_net'], $total_revenue ); $cost_total = $line['qty_net'] * $line['unit_cost']; $profit = $model->line_profit( array( 'revenue' => $line['revenue_net'], 'cost_total' => $cost_total, 'tax' => $line['tax_net'], 'shipping_alloc' => $shipping_alloc, 'payment_fee' => $payment_fee, 'other_cost' => $other_cost, ) ); $this->records->upsert( array( 'order_id' => $order_id, 'order_item_id' => $line['item_id'], 'product_id' => $line['unit_id'], 'product_name' => $line['product_name'], 'sku' => '' !== $line['sku'] ? $line['sku'] : null, 'qty' => Money::store( $line['qty_net'] ), 'sale_revenue' => Money::store( $line['revenue_net'] ), 'unit_cost' => Money::store( $line['unit_cost'] ), 'cost_total' => Money::store( $cost_total ), 'discount' => Money::store( $line['discount'] ), 'tax' => Money::store( $line['tax_net'] ), 'shipping_alloc' => Money::store( $shipping_alloc ), 'payment_fee' => Money::store( $payment_fee ), 'other_cost' => Money::store( $other_cost ), 'profit' => Money::store( $profit['profit'] ), 'margin' => round( $profit['margin'], 4 ), 'order_status' => $status, 'source' => $source, 'order_date' => $order_date, ) ); ++$written; } return $written; } /** * Resolve the unit cost for a line, preserving any already-snapshotted cost. * * @param int $order_item_id Order item ID. * @param int $unit_id Purchasable-unit ID. */ private function resolve_unit_cost( int $order_item_id, int $unit_id ): float { $existing = $this->records->find_by_item( $order_item_id ); if ( $existing && isset( $existing['unit_cost'] ) ) { return Money::to_float( $existing['unit_cost'] ); } $settings = $this->settings->get( $unit_id ); return $settings ? Money::to_float( $settings['current_cost'] ) : 0.0; } /** * Current plugin settings merged with cost defaults. * * @return array */ private function config(): array { $saved = get_option( 'profit_engine_settings', array() ); return is_array( $saved ) ? $saved : array(); } }