session ) { return 'deposit'; } $mode = WC()->session->get( self::SESSION_MODE_KEY, 'deposit' ); return 'full' === $mode ? 'full' : 'deposit'; } /** * Set the customer's active payment mode for this session. Used by * SDFW_Gateway::maybe_update_payment_mode_from_checkout() (the * customer explicitly toggling the radio) and by payment_fields() * itself when the cart falls below the site-wide minimum (forcing * 'full' regardless of whatever the customer last chose) — a * single setter keeps both call sites from duplicating the same * session write and the same input validation. * * @param string $mode 'deposit' or 'full' — anything else is * treated as 'deposit', matching get_payment_mode()'s * own safe-default behavior. */ public static function set_payment_mode( string $mode ): void { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return; } WC()->session->set( self::SESSION_MODE_KEY, 'full' === $mode ? 'full' : 'deposit' ); } /** * Resolve the flat advance (deposit) amount that actually applies * to an entire cart right now — i.e. respecting the customer's * chosen payment mode (Feature B). Returns 0.0 whenever 'full' mode * is active, on top of everything get_raw_deposit_for_cart() itself * already accounts for. * * Use get_raw_deposit_for_cart() instead when you need "what would * the deposit be if the customer chose deposit mode" regardless of * their current selection — e.g. to label the gateway's mode-toggle * radio options, which need to show the deposit figure even while * "pay in full" is the one currently selected. * * @param \WC_Cart|null $cart Defaults to WC()->cart when omitted. */ public static function get_advance_for_cart( ?\WC_Cart $cart = null ): float { // Customer has chosen to pay the full order total online now — // no partial split applies, so treat this exactly like "no // advance owed" everywhere downstream (cart rows disappear, // gateway process_payment() skips the split, etc.). if ( 'full' === self::get_payment_mode() ) { return 0.0; } return self::get_raw_deposit_for_cart( $cart ); } /** * Resolve the processing fee that applies to the cart right now, * given the customer's currently active payment mode. Returns 0.0 * whenever get_advance_for_cart() itself would return 0.0 — i.e. * 'full' payment mode, an empty/unavailable cart, or a cart below * the site-wide minimum (which forces 'full' mode) — since the fee * is deposit-only by definition and has no meaning without a genuine * deposit to base it on. * * Returned as its own figure, separate from the deposit amount, so * callers (cart/checkout display, the gateway) can show or charge it * as a distinct line item rather than it being silently folded into * get_advance_for_cart()'s result. * * @param \WC_Cart|null $cart Defaults to WC()->cart when omitted. */ public static function get_processing_fee_for_cart( ?\WC_Cart $cart = null ): float { $deposit = self::get_advance_for_cart( $cart ); if ( $deposit <= 0.0 ) { return 0.0; } return sdfw_get_processing_fee_amount( $deposit ); } /** * Resolve the flat advance (deposit) amount for an entire cart, * ignoring the customer's chosen payment mode entirely (see * get_advance_for_cart() for the mode-aware version most callers * actually want). * * Rules: * - Returns 0.0 if the plugin is disabled globally. * - Returns 0.0 if the cart is empty or unavailable. * - Returns 0.0 if the cart total is below the site-wide global * amount (see is_deposit_eligible()) — below that minimum, the * customer pays the full order total online instead of a partial * split, so there's nothing to resolve as an "advance" here. * - Each distinct product/variation contributes its own resolved * amount once — quantity never multiplies it, and multiple * different amounts never sum. Only the single highest amount * across the cart is charged as the advance. * - The result is clamped so it never exceeds the cart's own * contents total (protects against a configured amount exceeding * the price of a heavily-discounted product). * - Rounded to the number of decimals WooCommerce itself uses for * prices, so this always agrees with get_cod_remainder() and * whatever the cart/checkout display shows. * * @param \WC_Cart|null $cart Defaults to WC()->cart when omitted. */ public static function get_raw_deposit_for_cart( ?\WC_Cart $cart = null ): float { if ( ! sdfw_is_enabled() ) { return 0.0; } $cart = self::resolve_cart( $cart ); if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) { return 0.0; } // Cart total doesn't meet the site-wide minimum — full online // payment is required instead of a partial deposit + COD split. if ( ! self::is_deposit_eligible( $cart ) ) { return 0.0; } // Tier lookup uses the cart's contents total (after coupons, before // shipping/tax) — same figure is_deposit_eligible() already uses. $basis = (float) $cart->get_cart_contents_total(); $highest = 0.0; foreach ( $cart->get_cart() as $cart_item ) { $product_id = self::resolve_product_id( $cart_item ); if ( $product_id <= 0 ) { continue; } $amount = sdfw_get_product_amount( $product_id, $basis ); if ( $amount > $highest ) { $highest = $amount; } } if ( $highest <= 0.0 ) { return 0.0; } $cart_total = (float) $cart->get_cart_contents_total(); // Never let the advance exceed what the cart actually costs — // e.g. a heavily-discounted item should never require a // bigger "advance" than its own price. $highest = min( $highest, $cart_total ); return round( $highest, self::get_decimals() ); } /** * Whether the cart's total meets the site-wide minimum required * for the deposit + COD split to apply at all. * * Below this threshold, a "partial advance" would be a larger * fraction of the order than makes sense (or could even exceed * the order total), so full online payment is required instead — * see get_advance_for_cart(), which already folds this check in. * * The threshold is always read live from sdfw_get_global_amount(), * so changing that setting immediately changes this too — nothing * about the minimum is cached or hardcoded here. * * @param \WC_Cart|null $cart Defaults to WC()->cart when omitted. */ public static function is_deposit_eligible( ?\WC_Cart $cart = null ): bool { if ( ! sdfw_is_enabled() ) { return false; } $cart = self::resolve_cart( $cart ); if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) { return false; } $cart_total = (float) $cart->get_cart_contents_total(); return $cart_total >= sdfw_get_global_amount(); } /** * Remaining Cash-on-Delivery balance after the advance is paid. * Clamped to 0 so a misconfigured/oversized advance can never * produce a negative COD amount. */ public static function get_cod_remainder( float $total, float $advance ): float { return round( max( 0.0, $total - $advance ), self::get_decimals() ); } /** * The product that drove the highest (winning) advance amount in * the cart — used to annotate the cart notice / order meta with * "(Deposit set by: Product Name)". * * Ties (two products resolving to the identical highest amount) * resolve to whichever appears first in the cart. * * @param \WC_Cart|null $cart Defaults to WC()->cart when omitted. */ public static function get_advance_source_product( ?\WC_Cart $cart = null ): ?\WC_Product { if ( ! sdfw_is_enabled() ) { return null; } $cart = self::resolve_cart( $cart ); if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) { return null; } $basis = (float) $cart->get_cart_contents_total(); $highest_amount = 0.0; $highest_product = null; foreach ( $cart->get_cart() as $cart_item ) { $product_id = self::resolve_product_id( $cart_item ); if ( $product_id <= 0 ) { continue; } $amount = sdfw_get_product_amount( $product_id, $basis ); if ( $amount > $highest_amount ) { $highest_amount = $amount; $highest_product = ( isset( $cart_item['data'] ) && $cart_item['data'] instanceof \WC_Product ) ? $cart_item['data'] : wc_get_product( $product_id ); } } return $highest_product; } /** * Prefer the variation ID (so sdfw_get_product_amount()'s * variation → parent → global resolution chain applies correctly), * falling back to the plain product ID for simple/external items. * * @param array $cart_item A single WC_Cart line item. */ private static function resolve_product_id( array $cart_item ): int { if ( ! empty( $cart_item['variation_id'] ) ) { return (int) $cart_item['variation_id']; } if ( ! empty( $cart_item['product_id'] ) ) { return (int) $cart_item['product_id']; } return 0; } /** * Fall back to the global cart when none is passed explicitly — * keeps every public method callable as SDFW_Calculator::method() * with no arguments from template code. */ private static function resolve_cart( ?\WC_Cart $cart ): ?\WC_Cart { if ( $cart instanceof \WC_Cart ) { return $cart; } return ( function_exists( 'WC' ) && WC()->cart instanceof \WC_Cart ) ? WC()->cart : null; } /** * Decimal precision to round to — mirrors WooCommerce's own price * rounding so displayed figures never disagree with core. */ private static function get_decimals(): int { return function_exists( 'wc_get_price_decimals' ) ? wc_get_price_decimals() : 2; } }