Plugin Directory

Changeset 3752964


Ignore:
Timestamp:
10/18/2026 11:44:47 AM (4 weeks ago)
Author:
demfilz
Message:

Release 1.2.1: gift card and store credit support, order fees synced, 429 retry, bulk stock map

Location:
demfilz-sync-for-quickbooks/trunk
Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • demfilz-sync-for-quickbooks/trunk/demfilz-sync-for-quickbooks.php

    r3606810 r3752964  
    44 * Plugin URI:        https://demfilz.com/demfilz-woo-to-quickbooks
    55 * Description:       Send WooCommerce orders, customers and stock to QuickBooks Online via the official Intuit API. Free manual sync; Pro adds automation.
    6  * Version:           1.2.0
     6 * Version:           1.2.1
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.2
    … …  
    1919defined( 'ABSPATH' ) || exit;
    2020
    21 define( 'DWTQ_VERSION', '1.2.0' );
     21define( 'DWTQ_VERSION', '1.2.1' );
    2222define( 'DWTQ_FILE', __FILE__ );
    2323define( 'DWTQ_DIR', plugin_dir_path( __FILE__ ) );
  • demfilz-sync-for-quickbooks/trunk/includes/admin/class-dwtq-admin.php

    r3606810 r3752964  
    209209                Dwtq_Settings::update(
    210210                        array(
    211                                 'default_item'     => isset( $_POST['default_item'] ) ? sanitize_text_field( wp_unslash( $_POST['default_item'] ) ) : '',
    212                                 'default_tax_code' => isset( $_POST['default_tax_code'] ) ? sanitize_text_field( wp_unslash( $_POST['default_tax_code'] ) ) : '',
    213                                 'freight_tax_code' => isset( $_POST['freight_tax_code'] ) ? sanitize_text_field( wp_unslash( $_POST['freight_tax_code'] ) ) : '',
    214                                 'sale_type'        => isset( $_POST['sale_type'] ) ? sanitize_text_field( wp_unslash( $_POST['sale_type'] ) ) : 'Invoice',
     211                                'default_item'       => isset( $_POST['default_item'] ) ? sanitize_text_field( wp_unslash( $_POST['default_item'] ) ) : '',
     212                                'default_tax_code'   => isset( $_POST['default_tax_code'] ) ? sanitize_text_field( wp_unslash( $_POST['default_tax_code'] ) ) : '',
     213                                'freight_tax_code'   => isset( $_POST['freight_tax_code'] ) ? sanitize_text_field( wp_unslash( $_POST['freight_tax_code'] ) ) : '',
     214                                'gift_card_item'     => isset( $_POST['gift_card_item'] ) ? sanitize_text_field( wp_unslash( $_POST['gift_card_item'] ) ) : '',
     215                                'gift_card_tax_code' => isset( $_POST['gift_card_tax_code'] ) ? sanitize_text_field( wp_unslash( $_POST['gift_card_tax_code'] ) ) : '',
     216                                'sale_type'          => isset( $_POST['sale_type'] ) ? sanitize_text_field( wp_unslash( $_POST['sale_type'] ) ) : 'Invoice',
    215217                        )
    216218                );
  • demfilz-sync-for-quickbooks/trunk/includes/admin/views/mapping.php

    r3606810 r3752964  
    2323$dwtq_tax       = Dwtq_Settings::get( 'default_tax_code' );
    2424$dwtq_freight   = Dwtq_Settings::get( 'freight_tax_code' );
     25$dwtq_gift_item = Dwtq_Settings::get( 'gift_card_item' );
     26$dwtq_gift_tax  = Dwtq_Settings::get( 'gift_card_tax_code' );
    2527
    2628if ( ! function_exists( 'dwtq_render_select' ) ) :
    … …  
    7375                        <td><?php dwtq_render_select( 'freight_tax_code', $dwtq_taxes, $dwtq_freight ); ?></td>
    7476                </tr>
     77                <tr>
     78                        <th><?php esc_html_e( 'Gift card item', 'demfilz-sync-for-quickbooks' ); ?></th>
     79                        <td>
     80                                <?php dwtq_render_select( 'gift_card_item', $dwtq_items, $dwtq_gift_item ); ?>
     81                                <p class="description"><?php esc_html_e( 'Only needed if you sell gift cards or accept gift cards or store credit (Demfilz Gift Cards and Store Credit). In QuickBooks, create a Non-inventory or Service item named "Gift card" whose income account is a liability account, for example "Gift card liability". Gift cards sold post to it instead of income, and amounts paid by gift card or store credit post as a negative line for it, so your revenue and tax stay complete and the sale total matches what the customer was charged.', 'demfilz-sync-for-quickbooks' ); ?></p>
     82                        </td>
     83                </tr>
     84                <tr>
     85                        <th><?php esc_html_e( 'Gift card tax code', 'demfilz-sync-for-quickbooks' ); ?></th>
     86                        <td>
     87                                <?php dwtq_render_select( 'gift_card_tax_code', $dwtq_taxes, $dwtq_gift_tax ); ?>
     88                                <p class="description"><?php esc_html_e( 'Choose a code with no tax, such as Out of scope or Not reportable. Leave blank to use the item default.', 'demfilz-sync-for-quickbooks' ); ?></p>
     89                        </td>
     90                </tr>
    7591        </table>
    7692        <p class="description"><?php esc_html_e( 'Free tier maps all lines to one tax code. Pro adds per-product / per-tax-class rules. Leave tax codes blank if your QuickBooks region does not use tax codes on sales.', 'demfilz-sync-for-quickbooks' ); ?></p>
  • demfilz-sync-for-quickbooks/trunk/includes/class-dwtq-installer.php

    r3606810 r3752964  
    8080        public static function default_settings() {
    8181                return array(
    82                         'environment'      => 'production', // Intuit environment: production or sandbox.
    83                         'sale_type'        => 'Invoice',    // Sale document type: Invoice, SalesReceipt or Estimate.
    84                         'default_item'     => '',           // QuickBooks item id used for line items.
    85                         'default_tax_code' => '',           // QuickBooks tax code id.
    86                         'freight_tax_code' => '',           // QuickBooks tax code id for shipping.
    87                         'auto_sync_status' => array( 'processing', 'completed' ), // Statuses that trigger auto sync in Pro.
     82                        'environment'        => 'production', // Intuit environment: production or sandbox.
     83                        'sale_type'          => 'Invoice',    // Sale document type: Invoice, SalesReceipt or Estimate.
     84                        'default_item'       => '',           // QuickBooks item id used for line items.
     85                        'default_tax_code'   => '',           // QuickBooks tax code id.
     86                        'freight_tax_code'   => '',           // QuickBooks tax code id for shipping.
     87                        'gift_card_item'     => '',           // QuickBooks item id (its account is a liability) for gift cards sold and redeemed.
     88                        'gift_card_tax_code' => '',           // QuickBooks tax code id for gift card lines (no tax).
     89                        'auto_sync_status'   => array( 'processing', 'completed' ), // Statuses that trigger auto sync in Pro.
    8890                );
    8991        }
  • demfilz-sync-for-quickbooks/trunk/includes/providers/class-dwtq-abstract-provider.php

    r3606810 r3752964  
    113113                }
    114114
    115                 $response = wp_remote_request( $url, $args );
    116                 if ( is_wp_error( $response ) ) {
    117                         return $response;
    118                 }
     115                // QuickBooks throttles to 500 requests/minute per company and replies 429 with
     116                // a Retry-After header. Wait and retry a couple of times so a brief throttle does
     117                // not fail the sync. Waits are bounded, and larger in background (cron) context
     118                // than interactive so a manual click never hangs for long.
     119                $max_wait = function_exists( 'wp_doing_cron' ) && wp_doing_cron() ? 60 : 10;
     120                $attempts = 0;
     121                do {
     122                        $response = wp_remote_request( $url, $args );
     123                        if ( is_wp_error( $response ) ) {
     124                                return $response;
     125                        }
     126                        $status = (int) wp_remote_retrieve_response_code( $response );
     127                        if ( 429 !== $status || $attempts >= 2 ) {
     128                                break;
     129                        }
     130                        $wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
     131                        if ( $wait <= 0 ) {
     132                                $wait = 5;
     133                        }
     134                        if ( $wait > $max_wait ) {
     135                                break;
     136                        }
     137                        ++$attempts;
     138                        sleep( $wait ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration, WordPressVIPMinimum.Performance.LowExpiryCacheTime.sleep -- Deliberate rate-limit backoff, bounded above.
     139                } while ( true );
    119140
    120141                $status = (int) wp_remote_retrieve_response_code( $response );
    121142                $raw    = wp_remote_retrieve_body( $response );
    122143                $data   = '' !== $raw ? json_decode( $raw, true ) : array();
     144
     145                if ( 429 === $status ) {
     146                        $retry = (int) wp_remote_retrieve_header( $response, 'retry-after' );
     147                        $msg   = $retry > 0
     148                                /* translators: %d: number of seconds to wait. */
     149                                ? sprintf( __( 'QuickBooks is rate-limiting requests (429). Please wait %d seconds and try again.', 'demfilz-sync-for-quickbooks' ), $retry )
     150                                : __( 'QuickBooks is rate-limiting requests (429). Please wait a minute and try again.', 'demfilz-sync-for-quickbooks' );
     151                        return new WP_Error(
     152                                'dwtq_api_rate_limit',
     153                                $msg,
     154                                array(
     155                                        'status'      => 429,
     156                                        'retry_after' => $retry,
     157                                )
     158                        );
     159                }
    123160
    124161                if ( $status < 200 || $status >= 300 ) {
  • demfilz-sync-for-quickbooks/trunk/includes/providers/class-dwtq-provider-quickbooks.php

    r3606810 r3752964  
    139139         */
    140140        protected function sale_lines( $order ) {
    141                 $lines    = array();
    142                 $tax_map  = new Dwtq_Tax_Mapper();
    143                 $item_ref = Dwtq_Settings::get( 'default_item' );
     141                $lines     = array();
     142                $tax_map   = new Dwtq_Tax_Mapper();
     143                $item_ref  = Dwtq_Settings::get( 'default_item' );
     144                $gift_item = Dwtq_Settings::get( 'gift_card_item' );
     145                $gift_tax  = Dwtq_Settings::get( 'gift_card_tax_code' );
    144146
    145147                foreach ( $order->get_items() as $item ) {
    146                         $detail = array( 'ItemRef' => array( 'value' => $item_ref ) );
    147                         $tax    = $tax_map->code_uid_for_item( $order, $item );
     148                        // A gift card being sold is money held for the customer: it goes on the gift card
     149                        // item, whose account is a liability, not on the income item.
     150                        $gift_sale = Dwtq_Gift_Cards::is_gift_card_sale( $item );
     151                        $detail    = array( 'ItemRef' => array( 'value' => $gift_sale ? $gift_item : $item_ref ) );
     152                        $tax       = $gift_sale ? $gift_tax : $tax_map->code_uid_for_item( $order, $item );
    148153                        if ( $tax ) {
    149154                                $detail['TaxCodeRef'] = array( 'value' => $tax );
    150155                        }
     156                        $total   = round( (float) $order->get_line_total( $item, false ), 2 );
     157                        $face    = $gift_sale ? Dwtq_Gift_Cards::face_value( $item, $total ) : $total;
    151158                        $lines[] = array(
    152159                                'DetailType'          => 'SalesItemLineDetail',
    153                                 'Amount'              => round( (float) $order->get_line_total( $item, false ), 2 ),
     160                                'Amount'              => $face,
    154161                                'Description'         => $item->get_name(),
    155162                                'SalesItemLineDetail' => $detail,
    156163                        );
     164
     165                        // A card sold below its value (on sale or with a coupon): the difference is a
     166                        // discount, on the income item.
     167                        if ( $face > $total ) {
     168                                $amount = -1 * round( $face - $total, 2 );
     169                                $detail = array(
     170                                        'ItemRef'   => array( 'value' => $item_ref ),
     171                                        'Qty'       => 1,
     172                                        'UnitPrice' => $amount,
     173                                );
     174                                if ( $gift_tax ) {
     175                                        $detail['TaxCodeRef'] = array( 'value' => $gift_tax );
     176                                }
     177                                $lines[] = array(
     178                                        'DetailType'          => 'SalesItemLineDetail',
     179                                        'Amount'              => $amount,
     180                                        /* translators: %s: product name. */
     181                                        'Description'         => sprintf( __( 'Discount on %s', 'demfilz-sync-for-quickbooks' ), $item->get_name() ),
     182                                        'SalesItemLineDetail' => $detail,
     183                                );
     184                        }
    157185                }
    158186
    … …  
    171199                }
    172200
     201                // Fees / surcharges (e.g. a payment surcharge added as a WooCommerce fee). A negative
     202                // fee (a discount-style fee) posts as a negative line.
     203                $fee_tax = Dwtq_Settings::get( 'default_tax_code' );
     204                foreach ( $order->get_fees() as $fee ) {
     205                        $amount = round( (float) $fee->get_total(), 2 );
     206                        if ( 0.0 === $amount ) {
     207                                continue;
     208                        }
     209                        $detail = array(
     210                                'ItemRef'   => array( 'value' => $item_ref ),
     211                                'Qty'       => 1,
     212                                'UnitPrice' => $amount,
     213                        );
     214                        if ( $fee_tax ) {
     215                                $detail['TaxCodeRef'] = array( 'value' => $fee_tax );
     216                        }
     217                        $lines[] = array(
     218                                'DetailType'          => 'SalesItemLineDetail',
     219                                'Amount'              => $amount,
     220                                'Description'         => $fee->get_name() ? $fee->get_name() : __( 'Fee', 'demfilz-sync-for-quickbooks' ),
     221                                'SalesItemLineDetail' => $detail,
     222                        );
     223                }
     224
     225                // Amounts paid by gift card or store credit: a negative line on the gift card item, so
     226                // the payment comes out of the gift card liability, the sale keeps its full revenue and
     227                // tax, and the sale total matches the order total.
     228                foreach ( Dwtq_Gift_Cards::payments( $order ) as $payment ) {
     229                        $amount = -1 * round( (float) $payment['amount'], 2 );
     230                        $detail = array(
     231                                'ItemRef'   => array( 'value' => $gift_item ),
     232                                'Qty'       => 1,
     233                                'UnitPrice' => $amount,
     234                        );
     235                        if ( $gift_tax ) {
     236                                $detail['TaxCodeRef'] = array( 'value' => $gift_tax );
     237                        }
     238                        $lines[] = array(
     239                                'DetailType'          => 'SalesItemLineDetail',
     240                                'Amount'              => $amount,
     241                                'Description'         => $payment['label'],
     242                                'SalesItemLineDetail' => $detail,
     243                        );
     244                }
     245
    173246                return $lines;
    174247        }
    … …  
    185258                if ( '' === (string) Dwtq_Settings::get( 'default_item' ) ) {
    186259                        return new WP_Error( 'dwtq_no_item', __( 'Choose a default QuickBooks product/service on the Mapping tab before syncing.', 'demfilz-sync-for-quickbooks' ) );
     260                }
     261                if ( '' === (string) Dwtq_Settings::get( 'gift_card_item' ) && Dwtq_Gift_Cards::order_uses_gift_cards( $order ) ) {
     262                        return new WP_Error( 'dwtq_no_gift_card_item', __( 'This order sells a gift card or was paid partly by gift card or store credit. Choose a gift card item (one whose account is a liability account) on the Mapping tab, then sync the order again.', 'demfilz-sync-for-quickbooks' ) );
    187263                }
    188264
    … …  
    281357
    282358        /**
     359         * Fetch every stock-tracked QuickBooks item in a few paginated calls and return
     360         * a SKU => quantity-on-hand map. Lets a bulk stock sync match locally instead of
     361         * one API query per product (which blows QuickBooks' per-minute throttle).
     362         *
     363         * @return array|WP_Error Map of SKU (string) => QtyOnHand (float).
     364         */
     365        public function get_stock_map() {
     366                $map   = array();
     367                $start = 1;
     368                $page  = 1000;
     369                // Cap pages so a runaway/huge catalogue cannot loop forever.
     370                for ( $i = 0; $i < 50; $i++ ) {
     371                        $data = $this->query( "SELECT * FROM Item WHERE Type = 'Inventory' STARTPOSITION " . $start . ' MAXRESULTS ' . $page );
     372                        if ( is_wp_error( $data ) ) {
     373                                return $data;
     374                        }
     375                        $items = isset( $data['Item'] ) && is_array( $data['Item'] ) ? $data['Item'] : array();
     376                        foreach ( $items as $item ) {
     377                                if ( empty( $item['Sku'] ) || ! isset( $item['QtyOnHand'] ) ) {
     378                                        continue;
     379                                }
     380                                $map[ (string) $item['Sku'] ] = (float) $item['QtyOnHand'];
     381                        }
     382                        if ( count( $items ) < $page ) {
     383                                break;
     384                        }
     385                        $start += $page;
     386                }
     387                return $map;
     388        }
     389
     390        /**
    283391         * List tax codes (id => name).
    284392         *
  • demfilz-sync-for-quickbooks/trunk/includes/sync/class-dwtq-inventory-sync.php

    r3606810 r3752964  
    6666                return $qty;
    6767        }
     68
     69        /**
     70         * Bulk-pull stock for many products from ONE QuickBooks item map (no per-product
     71         * API call). Fetches the SKU => quantity map once (unless passed in) and matches
     72         * each product locally. Used by the Pro scheduled sweep and the "Sync all stock"
     73         * button to stay under QuickBooks' per-minute throttle.
     74         *
     75         * @param int[]|null $product_ids Product ids to update (null = all published).
     76         * @param array|null $map         Pre-fetched SKU => qty map (null = fetch now).
     77         * @return array|WP_Error { updated, skipped, missing } on success.
     78         */
     79        public function pull_bulk( $product_ids = null, $map = null ) {
     80                if ( null === $map ) {
     81                        $provider = Dwtq_Provider_Factory::make();
     82                        if ( is_wp_error( $provider ) ) {
     83                                return $provider;
     84                        }
     85                        $map = $provider->get_stock_map();
     86                        if ( is_wp_error( $map ) ) {
     87                                return $map;
     88                        }
     89                }
     90
     91                if ( null === $product_ids ) {
     92                        $product_ids = wc_get_products(
     93                                array(
     94                                        'status' => 'publish',
     95                                        'limit'  => -1,
     96                                        'return' => 'ids',
     97                                )
     98                        );
     99                }
     100
     101                $updated = 0;
     102                $skipped = 0;
     103                $missing = 0;
     104                foreach ( (array) $product_ids as $pid ) {
     105                        $product = wc_get_product( $pid );
     106                        if ( ! $product ) {
     107                                continue;
     108                        }
     109                        $sku = $product->get_sku();
     110                        if ( '' === $sku ) {
     111                                ++$skipped;
     112                                continue;
     113                        }
     114                        if ( ! array_key_exists( $sku, $map ) ) {
     115                                ++$missing;
     116                                continue;
     117                        }
     118                        $product->set_manage_stock( true );
     119                        $product->set_stock_quantity( $map[ $sku ] );
     120                        $product->save();
     121                        ++$updated;
     122                }
     123
     124                return array(
     125                        'updated' => $updated,
     126                        'skipped' => $skipped,
     127                        'missing' => $missing,
     128                );
     129        }
    68130}
  • demfilz-sync-for-quickbooks/trunk/readme.txt

    r3644269 r3752964  
    66Requires PHP: 7.2
    77Requires Plugins: woocommerce
    8 Stable tag: 1.2.0
     8Stable tag: 1.2.1
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    7474No. The free version handles connecting, pushing an order, customer sync and stock pull. You only need a QuickBooks Online company to connect to (a free trial or the Intuit sandbox company works). Pro is an optional separate add-on that unlocks automation.
    7575
     76= Does it handle gift cards and store credit? =
     77Yes, with Demfilz Gift Cards and Store Credit for WooCommerce. A gift card is a payment, not a discount. In QuickBooks, create a Non-inventory or Service item called "Gift card" whose income account is a liability account (for example "Gift card liability"), then choose it on the Mapping tab with a tax code that has no tax. Gift cards sold post to that item instead of income. When an order is paid partly by gift card or store credit, the sale keeps every product and shipping line with its tax, and the amount paid by card is a negative line for the gift card item, so the sale total matches what the customer paid at checkout. Until the item is chosen, orders that use gift cards are not synced and the log says why.
     78
    7679== Screenshots ==
    7780
    … …  
    8285
    8386== Changelog ==
     87
     88= 1.2.1 =
     89* New: gift card and store credit support for Demfilz Gift Cards and Store Credit for WooCommerce. Gift cards sold post to a gift card item whose account is a liability, and amounts paid by gift card or store credit post as a negative line for that item, so revenue and tax stay complete and the sale total matches the order total. Choose the item on the Mapping tab. Developers can report another gift card plugin's payments with the dwtq_gift_card_payments filter.
     90* A gift card sold below its value (on sale or with a coupon) posts its full value to the gift card account and the difference as a discount on income, so the account always holds what customers can spend.
     91* Fix: order fees (for example a payment surcharge) are now synced as lines on the default item with the default tax code, so an order with a fee no longer syncs below its order total. Negative (discount-style) fees post as negative lines.
     92* QuickBooks rate limits (429): a throttled request now waits for the time QuickBooks asks for and tries again, up to twice (up to 60 seconds in background jobs, 10 seconds when you click a button), then shows a clear "wait and try again" message instead of a raw API error.
     93* Stock can now be read for the whole catalogue from one paginated QuickBooks item list instead of one request per product (used by the Pro scheduled stock sync), which keeps large catalogues under the QuickBooks request limit.
    8494
    8595= 1.2.0 =
    … …  
    110120== Upgrade Notice ==
    111121
     122= 1.2.1 =
     123Adds gift card and store credit support. If you use Demfilz Gift Cards and Store Credit, choose a gift card item on the Mapping tab.
     124
    112125= 1.1.0 =
    113126Adds supplier-bill sync (with Demfilz Auto Order) and connects Pro to live billing.
Note: See TracChangeset for help on using the changeset viewer.