Plugin Directory

Changeset 3476823


Ignore:
Timestamp:
03/07/2026 02:38:56 AM (4 days ago)
Author:
Jyria
Message:

Release 1.0.5: refactor core coupon logic and improve WooCommerce/Germanized compatibility

Location:
taxproof-coupons-for-woocommerce
Files:
12 added
3 edited
4 copied

Legend:

Unmodified
Added
Removed
  • taxproof-coupons-for-woocommerce/tags/1.0.5/README.md

    r3381508 r3476823  
    44**Tags:** woocommerce, coupon, tax, discount 
    55**Requires at least:** 6.5 
    6 **Tested up to:** 6.8 
    7 **Stable tag:** 1.0.4
     6**Tested up to:** 6.9 
     7**Stable tag:** 1.0.5
    88**License:** GPLv2 or later 
    99**License URI:** https://www.gnu.org/licenses/gpl-2.0.html
     
    1414
    1515- Adds **Apply coupon after tax** checkbox to coupon settings.
    16 - Converts gross coupon values into precise net discounts with high accuracy.
     16- Converts the gross coupon value into the net discount WooCommerce expects, while keeping the applied gross discount exact.
    1717- Guarantees the exact gross amount is deducted in cart and checkout.
    18 - **StoreaBill/Germanized Pro Integration** for accurate invoice generation.
    19 - **Enhanced Admin Display** showing detailed net and gross amounts.
    20 - **Precision Calculations** for complex tax scenarios.
    21 - **Debug Logging** for development and troubleshooting.
     18- Caps oversized coupons to the actually discountable gross cart value.
     19- **StoreaBill/Germanized Pro Integration** via a dedicated compatibility layer.
     20- **WPML/WCML Compatibility** via an isolated order-total correction layer.
     21- **Enhanced Admin Display** showing the persisted gross, net, and tax split.
    2222
    2323## Installation
     
    2929## Changelog
    3030
     31### 1.0.5
     32
     33- Refactored the plugin into a lean core service with isolated StoreaBill and WPML integrations.
     34- Fixed carts where the configured gross coupon amount exceeds the discountable order value.
     35- Removed release-time debug logging and total-adjustment hacks from the wp.org build.
     36- Persist the gross/net/tax split on order coupon items for more reliable invoice generation.
     37
    3138### 1.0.4
    3239
     
    3643- **Admin-Verbesserungen**: Detaillierte Anzeige von Netto- und Bruttobeträgen in der WooCommerce Admin-Oberfläche
    3744- **Erweiterte Metadaten-Speicherung**: Präzise Speicherung von Coupon-Beträgen mit hoher Genauigkeit
    38 - **Debug-Funktionen**: Erweiterte Logging-Funktionen für bessere Entwicklung und Fehlerbehebung
    3945- **Hook-Integration**: Neue Hooks für bessere Integration mit WooCommerce und Drittanbieter-Plugins
    4046- **Performance-Optimierungen**: Verbesserte Berechnungslogik für komplexe Steuerszenarien
  • taxproof-coupons-for-woocommerce/tags/1.0.5/readme.txt

    r3381508 r3476823  
    44Tags: woocommerce, coupon, tax, discount
    55Requires at least: 6.5
    6 Tested up to: 6.8
    7 Stable tag: 1.0.4
     6Tested up to: 6.9
     7Stable tag: 1.0.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    2727
    2828== Changelog ==
     29
     30= 1.0.5 =
     31
     32Release date: March 2026
     33
     34* Refactored the plugin into a cleaner core service plus isolated WPML and StoreaBill compatibility layers.
     35* Fixed fixed-cart coupons whose configured gross amount is larger than the discountable cart total.
     36* Removed release-time debug logging and broad total-manipulation hooks that caused rounding drift in edge cases.
     37* Persist the gross, net, and tax components on order coupon items so Germanized Pro / StoreaBill can invoice consistently.
     38* Keep the displayed coupon amount capped to the effective gross discount that can actually be applied.
    2939
    3040= 1.0.4 =
  • taxproof-coupons-for-woocommerce/tags/1.0.5/tax-proof-coupons-plugin.php

    r3381508 r3476823  
    44 * Plugin URI:        https://github.com/s-a-s-k-i-a/tax-proof-coupons
    55 * Description:       Ensure fixed-value coupons always apply after tax, regardless of VAT rate or customer location.
    6  * Version:           1.0.4
     6 * Version:           1.0.5
    77 * Author:            Saskia Teichmann
    88 * Author URI:        https://saskialund.de
     
    1616
    1717if ( ! defined( 'ABSPATH' ) ) {
    18     exit; // Exit if accessed directly.
     18    exit;
    1919}
    2020
    21 /**
    22  * Main plugin class for Tax‑Proof Coupons for WooCommerce functionality.
    23  */
    24 class Plugin {
    25     /** Plugin version. */
    26     public const VERSION = '1.0.4';
     21require_once __DIR__ . '/includes/class-coupon-service.php';
     22require_once __DIR__ . '/includes/integrations/class-storeabill-integration.php';
     23require_once __DIR__ . '/includes/integrations/class-wpml-integration.php';
     24require_once __DIR__ . '/includes/class-plugin.php';
    2725
    28     /** Singleton instance. */
    29     private static $instance = null;
    30 
    31     /**
    32      * Get or create the singleton instance.
    33      *
    34      * @return Plugin
    35      */
    36     public static function instance(): Plugin {
    37         if ( null === self::$instance ) {
    38             self::$instance = new self();
    39             self::$instance->init_hooks();
    40         }
    41         return self::$instance;
    42     }
    43 
    44     /** Prevent direct instantiation. */
    45     private function __construct() {}
    46 
    47     /**
    48      * Check if Germanized (free or Pro) is active.
    49      *
    50      * @return bool
    51      */
    52     private function is_storeabill_active(): bool {
    53         return function_exists( 'WC_GZD' );
    54     }
    55 
    56     /** Initialize WP and WooCommerce hooks. */
    57     private function init_hooks(): void {
    58         add_action( 'woocommerce_coupon_options', [ $this, 'add_apply_after_tax_checkbox' ] );
    59         add_action( 'woocommerce_coupon_options_save', [ $this, 'save_apply_after_tax_checkbox' ], 10, 2 );
    60         add_filter( 'woocommerce_coupon_get_discount_amount', [ $this, 'apply_coupon_after_tax' ], 20, 5 );
    61         add_filter( 'woocommerce_coupon_discount_amount_html', [ $this, 'adjust_coupon_display_amount' ], 20, 2 );
    62         add_filter( 'woocommerce_cart_totals_coupon_html', [ $this, 'adjust_cart_coupon_display' ], 20, 3 );
    63        
    64         // Store correct net amounts when creating order items
    65         add_action( 'woocommerce_checkout_create_order', [ $this, 'store_coupon_net_amounts' ], 10, 2 );
    66         add_action( 'woocommerce_create_order_coupon_item', [ $this, 'set_correct_coupon_amounts' ], 10, 4 );
    67        
    68         // Admin display
    69         add_filter( 'woocommerce_order_formatted_line_subtotal', [ $this, 'adjust_admin_coupon_display' ], 10, 3 );
    70        
    71         // Force correct calculation for invoices - use the correct hook
    72         add_filter( 'woocommerce_order_get_total_discount', [ $this, 'adjust_total_discount_for_invoices' ], 10, 2 );
    73        
    74         // StoreaBill (Germanized Pro) specific hooks - only if available
    75         if ( $this->is_storeabill_active() ) {
    76             add_filter( 'storeabill_invoice_get_discount_total', [ $this, 'storeabill_get_discount_total_gross' ], 10, 2 );
    77             add_filter( 'storeabill_woo_order_voucher_total', [ $this, 'storeabill_voucher_total_gross' ], 10, 2 );
    78            
    79             // Hook sniffer to find the correct Germanized hooks (only in debug mode)
    80             if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    81                 add_action( 'all', [ $this, 'log_germanized_hooks' ], 1 );
    82             }
    83         }
    84     }
    85 
    86     /** Add "Apply after tax" checkbox to the coupon admin screen. */
    87     public function add_apply_after_tax_checkbox(): void {
    88         \woocommerce_wp_checkbox( [
    89             'id'          => 'tpc_apply_after_tax',
    90             'label'       => __( 'Apply coupon after tax', 'taxproof-coupons-for-woocommerce' ),
    91             'description' => __( 'Deduct the fixed coupon amount from the order total including tax, ensuring the coupon value remains constant across all tax rates and locations.', 'taxproof-coupons-for-woocommerce' ),
    92         ] );
    93     }
    94 
    95     /**
    96      * Save the "Apply after tax" checkbox value.
    97      *
    98      * @param int       $post_id Coupon post ID.
    99      * @param \WC_Coupon $coupon Coupon object.
    100      */
    101     public function save_apply_after_tax_checkbox( int $post_id, \WC_Coupon $coupon ): void {
    102         // Verify nonce for security
    103         if ( ! isset( $_POST['woocommerce_meta_nonce'] ) ||
    104              ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['woocommerce_meta_nonce'] ) ), 'woocommerce_save_data' ) ) {
    105             return;
    106         }
    107        
    108         $apply_after_tax = isset( $_POST['tpc_apply_after_tax'] ) ? 'yes' : 'no';
    109         $coupon->update_meta_data( 'tpc_apply_after_tax', $apply_after_tax );
    110         $coupon->save();
    111     }
    112 
    113     /**
    114      * Apply fixed-cart coupons after tax: convert the gross amount into the correct net discount,
    115      * then hand that to WC so that tax is re-applied correctly.
    116      *
    117      * @param float      $discount           Current discount amount for this item.
    118      * @param float      $discounting_amount The original line total being discounted.
    119      * @param array      $cart_item          Cart item data.
    120      * @param bool       $single             Single item flag.
    121      * @param \WC_Coupon $coupon             Coupon object.
    122      * @return float     Modified discount.
    123      */
    124     public function apply_coupon_after_tax(
    125         float      $discount,
    126         float      $discounting_amount,
    127         array      $cart_item,
    128         bool       $single,
    129         \WC_Coupon $coupon
    130     ): float {
    131         // Only target fixed-cart coupons with our checkbox enabled.
    132         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    133              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    134             return $discount;
    135         }
    136 
    137         static $applied = [];
    138         $code = $coupon->get_code();
    139 
    140         // Only apply once per coupon code.
    141         if ( in_array( $code, $applied, true ) ) {
    142             return 0.0;
    143         }
    144 
    145         // Sum gross and net totals across all product line items.
    146         $total_gross = 0.0;
    147         $total_net   = 0.0;
    148         foreach ( WC()->cart->get_cart() as $item ) {
    149             $total_net   += wc_get_price_excluding_tax( $item['data'], [ 'qty' => $item['quantity'] ] );
    150             $total_gross += wc_get_price_including_tax(   $item['data'], [ 'qty' => $item['quantity'] ] );
    151         }
    152 
    153         if ( $total_net <= 0 || $total_gross <= 0 ) {
    154             return 0.0;
    155         }
    156 
    157         // The gross amount the admin entered (e.g. 150).
    158         $gross_coupon = floatval( $coupon->get_amount() );
    159 
    160         // Compute the average tax rate across items.
    161         $avg_tax_rate = ( $total_gross / $total_net ) - 1;
    162 
    163         // Convert the gross coupon into the correct net discount with precision handling
    164         $net_discount = $this->calculate_precise_net_discount(
    165             $gross_coupon,
    166             $avg_tax_rate,
    167             $total_net,
    168             $total_gross
    169         );
    170 
    171         // Mark as applied so we don't double-dip.
    172         $applied[] = $code;
    173 
    174         return $net_discount;
    175     }
    176 
    177     /**
    178      * Adjust the coupon display amount to show the gross value.
    179      * This ensures the customer always sees the advertised coupon amount.
    180      *
    181      * @param string $discount_amount_html The HTML string for the discount amount
    182      * @param \WC_Coupon $coupon The coupon object
    183      * @return string Modified HTML
    184      */
    185     public function adjust_coupon_display_amount( string $discount_amount_html, \WC_Coupon $coupon ): string {
    186         // Only adjust for our tax-proof coupons
    187         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    188              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    189             return $discount_amount_html;
    190         }
    191        
    192         // Get the original coupon amount (gross)
    193         $gross_amount = floatval( $coupon->get_amount() );
    194        
    195         // Format it as WooCommerce would
    196         $formatted_amount = wc_price( $gross_amount );
    197        
    198         error_log( sprintf( 'TPC Debug: Adjusting display from %s to %s', $discount_amount_html, $formatted_amount ) );
    199        
    200         return '-' . $formatted_amount;
    201     }
    202 
    203     /**
    204      * Adjust the cart display to show the gross coupon amount.
    205      * This filter specifically targets the cart and checkout displays.
    206      *
    207      * @param string $coupon_html Current HTML
    208      * @param \WC_Coupon $coupon Coupon object 
    209      * @param string $discount_amount_html Discount amount HTML
    210      * @return string Modified HTML
    211      */
    212     public function adjust_cart_coupon_display( string $coupon_html, \WC_Coupon $coupon, string $discount_amount_html ): string {
    213         // Only adjust for our tax-proof coupons
    214         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    215              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    216             return $coupon_html;
    217         }
    218        
    219         // Get the original coupon amount (gross)
    220         $gross_amount = floatval( $coupon->get_amount() );
    221        
    222         // Format it as WooCommerce would
    223         $formatted_amount = wc_price( $gross_amount );
    224        
    225         // Replace the discount amount in the HTML
    226         $new_html = str_replace( $discount_amount_html, '-' . $formatted_amount, $coupon_html );
    227        
    228         error_log( sprintf( 'TPC Debug: Cart display adjustment - Original: %s, New: %s', $coupon_html, $new_html ) );
    229        
    230         return $new_html;
    231     }
    232 
    233     /**
    234      * Calculate precise net discount that ensures the gross amount matches exactly.
    235      * This method handles rounding issues by finding the optimal net amount.
    236      *
    237      * @param float $gross_coupon  The gross coupon amount (e.g., 100.00)
    238      * @param float $avg_tax_rate  The average tax rate (e.g., 0.21 for 21%)
    239      * @param float $total_net     Total net amount in cart
    240      * @param float $total_gross   Total gross amount in cart
    241      * @return float The precise net discount amount
    242      */
    243     private function calculate_precise_net_discount(
    244         float $gross_coupon,
    245         float $avg_tax_rate,
    246         float $total_net,
    247         float $total_gross
    248     ): float {
    249         $decimals = wc_get_price_decimals();
    250         $precision_decimals = 4; // Use 4 decimals for internal precision
    251        
    252         // Debug logging
    253         error_log( 'TPC Debug: Starting precise calculation' );
    254         error_log( sprintf( 'TPC Debug: Gross coupon: %.4f', $gross_coupon ) );
    255         error_log( sprintf( 'TPC Debug: Avg tax rate: %.4f (%.2f%%)', $avg_tax_rate, $avg_tax_rate * 100 ) );
    256         error_log( sprintf( 'TPC Debug: Decimals: %d', $decimals ) );
    257        
    258         // Initial calculation with higher precision
    259         $net_discount_exact = $gross_coupon / ( 1 + $avg_tax_rate );
    260         error_log( sprintf( 'TPC Debug: Exact net discount (unrounded): %.6f', $net_discount_exact ) );
    261        
    262         // Try both floor and ceil to see which gives us the target gross
    263         $net_floor = floor( $net_discount_exact * pow( 10, $decimals ) ) / pow( 10, $decimals );
    264         $net_ceil = ceil( $net_discount_exact * pow( 10, $decimals ) ) / pow( 10, $decimals );
    265        
    266         error_log( sprintf( 'TPC Debug: Net floor: %.4f, Net ceil: %.4f', $net_floor, $net_ceil ) );
    267        
    268         // Calculate gross for both options
    269         // IMPORTANT: For Germany with 19% MwSt:
    270         // 84.03 * 1.19 = 99.9957 which rounds to 99.99 (NOT 100.00!)
    271         // 84.04 * 1.19 = 100.0076 which rounds to 100.01
    272         $gross_floor_exact = $net_floor * ( 1 + $avg_tax_rate );
    273         $gross_ceil_exact = $net_ceil * ( 1 + $avg_tax_rate );
    274         $gross_floor = round( $gross_floor_exact, $decimals );
    275         $gross_ceil = round( $gross_ceil_exact, $decimals );
    276        
    277         error_log( sprintf( 'TPC Debug: Floor exact: %.6f, Ceil exact: %.6f', $gross_floor_exact, $gross_ceil_exact ) );
    278        
    279         error_log( sprintf( 'TPC Debug: Gross from floor: %.4f, Gross from ceil: %.4f', $gross_floor, $gross_ceil ) );
    280        
    281         // Choose the net amount that gives us the exact gross we want
    282         // The problem: With only 2 decimals, we can't always achieve the exact gross amount
    283         // Solution: Return the EXACT net amount with more precision
    284        
    285         if ( $gross_floor == $gross_coupon ) {
    286             $net_discount = $net_floor;
    287             error_log( 'TPC Debug: Using floor value for exact match' );
    288         } elseif ( $gross_ceil == $gross_coupon ) {
    289             $net_discount = $net_ceil;
    290             error_log( 'TPC Debug: Using ceil value for exact match' );
    291         } else {
    292             // For 19% MwSt: 84.03 gives 99.9957 which rounds to 99.99, NOT 100.00
    293             // We need to use 84.04 which gives 100.0076 and rounds to 100.01
    294             // But we can accept 100.01 and show 100.00 in display
    295             error_log( sprintf( 'TPC Debug: No exact match - floor gives %.4f, ceil gives %.4f', $gross_floor, $gross_ceil ) );
    296            
    297             // Prefer the value that's slightly over rather than under
    298             if ( $gross_floor < $gross_coupon && $gross_ceil > $gross_coupon ) {
    299                 // If floor is under and ceil is over, use ceil
    300                 $net_discount = $net_ceil;
    301                 error_log( 'TPC Debug: Using ceil to avoid underpayment' );
    302             } else {
    303                 // If neither gives exact match, we need a more sophisticated approach
    304             // Calculate what net amount would give us exactly the gross we want
    305             // This might require adjusting by fractions of a cent
    306            
    307             error_log( 'TPC Debug: Neither floor nor ceil gives exact match, calculating optimal value' );
    308            
    309             // We'll use a binary search approach to find the optimal value
    310             $lower = $net_floor;
    311             $upper = $net_ceil;
    312             $best_net = $net_floor;
    313             $best_diff = abs( $gross_floor - $gross_coupon );
    314            
    315             // Try intermediate values
    316             for ( $i = 1; $i < 10; $i++ ) {
    317                 $test_net = $lower + ( $upper - $lower ) * ( $i / 10 );
    318                 $test_net = round( $test_net, $decimals + 2 ); // Use extra precision
    319                 $test_gross = round( $test_net * ( 1 + $avg_tax_rate ), $decimals );
    320                 $diff = abs( $test_gross - $gross_coupon );
    321                
    322                 if ( $diff < $best_diff ) {
    323                     $best_net = $test_net;
    324                     $best_diff = $diff;
    325                    
    326                     if ( $diff < 0.001 ) {
    327                         error_log( sprintf( 'TPC Debug: Found optimal value: %.4f', $best_net ) );
    328                         break;
    329                     }
    330                 }
    331             }
    332            
    333             // Round to allowed decimals
    334             $net_discount = round( $best_net, $decimals );
    335            
    336             // If this still doesn't give us the exact gross, adjust by one cent in the right direction
    337             $final_gross = round( $net_discount * ( 1 + $avg_tax_rate ), $decimals );
    338             if ( $final_gross < $gross_coupon ) {
    339                 $net_discount += pow( 10, -$decimals );
    340                 error_log( 'TPC Debug: Adjusted up by one cent for exact match' );
    341             } elseif ( $final_gross > $gross_coupon && $net_discount > pow( 10, -$decimals ) ) {
    342                 $net_discount -= pow( 10, -$decimals );
    343                 error_log( 'TPC Debug: Adjusted down by one cent for exact match' );
    344             }
    345             }
    346         }
    347        
    348         // Final step: Always return the EXACT mathematical value
    349         // Don't try to round to 2 decimals - return with full precision
    350         $exact_net = $gross_coupon / ( 1 + $avg_tax_rate );
    351        
    352         error_log( sprintf( 'TPC Debug: Final calculation - Gross %.4f / (1 + %.4f) = %.10f',
    353             $gross_coupon, $avg_tax_rate, $exact_net ) );
    354        
    355         // Return with high precision to ensure exact calculations
    356         return $exact_net;
    357     }
    358 
    359     /**
    360      * Set correct coupon amounts when creating the order item.
    361      * This runs BEFORE the item is saved, allowing us to set the correct net amount.
    362      *
    363      * @param \WC_Order_Item_Coupon $item The coupon item being created
    364      * @param string $code Coupon code
    365      * @param float $discount Discount amount
    366      * @param \WC_Order $order Order object
    367      */
    368     public function set_correct_coupon_amounts( \WC_Order_Item_Coupon $item, string $code, float $discount, \WC_Order $order ): void {
    369         error_log( sprintf( 'TPC Debug: set_correct_coupon_amounts called - Code: %s, Discount: %.4f', $code, $discount ) );
    370        
    371         $coupon = new \WC_Coupon( $code );
    372        
    373         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    374              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    375             error_log( 'TPC Debug: Not a tax-proof coupon, skipping' );
    376             return;
    377         }
    378        
    379         // Get the gross amount (what customer sees)
    380         $gross_amount = floatval( $coupon->get_amount() );
    381        
    382         // Calculate tax rate from cart totals
    383         $cart = WC()->cart;
    384         $total_tax = $cart->get_total_tax();
    385         $subtotal = $cart->get_subtotal();
    386         $total = $cart->get_total( 'edit' );
    387        
    388         // Calculate average tax rate
    389         $avg_tax_rate = $subtotal > 0 ? ( $total_tax / $subtotal ) : 0;
    390        
    391         // Calculate precise net amount
    392         $net_amount = $this->calculate_precise_net_discount(
    393             $gross_amount,
    394             $avg_tax_rate,
    395             $subtotal,
    396             $subtotal + $total_tax
    397         );
    398        
    399         // Calculate tax amount
    400         $tax_amount = $gross_amount - $net_amount;
    401        
    402         // Set the correct amounts on the item BEFORE it's saved
    403         // IMPORTANT: We store the EXACT net amount with more decimals for correct calculation
    404         // This ensures that net * tax_rate = exactly the gross amount we want
    405         $item->set_discount( $net_amount );
    406         $item->set_discount_tax( $tax_amount );
    407        
    408         // Store metadata for reference - with full precision
    409         $item->add_meta_data( '_tpc_gross_discount', $gross_amount );
    410         $item->add_meta_data( '_tpc_net_discount', number_format( $net_amount, 10, '.', '' ) ); // Store with 10 decimals
    411         $item->add_meta_data( '_tpc_tax_rate', $avg_tax_rate );
    412         $item->add_meta_data( '_tpc_applied_after_tax', 'yes' );
    413        
    414         error_log( sprintf(
    415             'TPC Debug: Setting coupon amounts - Code: %s, Gross: %.4f, Net: %.4f, Tax: %.4f',
    416             $code,
    417             $gross_amount,
    418             $net_amount,
    419             $tax_amount
    420         ) );
    421     }
    422 
    423     /**
    424      * Store precise net amounts after order is created.
    425      * This ensures Germanized Pro uses the correct values for invoice generation.
    426      *
    427      * @param \WC_Order $order Order object
    428      * @param array $data Checkout data
    429      */
    430     public function store_coupon_net_amounts( \WC_Order $order, array $data ): void {
    431         error_log( sprintf( 'TPC Debug: store_coupon_net_amounts called for order %s', $order->get_id() ) );
    432        
    433         foreach ( $order->get_coupons() as $item ) {
    434             $coupon = new \WC_Coupon( $item->get_code() );
    435            
    436             if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    437                  'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    438                 continue;
    439             }
    440            
    441             error_log( sprintf( 'TPC Debug: Processing tax-proof coupon %s', $item->get_code() ) );
    442            
    443             // Get the gross amount
    444             $gross_amount = floatval( $coupon->get_amount() );
    445            
    446             // Calculate tax rate from order
    447             // IMPORTANT: We need the subtotal BEFORE discount to get the correct tax rate
    448             $order_subtotal_before_discount = 0;
    449             $order_tax = 0;
    450            
    451             // Calculate from line items (products) to get accurate rate
    452             foreach ( $order->get_items() as $line_item ) {
    453                 $order_subtotal_before_discount += $line_item->get_subtotal();
    454                 $order_tax += $line_item->get_subtotal_tax();
    455             }
    456            
    457             $avg_tax_rate = $order_subtotal_before_discount > 0 ? ( $order_tax / $order_subtotal_before_discount ) : 0;
    458            
    459             error_log( sprintf(
    460                 'TPC Debug: Order data - Subtotal (before discount): %.4f, Tax: %.4f, Calc Tax Rate: %.6f (%.2f%%)',
    461                 $order_subtotal_before_discount,
    462                 $order_tax,
    463                 $avg_tax_rate,
    464                 $avg_tax_rate * 100
    465             ) );
    466            
    467             // Calculate exact net amount
    468             $net_amount_exact = $gross_amount / ( 1 + $avg_tax_rate );
    469             $tax_amount_exact = $gross_amount - $net_amount_exact;
    470            
    471             error_log( sprintf(
    472                 'TPC Debug: Calculation - Gross: %.4f / (1 + %.6f) = Net: %.10f, Tax: %.10f',
    473                 $gross_amount,
    474                 $avg_tax_rate,
    475                 $net_amount_exact,
    476                 $tax_amount_exact
    477             ) );
    478            
    479             // Update the item with precise values
    480             $item->set_discount( $net_amount_exact );
    481             $item->set_discount_tax( $tax_amount_exact );
    482            
    483             // Store metadata
    484             $item->update_meta_data( '_tpc_gross_discount', $gross_amount );
    485             $item->update_meta_data( '_tpc_net_discount', $net_amount_exact );
    486             $item->update_meta_data( '_tpc_tax_rate', $avg_tax_rate );
    487             $item->update_meta_data( '_tpc_applied_after_tax', 'yes' );
    488             $item->save();
    489            
    490             error_log( sprintf(
    491                 'TPC Debug: Updated coupon %s - Gross: %.4f, Net: %.10f, Tax: %.10f',
    492                 $item->get_code(),
    493                 $gross_amount,
    494                 $net_amount_exact,
    495                 $tax_amount_exact
    496             ) );
    497         }
    498        
    499         // Save the order to persist all changes
    500         $order->save();
    501     }
    502 
    503 
    504     /**
    505      * Adjust coupon display in admin order view.
    506      *
    507      * @param string $subtotal Formatted subtotal
    508      * @param \WC_Order_Item $item Order item
    509      * @param \WC_Order $order Order object
    510      * @return string Modified subtotal
    511      */
    512     public function adjust_admin_coupon_display( string $subtotal, \WC_Order_Item $item, \WC_Order $order ): string {
    513         // Only process coupon items
    514         if ( ! $item->is_type( 'coupon' ) ) {
    515             return $subtotal;
    516         }
    517        
    518         $net_discount = $item->get_meta( '_tpc_net_discount' );
    519         $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    520        
    521         error_log( sprintf( 'TPC Debug: Meta data - Net: %s, Gross: %s',
    522             $net_discount ? $net_discount : 'NOT FOUND',
    523             $gross_discount ? $gross_discount : 'NOT FOUND'
    524         ) );
    525        
    526         if ( $net_discount && $gross_discount ) {
    527             // Show detailed information in admin with 4 decimal places
    528             $net_value = floatval( $net_discount );
    529             $net_display = number_format( $net_value, 4, ',', '' );
    530             $subtotal = sprintf(
    531                 '-<span class="woocommerce-Price-amount amount">%s&nbsp;<span class="woocommerce-Price-currencySymbol">€</span></span> <small class="woocommerce-help-tip" data-tip="Brutto: %s">(Brutto: %s)</small>',
    532                 $net_display,
    533                 wc_price( $gross_discount ),
    534                 wc_price( $gross_discount )
    535             );
    536            
    537             error_log( sprintf(
    538                 'TPC Debug: Admin display adjusted - Net: %.4f, Gross: %.4f',
    539                 $net_discount,
    540                 $gross_discount
    541             ) );
    542         }
    543        
    544         return $subtotal;
    545     }
    546 
    547 
    548     /**
    549      * Adjust total discount to ensure it matches the gross coupon amount.
    550      * This is critical for invoice generation.
    551      *
    552      * @param float $total_discount Current total discount
    553      * @param \WC_Order $order Order object
    554      * @return float Adjusted discount
    555      */
    556     public function adjust_total_discount_for_invoices( float $total_discount, \WC_Order $order ): float {
    557         $adjusted_total = 0;
    558        
    559         foreach ( $order->get_coupons() as $item ) {
    560             if ( $item->get_meta( '_tpc_applied_after_tax' ) === 'yes' ) {
    561                 // Use the gross discount amount for tax-proof coupons
    562                 $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    563                 if ( $gross_discount ) {
    564                     $adjusted_total += floatval( $gross_discount );
    565                     error_log( sprintf(
    566                         'TPC Debug: Adjusting invoice discount for %s - Using gross: %.4f instead of net+tax',
    567                         $item->get_code(),
    568                         $gross_discount
    569                     ) );
    570                 } else {
    571                     $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    572                 }
    573             } else {
    574                 $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    575             }
    576         }
    577        
    578         if ( $adjusted_total != $total_discount ) {
    579             error_log( sprintf(
    580                 'TPC Debug: Adjusted total discount from %.4f to %.4f',
    581                 $total_discount,
    582                 $adjusted_total
    583             ) );
    584         }
    585        
    586         return $adjusted_total;
    587     }
    588 
    589     /**
    590      * Adjust StoreaBill invoice discount total to use gross amounts.
    591      * This filter is called when StoreaBill calculates the total discount for the invoice.
    592      *
    593      * @param float $total Current discount total
    594      * @param object $invoice The invoice document
    595      * @return float Adjusted total
    596      */
    597     public function storeabill_get_discount_total_gross( float $total, $invoice ): float {
    598         // Get the WooCommerce order
    599         if ( ! method_exists( $invoice, 'get_order' ) ) {
    600             return $total;
    601         }
    602        
    603         $order = $invoice->get_order();
    604         if ( ! $order ) {
    605             return $total;
    606         }
    607        
    608         $adjusted_total = 0;
    609         $has_tax_proof_coupons = false;
    610        
    611         foreach ( $order->get_coupons() as $item ) {
    612             $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    613             if ( $gross_discount ) {
    614                 $adjusted_total += floatval( $gross_discount );
    615                 $has_tax_proof_coupons = true;
    616                 error_log( sprintf(
    617                     'TPC Debug: StoreaBill discount total - Using gross: %.4f for %s',
    618                     $gross_discount,
    619                     $item->get_code()
    620                 ) );
    621             } else {
    622                 $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    623             }
    624         }
    625        
    626         if ( $has_tax_proof_coupons && $adjusted_total != $total ) {
    627             error_log( sprintf(
    628                 'TPC Debug: StoreaBill total discount changed from %.4f to %.4f',
    629                 $total,
    630                 $adjusted_total
    631             ) );
    632             return $adjusted_total;
    633         }
    634        
    635         return $total;
    636     }
    637 
    638     /**
    639      * Adjust StoreaBill voucher total to use gross amounts.
    640      *
    641      * @param float $total Current voucher total
    642      * @param object $order_data_store The WooCommerce order data store
    643      * @return float Adjusted total
    644      */
    645     public function storeabill_voucher_total_gross( float $total, $order_data_store ): float {
    646         error_log( sprintf( 'TPC Debug: storeabill_voucher_total_gross called - Total: %.4f', $total ) );
    647        
    648         if ( ! method_exists( $order_data_store, 'get_order' ) ) {
    649             error_log( 'TPC Debug: No get_order method' );
    650             return $total;
    651         }
    652        
    653         $order = $order_data_store->get_order();
    654         if ( ! $order ) {
    655             error_log( 'TPC Debug: No order found' );
    656             return $total;
    657         }
    658        
    659         $adjusted_total = 0;
    660        
    661         foreach ( $order->get_coupons() as $item ) {
    662             $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    663             if ( $gross_discount ) {
    664                 $adjusted_total += floatval( $gross_discount );
    665                 error_log( sprintf(
    666                     'TPC Debug: StoreaBill voucher total - Using gross: %.4f for %s',
    667                     $gross_discount,
    668                     $item->get_code()
    669                 ) );
    670             }
    671         }
    672        
    673         if ( $adjusted_total > 0 ) {
    674             error_log( sprintf(
    675                 'TPC Debug: StoreaBill voucher total changed from %.4f to %.4f',
    676                 $total,
    677                 $adjusted_total
    678             ) );
    679             return $adjusted_total;
    680         }
    681        
    682         return $total;
    683     }
    684 
    685     /**
    686      * Log all Germanized/StoreaBill hooks to find the right one.
    687      * This is a temporary debugging method.
    688      *
    689      * @param string $hook Hook name
    690      */
    691     public function log_germanized_hooks( string $hook ): void {
    692         // Only log during invoice generation (check if we're in admin)
    693         if ( ! is_admin() ) {
    694             return;
    695         }
    696        
    697         // Only log hooks that might be related to invoice/discount
    698         if ( strpos( $hook, 'gzd' ) !== false ||
    699              strpos( $hook, 'germanized' ) !== false ||
    700              strpos( $hook, 'storeabill' ) !== false ||
    701              strpos( $hook, 'invoice' ) !== false ) {
    702            
    703             // Only log filter hooks (not actions) to reduce noise
    704             if ( strpos( $hook, 'discount' ) !== false ||
    705                  strpos( $hook, 'total' ) !== false ||
    706                  strpos( $hook, 'coupon' ) !== false ) {
    707                 error_log( sprintf( 'TPC Debug: Hook fired: %s', $hook ) );
    708             }
    709         }
    710     }
    711 
    712     /** Public wakeup to satisfy PHP's magic method requirement. */
    713     public function __wakeup() {}
    714 }
    715 
    716 // Bootstrap the plugin.
    717 add_action( 'plugins_loaded', [ Plugin::class, 'instance' ] );
     26add_action( 'plugins_loaded', array( Plugin::class, 'instance' ) );
  • taxproof-coupons-for-woocommerce/trunk/README.md

    r3381508 r3476823  
    44**Tags:** woocommerce, coupon, tax, discount 
    55**Requires at least:** 6.5 
    6 **Tested up to:** 6.8 
    7 **Stable tag:** 1.0.4
     6**Tested up to:** 6.9 
     7**Stable tag:** 1.0.5
    88**License:** GPLv2 or later 
    99**License URI:** https://www.gnu.org/licenses/gpl-2.0.html
     
    1414
    1515- Adds **Apply coupon after tax** checkbox to coupon settings.
    16 - Converts gross coupon values into precise net discounts with high accuracy.
     16- Converts the gross coupon value into the net discount WooCommerce expects, while keeping the applied gross discount exact.
    1717- Guarantees the exact gross amount is deducted in cart and checkout.
    18 - **StoreaBill/Germanized Pro Integration** for accurate invoice generation.
    19 - **Enhanced Admin Display** showing detailed net and gross amounts.
    20 - **Precision Calculations** for complex tax scenarios.
    21 - **Debug Logging** for development and troubleshooting.
     18- Caps oversized coupons to the actually discountable gross cart value.
     19- **StoreaBill/Germanized Pro Integration** via a dedicated compatibility layer.
     20- **WPML/WCML Compatibility** via an isolated order-total correction layer.
     21- **Enhanced Admin Display** showing the persisted gross, net, and tax split.
    2222
    2323## Installation
     
    2929## Changelog
    3030
     31### 1.0.5
     32
     33- Refactored the plugin into a lean core service with isolated StoreaBill and WPML integrations.
     34- Fixed carts where the configured gross coupon amount exceeds the discountable order value.
     35- Removed release-time debug logging and total-adjustment hacks from the wp.org build.
     36- Persist the gross/net/tax split on order coupon items for more reliable invoice generation.
     37
    3138### 1.0.4
    3239
     
    3643- **Admin-Verbesserungen**: Detaillierte Anzeige von Netto- und Bruttobeträgen in der WooCommerce Admin-Oberfläche
    3744- **Erweiterte Metadaten-Speicherung**: Präzise Speicherung von Coupon-Beträgen mit hoher Genauigkeit
    38 - **Debug-Funktionen**: Erweiterte Logging-Funktionen für bessere Entwicklung und Fehlerbehebung
    3945- **Hook-Integration**: Neue Hooks für bessere Integration mit WooCommerce und Drittanbieter-Plugins
    4046- **Performance-Optimierungen**: Verbesserte Berechnungslogik für komplexe Steuerszenarien
  • taxproof-coupons-for-woocommerce/trunk/readme.txt

    r3381508 r3476823  
    44Tags: woocommerce, coupon, tax, discount
    55Requires at least: 6.5
    6 Tested up to: 6.8
    7 Stable tag: 1.0.4
     6Tested up to: 6.9
     7Stable tag: 1.0.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    2727
    2828== Changelog ==
     29
     30= 1.0.5 =
     31
     32Release date: March 2026
     33
     34* Refactored the plugin into a cleaner core service plus isolated WPML and StoreaBill compatibility layers.
     35* Fixed fixed-cart coupons whose configured gross amount is larger than the discountable cart total.
     36* Removed release-time debug logging and broad total-manipulation hooks that caused rounding drift in edge cases.
     37* Persist the gross, net, and tax components on order coupon items so Germanized Pro / StoreaBill can invoice consistently.
     38* Keep the displayed coupon amount capped to the effective gross discount that can actually be applied.
    2939
    3040= 1.0.4 =
  • taxproof-coupons-for-woocommerce/trunk/tax-proof-coupons-plugin.php

    r3381508 r3476823  
    44 * Plugin URI:        https://github.com/s-a-s-k-i-a/tax-proof-coupons
    55 * Description:       Ensure fixed-value coupons always apply after tax, regardless of VAT rate or customer location.
    6  * Version:           1.0.4
     6 * Version:           1.0.5
    77 * Author:            Saskia Teichmann
    88 * Author URI:        https://saskialund.de
     
    1616
    1717if ( ! defined( 'ABSPATH' ) ) {
    18     exit; // Exit if accessed directly.
     18    exit;
    1919}
    2020
    21 /**
    22  * Main plugin class for Tax‑Proof Coupons for WooCommerce functionality.
    23  */
    24 class Plugin {
    25     /** Plugin version. */
    26     public const VERSION = '1.0.4';
     21require_once __DIR__ . '/includes/class-coupon-service.php';
     22require_once __DIR__ . '/includes/integrations/class-storeabill-integration.php';
     23require_once __DIR__ . '/includes/integrations/class-wpml-integration.php';
     24require_once __DIR__ . '/includes/class-plugin.php';
    2725
    28     /** Singleton instance. */
    29     private static $instance = null;
    30 
    31     /**
    32      * Get or create the singleton instance.
    33      *
    34      * @return Plugin
    35      */
    36     public static function instance(): Plugin {
    37         if ( null === self::$instance ) {
    38             self::$instance = new self();
    39             self::$instance->init_hooks();
    40         }
    41         return self::$instance;
    42     }
    43 
    44     /** Prevent direct instantiation. */
    45     private function __construct() {}
    46 
    47     /**
    48      * Check if Germanized (free or Pro) is active.
    49      *
    50      * @return bool
    51      */
    52     private function is_storeabill_active(): bool {
    53         return function_exists( 'WC_GZD' );
    54     }
    55 
    56     /** Initialize WP and WooCommerce hooks. */
    57     private function init_hooks(): void {
    58         add_action( 'woocommerce_coupon_options', [ $this, 'add_apply_after_tax_checkbox' ] );
    59         add_action( 'woocommerce_coupon_options_save', [ $this, 'save_apply_after_tax_checkbox' ], 10, 2 );
    60         add_filter( 'woocommerce_coupon_get_discount_amount', [ $this, 'apply_coupon_after_tax' ], 20, 5 );
    61         add_filter( 'woocommerce_coupon_discount_amount_html', [ $this, 'adjust_coupon_display_amount' ], 20, 2 );
    62         add_filter( 'woocommerce_cart_totals_coupon_html', [ $this, 'adjust_cart_coupon_display' ], 20, 3 );
    63        
    64         // Store correct net amounts when creating order items
    65         add_action( 'woocommerce_checkout_create_order', [ $this, 'store_coupon_net_amounts' ], 10, 2 );
    66         add_action( 'woocommerce_create_order_coupon_item', [ $this, 'set_correct_coupon_amounts' ], 10, 4 );
    67        
    68         // Admin display
    69         add_filter( 'woocommerce_order_formatted_line_subtotal', [ $this, 'adjust_admin_coupon_display' ], 10, 3 );
    70        
    71         // Force correct calculation for invoices - use the correct hook
    72         add_filter( 'woocommerce_order_get_total_discount', [ $this, 'adjust_total_discount_for_invoices' ], 10, 2 );
    73        
    74         // StoreaBill (Germanized Pro) specific hooks - only if available
    75         if ( $this->is_storeabill_active() ) {
    76             add_filter( 'storeabill_invoice_get_discount_total', [ $this, 'storeabill_get_discount_total_gross' ], 10, 2 );
    77             add_filter( 'storeabill_woo_order_voucher_total', [ $this, 'storeabill_voucher_total_gross' ], 10, 2 );
    78            
    79             // Hook sniffer to find the correct Germanized hooks (only in debug mode)
    80             if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    81                 add_action( 'all', [ $this, 'log_germanized_hooks' ], 1 );
    82             }
    83         }
    84     }
    85 
    86     /** Add "Apply after tax" checkbox to the coupon admin screen. */
    87     public function add_apply_after_tax_checkbox(): void {
    88         \woocommerce_wp_checkbox( [
    89             'id'          => 'tpc_apply_after_tax',
    90             'label'       => __( 'Apply coupon after tax', 'taxproof-coupons-for-woocommerce' ),
    91             'description' => __( 'Deduct the fixed coupon amount from the order total including tax, ensuring the coupon value remains constant across all tax rates and locations.', 'taxproof-coupons-for-woocommerce' ),
    92         ] );
    93     }
    94 
    95     /**
    96      * Save the "Apply after tax" checkbox value.
    97      *
    98      * @param int       $post_id Coupon post ID.
    99      * @param \WC_Coupon $coupon Coupon object.
    100      */
    101     public function save_apply_after_tax_checkbox( int $post_id, \WC_Coupon $coupon ): void {
    102         // Verify nonce for security
    103         if ( ! isset( $_POST['woocommerce_meta_nonce'] ) ||
    104              ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['woocommerce_meta_nonce'] ) ), 'woocommerce_save_data' ) ) {
    105             return;
    106         }
    107        
    108         $apply_after_tax = isset( $_POST['tpc_apply_after_tax'] ) ? 'yes' : 'no';
    109         $coupon->update_meta_data( 'tpc_apply_after_tax', $apply_after_tax );
    110         $coupon->save();
    111     }
    112 
    113     /**
    114      * Apply fixed-cart coupons after tax: convert the gross amount into the correct net discount,
    115      * then hand that to WC so that tax is re-applied correctly.
    116      *
    117      * @param float      $discount           Current discount amount for this item.
    118      * @param float      $discounting_amount The original line total being discounted.
    119      * @param array      $cart_item          Cart item data.
    120      * @param bool       $single             Single item flag.
    121      * @param \WC_Coupon $coupon             Coupon object.
    122      * @return float     Modified discount.
    123      */
    124     public function apply_coupon_after_tax(
    125         float      $discount,
    126         float      $discounting_amount,
    127         array      $cart_item,
    128         bool       $single,
    129         \WC_Coupon $coupon
    130     ): float {
    131         // Only target fixed-cart coupons with our checkbox enabled.
    132         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    133              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    134             return $discount;
    135         }
    136 
    137         static $applied = [];
    138         $code = $coupon->get_code();
    139 
    140         // Only apply once per coupon code.
    141         if ( in_array( $code, $applied, true ) ) {
    142             return 0.0;
    143         }
    144 
    145         // Sum gross and net totals across all product line items.
    146         $total_gross = 0.0;
    147         $total_net   = 0.0;
    148         foreach ( WC()->cart->get_cart() as $item ) {
    149             $total_net   += wc_get_price_excluding_tax( $item['data'], [ 'qty' => $item['quantity'] ] );
    150             $total_gross += wc_get_price_including_tax(   $item['data'], [ 'qty' => $item['quantity'] ] );
    151         }
    152 
    153         if ( $total_net <= 0 || $total_gross <= 0 ) {
    154             return 0.0;
    155         }
    156 
    157         // The gross amount the admin entered (e.g. 150).
    158         $gross_coupon = floatval( $coupon->get_amount() );
    159 
    160         // Compute the average tax rate across items.
    161         $avg_tax_rate = ( $total_gross / $total_net ) - 1;
    162 
    163         // Convert the gross coupon into the correct net discount with precision handling
    164         $net_discount = $this->calculate_precise_net_discount(
    165             $gross_coupon,
    166             $avg_tax_rate,
    167             $total_net,
    168             $total_gross
    169         );
    170 
    171         // Mark as applied so we don't double-dip.
    172         $applied[] = $code;
    173 
    174         return $net_discount;
    175     }
    176 
    177     /**
    178      * Adjust the coupon display amount to show the gross value.
    179      * This ensures the customer always sees the advertised coupon amount.
    180      *
    181      * @param string $discount_amount_html The HTML string for the discount amount
    182      * @param \WC_Coupon $coupon The coupon object
    183      * @return string Modified HTML
    184      */
    185     public function adjust_coupon_display_amount( string $discount_amount_html, \WC_Coupon $coupon ): string {
    186         // Only adjust for our tax-proof coupons
    187         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    188              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    189             return $discount_amount_html;
    190         }
    191        
    192         // Get the original coupon amount (gross)
    193         $gross_amount = floatval( $coupon->get_amount() );
    194        
    195         // Format it as WooCommerce would
    196         $formatted_amount = wc_price( $gross_amount );
    197        
    198         error_log( sprintf( 'TPC Debug: Adjusting display from %s to %s', $discount_amount_html, $formatted_amount ) );
    199        
    200         return '-' . $formatted_amount;
    201     }
    202 
    203     /**
    204      * Adjust the cart display to show the gross coupon amount.
    205      * This filter specifically targets the cart and checkout displays.
    206      *
    207      * @param string $coupon_html Current HTML
    208      * @param \WC_Coupon $coupon Coupon object 
    209      * @param string $discount_amount_html Discount amount HTML
    210      * @return string Modified HTML
    211      */
    212     public function adjust_cart_coupon_display( string $coupon_html, \WC_Coupon $coupon, string $discount_amount_html ): string {
    213         // Only adjust for our tax-proof coupons
    214         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    215              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    216             return $coupon_html;
    217         }
    218        
    219         // Get the original coupon amount (gross)
    220         $gross_amount = floatval( $coupon->get_amount() );
    221        
    222         // Format it as WooCommerce would
    223         $formatted_amount = wc_price( $gross_amount );
    224        
    225         // Replace the discount amount in the HTML
    226         $new_html = str_replace( $discount_amount_html, '-' . $formatted_amount, $coupon_html );
    227        
    228         error_log( sprintf( 'TPC Debug: Cart display adjustment - Original: %s, New: %s', $coupon_html, $new_html ) );
    229        
    230         return $new_html;
    231     }
    232 
    233     /**
    234      * Calculate precise net discount that ensures the gross amount matches exactly.
    235      * This method handles rounding issues by finding the optimal net amount.
    236      *
    237      * @param float $gross_coupon  The gross coupon amount (e.g., 100.00)
    238      * @param float $avg_tax_rate  The average tax rate (e.g., 0.21 for 21%)
    239      * @param float $total_net     Total net amount in cart
    240      * @param float $total_gross   Total gross amount in cart
    241      * @return float The precise net discount amount
    242      */
    243     private function calculate_precise_net_discount(
    244         float $gross_coupon,
    245         float $avg_tax_rate,
    246         float $total_net,
    247         float $total_gross
    248     ): float {
    249         $decimals = wc_get_price_decimals();
    250         $precision_decimals = 4; // Use 4 decimals for internal precision
    251        
    252         // Debug logging
    253         error_log( 'TPC Debug: Starting precise calculation' );
    254         error_log( sprintf( 'TPC Debug: Gross coupon: %.4f', $gross_coupon ) );
    255         error_log( sprintf( 'TPC Debug: Avg tax rate: %.4f (%.2f%%)', $avg_tax_rate, $avg_tax_rate * 100 ) );
    256         error_log( sprintf( 'TPC Debug: Decimals: %d', $decimals ) );
    257        
    258         // Initial calculation with higher precision
    259         $net_discount_exact = $gross_coupon / ( 1 + $avg_tax_rate );
    260         error_log( sprintf( 'TPC Debug: Exact net discount (unrounded): %.6f', $net_discount_exact ) );
    261        
    262         // Try both floor and ceil to see which gives us the target gross
    263         $net_floor = floor( $net_discount_exact * pow( 10, $decimals ) ) / pow( 10, $decimals );
    264         $net_ceil = ceil( $net_discount_exact * pow( 10, $decimals ) ) / pow( 10, $decimals );
    265        
    266         error_log( sprintf( 'TPC Debug: Net floor: %.4f, Net ceil: %.4f', $net_floor, $net_ceil ) );
    267        
    268         // Calculate gross for both options
    269         // IMPORTANT: For Germany with 19% MwSt:
    270         // 84.03 * 1.19 = 99.9957 which rounds to 99.99 (NOT 100.00!)
    271         // 84.04 * 1.19 = 100.0076 which rounds to 100.01
    272         $gross_floor_exact = $net_floor * ( 1 + $avg_tax_rate );
    273         $gross_ceil_exact = $net_ceil * ( 1 + $avg_tax_rate );
    274         $gross_floor = round( $gross_floor_exact, $decimals );
    275         $gross_ceil = round( $gross_ceil_exact, $decimals );
    276        
    277         error_log( sprintf( 'TPC Debug: Floor exact: %.6f, Ceil exact: %.6f', $gross_floor_exact, $gross_ceil_exact ) );
    278        
    279         error_log( sprintf( 'TPC Debug: Gross from floor: %.4f, Gross from ceil: %.4f', $gross_floor, $gross_ceil ) );
    280        
    281         // Choose the net amount that gives us the exact gross we want
    282         // The problem: With only 2 decimals, we can't always achieve the exact gross amount
    283         // Solution: Return the EXACT net amount with more precision
    284        
    285         if ( $gross_floor == $gross_coupon ) {
    286             $net_discount = $net_floor;
    287             error_log( 'TPC Debug: Using floor value for exact match' );
    288         } elseif ( $gross_ceil == $gross_coupon ) {
    289             $net_discount = $net_ceil;
    290             error_log( 'TPC Debug: Using ceil value for exact match' );
    291         } else {
    292             // For 19% MwSt: 84.03 gives 99.9957 which rounds to 99.99, NOT 100.00
    293             // We need to use 84.04 which gives 100.0076 and rounds to 100.01
    294             // But we can accept 100.01 and show 100.00 in display
    295             error_log( sprintf( 'TPC Debug: No exact match - floor gives %.4f, ceil gives %.4f', $gross_floor, $gross_ceil ) );
    296            
    297             // Prefer the value that's slightly over rather than under
    298             if ( $gross_floor < $gross_coupon && $gross_ceil > $gross_coupon ) {
    299                 // If floor is under and ceil is over, use ceil
    300                 $net_discount = $net_ceil;
    301                 error_log( 'TPC Debug: Using ceil to avoid underpayment' );
    302             } else {
    303                 // If neither gives exact match, we need a more sophisticated approach
    304             // Calculate what net amount would give us exactly the gross we want
    305             // This might require adjusting by fractions of a cent
    306            
    307             error_log( 'TPC Debug: Neither floor nor ceil gives exact match, calculating optimal value' );
    308            
    309             // We'll use a binary search approach to find the optimal value
    310             $lower = $net_floor;
    311             $upper = $net_ceil;
    312             $best_net = $net_floor;
    313             $best_diff = abs( $gross_floor - $gross_coupon );
    314            
    315             // Try intermediate values
    316             for ( $i = 1; $i < 10; $i++ ) {
    317                 $test_net = $lower + ( $upper - $lower ) * ( $i / 10 );
    318                 $test_net = round( $test_net, $decimals + 2 ); // Use extra precision
    319                 $test_gross = round( $test_net * ( 1 + $avg_tax_rate ), $decimals );
    320                 $diff = abs( $test_gross - $gross_coupon );
    321                
    322                 if ( $diff < $best_diff ) {
    323                     $best_net = $test_net;
    324                     $best_diff = $diff;
    325                    
    326                     if ( $diff < 0.001 ) {
    327                         error_log( sprintf( 'TPC Debug: Found optimal value: %.4f', $best_net ) );
    328                         break;
    329                     }
    330                 }
    331             }
    332            
    333             // Round to allowed decimals
    334             $net_discount = round( $best_net, $decimals );
    335            
    336             // If this still doesn't give us the exact gross, adjust by one cent in the right direction
    337             $final_gross = round( $net_discount * ( 1 + $avg_tax_rate ), $decimals );
    338             if ( $final_gross < $gross_coupon ) {
    339                 $net_discount += pow( 10, -$decimals );
    340                 error_log( 'TPC Debug: Adjusted up by one cent for exact match' );
    341             } elseif ( $final_gross > $gross_coupon && $net_discount > pow( 10, -$decimals ) ) {
    342                 $net_discount -= pow( 10, -$decimals );
    343                 error_log( 'TPC Debug: Adjusted down by one cent for exact match' );
    344             }
    345             }
    346         }
    347        
    348         // Final step: Always return the EXACT mathematical value
    349         // Don't try to round to 2 decimals - return with full precision
    350         $exact_net = $gross_coupon / ( 1 + $avg_tax_rate );
    351        
    352         error_log( sprintf( 'TPC Debug: Final calculation - Gross %.4f / (1 + %.4f) = %.10f',
    353             $gross_coupon, $avg_tax_rate, $exact_net ) );
    354        
    355         // Return with high precision to ensure exact calculations
    356         return $exact_net;
    357     }
    358 
    359     /**
    360      * Set correct coupon amounts when creating the order item.
    361      * This runs BEFORE the item is saved, allowing us to set the correct net amount.
    362      *
    363      * @param \WC_Order_Item_Coupon $item The coupon item being created
    364      * @param string $code Coupon code
    365      * @param float $discount Discount amount
    366      * @param \WC_Order $order Order object
    367      */
    368     public function set_correct_coupon_amounts( \WC_Order_Item_Coupon $item, string $code, float $discount, \WC_Order $order ): void {
    369         error_log( sprintf( 'TPC Debug: set_correct_coupon_amounts called - Code: %s, Discount: %.4f', $code, $discount ) );
    370        
    371         $coupon = new \WC_Coupon( $code );
    372        
    373         if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    374              'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    375             error_log( 'TPC Debug: Not a tax-proof coupon, skipping' );
    376             return;
    377         }
    378        
    379         // Get the gross amount (what customer sees)
    380         $gross_amount = floatval( $coupon->get_amount() );
    381        
    382         // Calculate tax rate from cart totals
    383         $cart = WC()->cart;
    384         $total_tax = $cart->get_total_tax();
    385         $subtotal = $cart->get_subtotal();
    386         $total = $cart->get_total( 'edit' );
    387        
    388         // Calculate average tax rate
    389         $avg_tax_rate = $subtotal > 0 ? ( $total_tax / $subtotal ) : 0;
    390        
    391         // Calculate precise net amount
    392         $net_amount = $this->calculate_precise_net_discount(
    393             $gross_amount,
    394             $avg_tax_rate,
    395             $subtotal,
    396             $subtotal + $total_tax
    397         );
    398        
    399         // Calculate tax amount
    400         $tax_amount = $gross_amount - $net_amount;
    401        
    402         // Set the correct amounts on the item BEFORE it's saved
    403         // IMPORTANT: We store the EXACT net amount with more decimals for correct calculation
    404         // This ensures that net * tax_rate = exactly the gross amount we want
    405         $item->set_discount( $net_amount );
    406         $item->set_discount_tax( $tax_amount );
    407        
    408         // Store metadata for reference - with full precision
    409         $item->add_meta_data( '_tpc_gross_discount', $gross_amount );
    410         $item->add_meta_data( '_tpc_net_discount', number_format( $net_amount, 10, '.', '' ) ); // Store with 10 decimals
    411         $item->add_meta_data( '_tpc_tax_rate', $avg_tax_rate );
    412         $item->add_meta_data( '_tpc_applied_after_tax', 'yes' );
    413        
    414         error_log( sprintf(
    415             'TPC Debug: Setting coupon amounts - Code: %s, Gross: %.4f, Net: %.4f, Tax: %.4f',
    416             $code,
    417             $gross_amount,
    418             $net_amount,
    419             $tax_amount
    420         ) );
    421     }
    422 
    423     /**
    424      * Store precise net amounts after order is created.
    425      * This ensures Germanized Pro uses the correct values for invoice generation.
    426      *
    427      * @param \WC_Order $order Order object
    428      * @param array $data Checkout data
    429      */
    430     public function store_coupon_net_amounts( \WC_Order $order, array $data ): void {
    431         error_log( sprintf( 'TPC Debug: store_coupon_net_amounts called for order %s', $order->get_id() ) );
    432        
    433         foreach ( $order->get_coupons() as $item ) {
    434             $coupon = new \WC_Coupon( $item->get_code() );
    435            
    436             if ( 'fixed_cart' !== $coupon->get_discount_type() ||
    437                  'yes'       !== $coupon->get_meta( 'tpc_apply_after_tax', true ) ) {
    438                 continue;
    439             }
    440            
    441             error_log( sprintf( 'TPC Debug: Processing tax-proof coupon %s', $item->get_code() ) );
    442            
    443             // Get the gross amount
    444             $gross_amount = floatval( $coupon->get_amount() );
    445            
    446             // Calculate tax rate from order
    447             // IMPORTANT: We need the subtotal BEFORE discount to get the correct tax rate
    448             $order_subtotal_before_discount = 0;
    449             $order_tax = 0;
    450            
    451             // Calculate from line items (products) to get accurate rate
    452             foreach ( $order->get_items() as $line_item ) {
    453                 $order_subtotal_before_discount += $line_item->get_subtotal();
    454                 $order_tax += $line_item->get_subtotal_tax();
    455             }
    456            
    457             $avg_tax_rate = $order_subtotal_before_discount > 0 ? ( $order_tax / $order_subtotal_before_discount ) : 0;
    458            
    459             error_log( sprintf(
    460                 'TPC Debug: Order data - Subtotal (before discount): %.4f, Tax: %.4f, Calc Tax Rate: %.6f (%.2f%%)',
    461                 $order_subtotal_before_discount,
    462                 $order_tax,
    463                 $avg_tax_rate,
    464                 $avg_tax_rate * 100
    465             ) );
    466            
    467             // Calculate exact net amount
    468             $net_amount_exact = $gross_amount / ( 1 + $avg_tax_rate );
    469             $tax_amount_exact = $gross_amount - $net_amount_exact;
    470            
    471             error_log( sprintf(
    472                 'TPC Debug: Calculation - Gross: %.4f / (1 + %.6f) = Net: %.10f, Tax: %.10f',
    473                 $gross_amount,
    474                 $avg_tax_rate,
    475                 $net_amount_exact,
    476                 $tax_amount_exact
    477             ) );
    478            
    479             // Update the item with precise values
    480             $item->set_discount( $net_amount_exact );
    481             $item->set_discount_tax( $tax_amount_exact );
    482            
    483             // Store metadata
    484             $item->update_meta_data( '_tpc_gross_discount', $gross_amount );
    485             $item->update_meta_data( '_tpc_net_discount', $net_amount_exact );
    486             $item->update_meta_data( '_tpc_tax_rate', $avg_tax_rate );
    487             $item->update_meta_data( '_tpc_applied_after_tax', 'yes' );
    488             $item->save();
    489            
    490             error_log( sprintf(
    491                 'TPC Debug: Updated coupon %s - Gross: %.4f, Net: %.10f, Tax: %.10f',
    492                 $item->get_code(),
    493                 $gross_amount,
    494                 $net_amount_exact,
    495                 $tax_amount_exact
    496             ) );
    497         }
    498        
    499         // Save the order to persist all changes
    500         $order->save();
    501     }
    502 
    503 
    504     /**
    505      * Adjust coupon display in admin order view.
    506      *
    507      * @param string $subtotal Formatted subtotal
    508      * @param \WC_Order_Item $item Order item
    509      * @param \WC_Order $order Order object
    510      * @return string Modified subtotal
    511      */
    512     public function adjust_admin_coupon_display( string $subtotal, \WC_Order_Item $item, \WC_Order $order ): string {
    513         // Only process coupon items
    514         if ( ! $item->is_type( 'coupon' ) ) {
    515             return $subtotal;
    516         }
    517        
    518         $net_discount = $item->get_meta( '_tpc_net_discount' );
    519         $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    520        
    521         error_log( sprintf( 'TPC Debug: Meta data - Net: %s, Gross: %s',
    522             $net_discount ? $net_discount : 'NOT FOUND',
    523             $gross_discount ? $gross_discount : 'NOT FOUND'
    524         ) );
    525        
    526         if ( $net_discount && $gross_discount ) {
    527             // Show detailed information in admin with 4 decimal places
    528             $net_value = floatval( $net_discount );
    529             $net_display = number_format( $net_value, 4, ',', '' );
    530             $subtotal = sprintf(
    531                 '-<span class="woocommerce-Price-amount amount">%s&nbsp;<span class="woocommerce-Price-currencySymbol">€</span></span> <small class="woocommerce-help-tip" data-tip="Brutto: %s">(Brutto: %s)</small>',
    532                 $net_display,
    533                 wc_price( $gross_discount ),
    534                 wc_price( $gross_discount )
    535             );
    536            
    537             error_log( sprintf(
    538                 'TPC Debug: Admin display adjusted - Net: %.4f, Gross: %.4f',
    539                 $net_discount,
    540                 $gross_discount
    541             ) );
    542         }
    543        
    544         return $subtotal;
    545     }
    546 
    547 
    548     /**
    549      * Adjust total discount to ensure it matches the gross coupon amount.
    550      * This is critical for invoice generation.
    551      *
    552      * @param float $total_discount Current total discount
    553      * @param \WC_Order $order Order object
    554      * @return float Adjusted discount
    555      */
    556     public function adjust_total_discount_for_invoices( float $total_discount, \WC_Order $order ): float {
    557         $adjusted_total = 0;
    558        
    559         foreach ( $order->get_coupons() as $item ) {
    560             if ( $item->get_meta( '_tpc_applied_after_tax' ) === 'yes' ) {
    561                 // Use the gross discount amount for tax-proof coupons
    562                 $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    563                 if ( $gross_discount ) {
    564                     $adjusted_total += floatval( $gross_discount );
    565                     error_log( sprintf(
    566                         'TPC Debug: Adjusting invoice discount for %s - Using gross: %.4f instead of net+tax',
    567                         $item->get_code(),
    568                         $gross_discount
    569                     ) );
    570                 } else {
    571                     $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    572                 }
    573             } else {
    574                 $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    575             }
    576         }
    577        
    578         if ( $adjusted_total != $total_discount ) {
    579             error_log( sprintf(
    580                 'TPC Debug: Adjusted total discount from %.4f to %.4f',
    581                 $total_discount,
    582                 $adjusted_total
    583             ) );
    584         }
    585        
    586         return $adjusted_total;
    587     }
    588 
    589     /**
    590      * Adjust StoreaBill invoice discount total to use gross amounts.
    591      * This filter is called when StoreaBill calculates the total discount for the invoice.
    592      *
    593      * @param float $total Current discount total
    594      * @param object $invoice The invoice document
    595      * @return float Adjusted total
    596      */
    597     public function storeabill_get_discount_total_gross( float $total, $invoice ): float {
    598         // Get the WooCommerce order
    599         if ( ! method_exists( $invoice, 'get_order' ) ) {
    600             return $total;
    601         }
    602        
    603         $order = $invoice->get_order();
    604         if ( ! $order ) {
    605             return $total;
    606         }
    607        
    608         $adjusted_total = 0;
    609         $has_tax_proof_coupons = false;
    610        
    611         foreach ( $order->get_coupons() as $item ) {
    612             $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    613             if ( $gross_discount ) {
    614                 $adjusted_total += floatval( $gross_discount );
    615                 $has_tax_proof_coupons = true;
    616                 error_log( sprintf(
    617                     'TPC Debug: StoreaBill discount total - Using gross: %.4f for %s',
    618                     $gross_discount,
    619                     $item->get_code()
    620                 ) );
    621             } else {
    622                 $adjusted_total += $item->get_discount() + $item->get_discount_tax();
    623             }
    624         }
    625        
    626         if ( $has_tax_proof_coupons && $adjusted_total != $total ) {
    627             error_log( sprintf(
    628                 'TPC Debug: StoreaBill total discount changed from %.4f to %.4f',
    629                 $total,
    630                 $adjusted_total
    631             ) );
    632             return $adjusted_total;
    633         }
    634        
    635         return $total;
    636     }
    637 
    638     /**
    639      * Adjust StoreaBill voucher total to use gross amounts.
    640      *
    641      * @param float $total Current voucher total
    642      * @param object $order_data_store The WooCommerce order data store
    643      * @return float Adjusted total
    644      */
    645     public function storeabill_voucher_total_gross( float $total, $order_data_store ): float {
    646         error_log( sprintf( 'TPC Debug: storeabill_voucher_total_gross called - Total: %.4f', $total ) );
    647        
    648         if ( ! method_exists( $order_data_store, 'get_order' ) ) {
    649             error_log( 'TPC Debug: No get_order method' );
    650             return $total;
    651         }
    652        
    653         $order = $order_data_store->get_order();
    654         if ( ! $order ) {
    655             error_log( 'TPC Debug: No order found' );
    656             return $total;
    657         }
    658        
    659         $adjusted_total = 0;
    660        
    661         foreach ( $order->get_coupons() as $item ) {
    662             $gross_discount = $item->get_meta( '_tpc_gross_discount' );
    663             if ( $gross_discount ) {
    664                 $adjusted_total += floatval( $gross_discount );
    665                 error_log( sprintf(
    666                     'TPC Debug: StoreaBill voucher total - Using gross: %.4f for %s',
    667                     $gross_discount,
    668                     $item->get_code()
    669                 ) );
    670             }
    671         }
    672        
    673         if ( $adjusted_total > 0 ) {
    674             error_log( sprintf(
    675                 'TPC Debug: StoreaBill voucher total changed from %.4f to %.4f',
    676                 $total,
    677                 $adjusted_total
    678             ) );
    679             return $adjusted_total;
    680         }
    681        
    682         return $total;
    683     }
    684 
    685     /**
    686      * Log all Germanized/StoreaBill hooks to find the right one.
    687      * This is a temporary debugging method.
    688      *
    689      * @param string $hook Hook name
    690      */
    691     public function log_germanized_hooks( string $hook ): void {
    692         // Only log during invoice generation (check if we're in admin)
    693         if ( ! is_admin() ) {
    694             return;
    695         }
    696        
    697         // Only log hooks that might be related to invoice/discount
    698         if ( strpos( $hook, 'gzd' ) !== false ||
    699              strpos( $hook, 'germanized' ) !== false ||
    700              strpos( $hook, 'storeabill' ) !== false ||
    701              strpos( $hook, 'invoice' ) !== false ) {
    702            
    703             // Only log filter hooks (not actions) to reduce noise
    704             if ( strpos( $hook, 'discount' ) !== false ||
    705                  strpos( $hook, 'total' ) !== false ||
    706                  strpos( $hook, 'coupon' ) !== false ) {
    707                 error_log( sprintf( 'TPC Debug: Hook fired: %s', $hook ) );
    708             }
    709         }
    710     }
    711 
    712     /** Public wakeup to satisfy PHP's magic method requirement. */
    713     public function __wakeup() {}
    714 }
    715 
    716 // Bootstrap the plugin.
    717 add_action( 'plugins_loaded', [ Plugin::class, 'instance' ] );
     26add_action( 'plugins_loaded', array( Plugin::class, 'instance' ) );
Note: See TracChangeset for help on using the changeset viewer.