is_connected(); include __DIR__ . '/views/dashboard.php'; } public static function render_settings(): void { $settings = QBWS_Config::all(); $authorizeUrl = QBWS_Config::get('client_id') ? QBWS_REST_API::build_authorize_url() : null; $connected = (new QBWS_Token_Manager())->is_connected(); include __DIR__ . '/views/settings.php'; } public static function render_conflicts(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- plugin's own table; name is $wpdb->prefix + constant. $conflicts = $wpdb->get_results( $wpdb->prepare("SELECT * FROM {$wpdb->prefix}qbws_order_map WHERE sync_status = %s ORDER BY updated_at DESC LIMIT %d", 'conflict', 100), ARRAY_A ); include __DIR__ . '/views/conflicts.php'; } public static function render_logs(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- plugin's own table; name is $wpdb->prefix + constant. $logs = $wpdb->get_results( $wpdb->prepare("SELECT * FROM {$wpdb->prefix}qbws_sync_log ORDER BY id DESC LIMIT %d", 200), ARRAY_A ); include __DIR__ . '/views/logs.php'; } // ═══════════════════════ ACTIONS ═══════════════════════════════════ public static function save_settings(): void { if (!current_user_can('manage_woocommerce') || !check_admin_referer('qbws_save_settings')) { wp_die(esc_html__('Unauthorized', 'ledgerbridge-sync')); } $environment = isset($_POST['environment']) ? sanitize_key(wp_unslash($_POST['environment'])) : 'production'; $environment = in_array($environment, ['production', 'sandbox'], true) ? $environment : 'production'; $values = [ 'client_id' => sanitize_text_field(wp_unslash($_POST['client_id'] ?? '')), 'realm_id' => sanitize_text_field(wp_unslash($_POST['realm_id'] ?? '')), 'environment' => $environment, 'order_sync_direction' => self::sanitize_direction(sanitize_key(wp_unslash($_POST['order_sync_direction'] ?? 'two_way'))), 'product_sync_direction' => self::sanitize_direction(sanitize_key(wp_unslash($_POST['product_sync_direction'] ?? 'two_way'))), 'customer_sync_direction' => self::sanitize_direction(sanitize_key(wp_unslash($_POST['customer_sync_direction'] ?? 'two_way'))), 'order_doc_type' => sanitize_text_field(wp_unslash($_POST['order_doc_type'] ?? 'Invoice')), 'conflict_strategy' => sanitize_text_field(wp_unslash($_POST['conflict_strategy'] ?? 'last_modified_wins')), 'default_currency' => sanitize_text_field(wp_unslash($_POST['default_currency'] ?? 'USD')), 'cache_ttl_seconds' => absint(wp_unslash($_POST['cache_ttl_seconds'] ?? 1800)), 'delete_data_on_uninstall' => !empty($_POST['delete_data_on_uninstall']), 'sync_matrix' => self::sanitize_sync_matrix(), ]; QBWS_Config::update($values); if (!empty($_POST['client_secret'])) { QBWS_Config::set_secret('client_secret', sanitize_text_field(wp_unslash($_POST['client_secret']))); } if (!empty($_POST['webhook_verifier'])) { QBWS_Config::set_secret('webhook_verifier', sanitize_text_field(wp_unslash($_POST['webhook_verifier']))); } wp_safe_redirect(add_query_arg('saved', '1', admin_url('admin.php?page=ledgerbridge-sync-settings'))); exit; } /** Validates a *_sync_direction value against the three allowed options, defaulting to two_way. */ private static function sanitize_direction($value): string { return in_array($value, ['qb_to_woo', 'woo_to_qb', 'two_way'], true) ? $value : 'two_way'; } /** Builds a clean sync_matrix array from the submitted form, ignoring anything outside the known entity list. */ private static function sanitize_sync_matrix(): array { check_admin_referer('qbws_save_settings'); $matrix = []; foreach (QBWS_Config::SYNC_MATRIX_ENTITIES as $direction => $entities) { foreach ($entities as $entity) { $interval = isset($_POST['sync_matrix'][$direction][$entity]['interval']) ? absint(wp_unslash($_POST['sync_matrix'][$direction][$entity]['interval'])) : 15; $matrix[$direction][$entity] = [ 'enabled' => !empty($_POST['sync_matrix'][$direction][$entity]['enabled']), 'interval' => max(15, $interval), // 15 min floor — see Settings screen's dropdown ]; } } return $matrix; } public static function ajax_run_sync(): void { check_ajax_referer('qbws_admin_nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error('Unauthorized', 403); $type = sanitize_text_field(wp_unslash($_POST['sync_type'] ?? '')); try { switch ($type) { case 'products': $result = (new QBWS_Sync_Products())->run(true); break; case 'customers': $result = (new QBWS_Sync_Customers())->pull_from_qb(true); break; case 'orders': QBWS_Sync_Orders_Inbound::poll_qb_for_changes(); $result = ['queued' => true]; break; default: wp_send_json_error('Unknown sync type'); return; } wp_send_json_success($result); } catch (\Throwable $e) { QBWS_Logger::error('ADMIN', "Manual sync '{$type}' failed: " . $e->getMessage()); wp_send_json_error($e->getMessage()); } } public static function ajax_start_historical_import(): void { check_ajax_referer('qbws_admin_nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error('Unauthorized', 403); QBWS_Historical_Import::start(); wp_send_json_success(QBWS_Historical_Import::get_state()); } public static function ajax_historical_import_status(): void { check_ajax_referer('qbws_admin_nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error('Unauthorized', 403); wp_send_json_success(QBWS_Historical_Import::get_state()); } public static function ajax_resolve_conflict(): void { check_ajax_referer('qbws_admin_nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error('Unauthorized', 403); $wcOrderId = absint(wp_unslash($_POST['order_id'] ?? 0)); $keep = sanitize_text_field(wp_unslash($_POST['keep'] ?? '')); // 'woo' or 'qb' $mapRow = QBWS_Mapper::order_map_row($wcOrderId); if (!$mapRow) wp_send_json_error('No mapping found for that order'); if ($keep === 'woo') { (new QBWS_Sync_Orders_Outbound())->push_order($wcOrderId); } elseif ($keep === 'qb') { (new QBWS_Sync_Orders_Inbound())->sync_single_from_qb($mapRow['qb_txn_id'], $mapRow['qb_txn_type']); } else { wp_send_json_error('keep must be "woo" or "qb"'); return; } global $wpdb; $wpdb->update($wpdb->prefix . 'qbws_order_map', ['sync_status' => 'synced'], ['wc_order_id' => $wcOrderId]); wp_send_json_success(); } /** Called from the "Update in QuickBooks" button in the order meta box. */ public static function ajax_update_order_in_qb(): void { check_ajax_referer('qbws_admin_nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error('Unauthorized', 403); $orderId = absint(wp_unslash($_POST['order_id'] ?? 0)); if (!$orderId) wp_send_json_error('Missing order id'); try { $result = (new QBWS_Sync_Orders_Outbound())->push_order($orderId); if ($result === 'conflict') { wp_send_json_error('This order has a conflicting change in QuickBooks — resolve it from the Conflicts tab first.'); } wp_send_json_success(['result' => $result]); } catch (\Throwable $e) { QBWS_Logger::error('ADMIN', "Manual order push failed for #{$orderId}: " . $e->getMessage()); wp_send_json_error($e->getMessage()); } } /** Streams the QB-generated PDF for an order's Invoice/SalesReceipt. */ public static function download_pdf(): void { $orderId = absint(wp_unslash($_GET['order_id'] ?? 0)); if (!$orderId || !check_admin_referer('qbws_download_pdf_' . $orderId)) { wp_die(esc_html__('Invalid request.', 'ledgerbridge-sync')); } if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized.', 'ledgerbridge-sync')); } $map = QBWS_Mapper::order_map_row($orderId); if (!$map || empty($map['qb_txn_id'])) { wp_die(esc_html__('This order has not been synced to QuickBooks yet.', 'ledgerbridge-sync')); } $docType = $map['qb_txn_type'] ?: 'Invoice'; $endpoint = $docType === 'SalesReceipt' ? 'salesreceipt' : 'invoice'; $realm = QBWS_Config::get('realm_id'); $pdf = (new QBWS_Api_Client())->get_raw( "v3/company/{$realm}/{$endpoint}/{$map['qb_txn_id']}/pdf", ['Accept' => 'application/pdf'] ); if (!$pdf || empty($pdf['body'])) { wp_die(esc_html__('Could not fetch the PDF from QuickBooks — check the Logs tab for details.', 'ledgerbridge-sync')); } nocache_headers(); header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="order-' . $orderId . '.pdf"'); header('Content-Length: ' . strlen($pdf['body'])); echo $pdf['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary PDF, not HTML output exit; } /** * Standalone CSV export of every WordPress user with their QuickBooks * sync status — built entirely from this plugin's own tables rather * than hooking into WooCommerce Analytics' Customers report. See the * README's "Customer QBO status" section for why: that report's * on-screen table has no supported way for a plugin to add a column * (open WooCommerce feature request), and the one semi-documented * way to add a CSV-export-only column requires guessing at that * report's internal SQL table aliases — getting it wrong risks * breaking the whole report for something we can build far more * reliably ourselves with a query we fully control. */ public static function export_customers_csv(): void { if (!check_admin_referer('qbws_export_customers')) { wp_die(esc_html__('Invalid request.', 'ledgerbridge-sync')); } if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized.', 'ledgerbridge-sync')); } // Every WordPress user account, not just ones with the 'customer' // role — mirrors what actually shows in WooCommerce's own // Analytics Customers report (which includes admin-placed test // orders etc. too), so the numbers here match what you'd expect // to see there. $users = get_users(['orderby' => 'display_name', 'order' => 'ASC']); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="customers-qbo-status-' . gmdate('Y-m-d') . '.csv"'); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV download; every field is quoted/escaped by csv_line(). echo self::csv_line(['User ID', 'Name', 'Username', 'Email', 'Registered', 'QBO Status', 'QB Customer ID']); foreach ($users as $user) { $qbId = QBWS_Mapper::qb_id_by_customer($user->ID); echo self::csv_line([ $user->ID, $user->display_name, $user->user_login, $user->user_email, $user->user_registered, $qbId ? 'Synced' : 'Not Synced', $qbId ?: '', ]); } // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped exit; } /** * Builds one RFC 4180 CSV line. Fields that start with a spreadsheet * formula character are prefixed with an apostrophe so a malicious * display name can't execute as a formula when the file is opened. */ private static function csv_line(array $fields): string { $cells = []; foreach ($fields as $field) { $value = (string) $field; if ($value !== '' && strpbrk($value[0], "=+-@\t\r") !== false) { $value = "'" . $value; } $cells[] = '"' . str_replace('"', '""', $value) . '"'; } return implode(',', $cells) . "\r\n"; } // ═══════════════════════ ORDER LIST COLUMN ═════════════════════════ public static function add_order_column(array $columns): array { if (isset($columns['qbws_status'])) return $columns; // already added (both filters can fire in some WC versions) $new = []; foreach ($columns as $key => $label) { $new[$key] = $label; if ($key === 'order_total') { $new['qbws_status'] = __('QBO Status', 'ledgerbridge-sync'); } } if (!isset($new['qbws_status'])) { $new['qbws_status'] = __('QBO Status', 'ledgerbridge-sync'); // fallback: append if 'order_total' wasn't present } return $new; } /** Legacy (post-based) orders screen — $item is the WP post ID. */ public static function render_order_column_legacy(string $column, $item): void { if ($column !== 'qbws_status') return; self::render_status_badge((int) $item); } /** HPOS orders screen — $item is a WC_Order object. */ public static function render_order_column_hpos(string $column, $item): void { if ($column !== 'qbws_status') return; $orderId = is_object($item) && method_exists($item, 'get_id') ? $item->get_id() : (int) $item; self::render_status_badge($orderId); } private static function render_status_badge(int $orderId): void { $map = QBWS_Mapper::order_map_row($orderId); $status = 'not_synced'; if ($map) { if (($map['sync_status'] ?? '') === 'conflict') $status = 'conflict'; elseif (!empty($map['qb_txn_id'])) $status = 'synced'; } $styles = [ 'synced' => ['#d5f5e3', '#1e7e34', __('Synced', 'ledgerbridge-sync')], 'conflict' => ['#fdf3d0', '#8a6d3b', __('Conflict', 'ledgerbridge-sync')], 'not_synced' => ['#fbdada', '#a94442', __('Not Synced', 'ledgerbridge-sync')], ][$status]; printf( '%s', esc_attr($styles[0]), esc_attr($styles[1]), esc_html($styles[2]) ); } // ═══════════════════════ ORDER META BOX ════════════════════════════ public static function add_order_meta_box(): void { $screen = class_exists('\Automattic\WooCommerce\Utilities\OrderUtil') ? wc_get_page_screen_id('shop-order') : 'shop_order'; add_meta_box( 'qbws_order_status', __('QuickBooks Status', 'ledgerbridge-sync'), [__CLASS__, 'render_order_meta_box'], $screen, 'side', 'default' ); } public static function render_order_meta_box($post_or_order): void { $order = ($post_or_order instanceof WC_Order) ? $post_or_order : wc_get_order(is_object($post_or_order) ? $post_or_order->ID : $post_or_order); if (!$order) { echo '
' . esc_html__('Order not found.', 'ledgerbridge-sync') . '
'; return; } $orderId = $order->get_id(); $map = QBWS_Mapper::order_map_row($orderId); $status = 'not_synced'; if ($map) { if (($map['sync_status'] ?? '') === 'conflict') $status = 'conflict'; elseif (!empty($map['qb_txn_id'])) $status = 'synced'; } $nonce = wp_create_nonce('qbws_admin_nonce'); $ajaxUrl = admin_url('admin-ajax.php'); include __DIR__ . '/views/order-meta-box.php'; } private static function qb_view_url(array $map): string { $docType = $map['qb_txn_type'] ?? 'Invoice'; $path = $docType === 'SalesReceipt' ? 'salesreceipt' : 'invoice'; $base = QBWS_Config::get('environment') === 'sandbox' ? 'https://sandbox.qbo.intuit.com' : 'https://qbo.intuit.com'; return "{$base}/app/{$path}?txnId=" . rawurlencode($map['qb_txn_id']); } /** Renders a