From 68c1ff445559de069da622078dcb503fd71cd325 Mon Sep 17 00:00:00 2001 From: Malin Date: Wed, 1 Apr 2026 13:58:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20WooDoo=20plugin=20=E2=80=93?= =?UTF-8?q?=20WooCommerce=20&=20Odoo=2019=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Odoo JSON-RPC client (no Composer, uses wp_remote_post) - Admin settings page under WooCommerce with connection test - Customer linking: search Odoo partners from WP user profile - My Account: Odoo Invoices tab with PDF proxy download - My Account: Book a Meeting tab (slot calculator + calendar.event) - WC order → Odoo sale.order auto-sync on processing status - Products matched by SKU; partner auto-created from billing info - Uninstall cleanup (options, user meta, order meta, DB table) Co-Authored-By: Claude Sonnet 4.6 --- assets/css/woodoo-admin.css | 26 ++ assets/css/woodoo.css | 189 ++++++++++++++ assets/js/woodoo-admin.js | 130 ++++++++++ assets/js/woodoo-frontend.js | 248 ++++++++++++++++++ includes/class-woodoo-admin.php | 402 +++++++++++++++++++++++++++++ includes/class-woodoo-api.php | 259 +++++++++++++++++++ includes/class-woodoo-calendar.php | 318 +++++++++++++++++++++++ includes/class-woodoo-invoices.php | 219 ++++++++++++++++ includes/class-woodoo-orders.php | 254 ++++++++++++++++++ templates/myaccount-calendar.php | 146 +++++++++++ templates/myaccount-invoices.php | 119 +++++++++ uninstall.php | 45 ++++ woodoo.php | 123 +++++++++ 13 files changed, 2478 insertions(+) create mode 100644 assets/css/woodoo-admin.css create mode 100644 assets/css/woodoo.css create mode 100644 assets/js/woodoo-admin.js create mode 100644 assets/js/woodoo-frontend.js create mode 100644 includes/class-woodoo-admin.php create mode 100644 includes/class-woodoo-api.php create mode 100644 includes/class-woodoo-calendar.php create mode 100644 includes/class-woodoo-invoices.php create mode 100644 includes/class-woodoo-orders.php create mode 100644 templates/myaccount-calendar.php create mode 100644 templates/myaccount-invoices.php create mode 100644 uninstall.php create mode 100644 woodoo.php diff --git a/assets/css/woodoo-admin.css b/assets/css/woodoo-admin.css new file mode 100644 index 0000000..edef085 --- /dev/null +++ b/assets/css/woodoo-admin.css @@ -0,0 +1,26 @@ +/* WooDoo Admin Styles */ +.woodoo-settings .nav-tab-wrapper { margin-bottom: 0; } +.woodoo-settings .woodoo-tab { padding: 20px 0; } +.woodoo-info-box { + background: #fff8e1; + border: 1px solid #ffe082; + border-radius: 4px; + padding: 12px 16px; + margin-top: 16px; + font-size: .875rem; + max-width: 760px; +} +.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; } +.woodoo-info-box li { margin-bottom: 4px; } +.woodoo-customer-linker { margin-bottom: 1.5rem; } +.woodoo-customer-linker label { display: block; margin-bottom: 6px; font-weight: 600; } +#woo-customer-results { margin-top: 10px; } +.woodoo-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: .75rem; + font-weight: 600; +} +.woodoo-badge--green { background: #d1fae5; color: #065f46; } +.woodoo-badge--red { background: #fee2e2; color: #991b1b; } diff --git a/assets/css/woodoo.css b/assets/css/woodoo.css new file mode 100644 index 0000000..3828c4a --- /dev/null +++ b/assets/css/woodoo.css @@ -0,0 +1,189 @@ +/* ========================================================= + WooDoo Frontend Styles + ========================================================= */ + +/* ── Shared ──────────────────────────────────────────────── */ +.woodoo-notice { + padding: 12px 16px; + border-radius: 4px; + margin: 12px 0; + font-size: .9rem; +} +.woodoo-notice.woodoo-success { background: #ecfdf5; border: 1px solid #6ee7b7; color: #065f46; } +.woodoo-notice.woodoo-error { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; } +.woodoo-empty { color: #6b7280; font-style: italic; } +.woodoo-section { margin-bottom: 2rem; } +.woodoo-section h3 { font-size: 1.15rem; margin-bottom: .5rem; } +.woodoo-desc { color: #6b7280; margin-bottom: 1rem; font-size: .9rem; } + +/* ── Buttons ─────────────────────────────────────────────── */ +.woodoo-btn { + display: inline-block; + padding: 8px 16px; + border-radius: 4px; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + border: none; + text-decoration: none; + transition: opacity .15s; + line-height: 1.4; +} +.woodoo-btn:hover { opacity: .85; text-decoration: none; } +.woodoo-btn--primary { background: #2271b1; color: #fff; } +.woodoo-btn--sm { padding: 4px 10px; font-size: .8rem; } +.woodoo-btn--outline { background: transparent; border: 1px solid #d1d5db; color: #374151; } +.woodoo-btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ── Badges ──────────────────────────────────────────────── */ +.woodoo-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: .75rem; + font-weight: 600; + white-space: nowrap; +} +.woodoo-badge--green { background: #d1fae5; color: #065f46; } +.woodoo-badge--red { background: #fee2e2; color: #991b1b; } +.woodoo-badge--orange { background: #fed7aa; color: #92400e; } +.woodoo-badge--blue { background: #dbeafe; color: #1e40af; } +.woodoo-badge--grey { background: #f3f4f6; color: #6b7280; } + +/* ── Table ───────────────────────────────────────────────── */ +.woodoo-table-wrap { overflow-x: auto; } +.woodoo-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} +.woodoo-table th, +.woodoo-table td { + padding: 10px 12px; + border-bottom: 1px solid #e5e7eb; + text-align: left; + vertical-align: middle; +} +.woodoo-table th { background: #f9fafb; font-weight: 600; } +.woodoo-table tr:last-child td { border-bottom: none; } +.woodoo-table .woodoo-amount { text-align: right; font-variant-numeric: tabular-nums; } +.woodoo-table .woodoo-inv-number { font-weight: 600; } +.woodoo-overdue { color: #dc2626; font-weight: 600; } + +/* ── Pagination ──────────────────────────────────────────── */ +.woodoo-pagination { + margin-top: 1rem; + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} +.woodoo-pagination a, +.woodoo-pagination .woodoo-page-current { + padding: 4px 10px; + border-radius: 4px; + border: 1px solid #d1d5db; + font-size: .875rem; + text-decoration: none; +} +.woodoo-pagination .woodoo-page-current { + background: #2271b1; + color: #fff; + border-color: #2271b1; + font-weight: 600; +} + +/* ── Calendar / Booking ──────────────────────────────────── */ +.woodoo-booking-form .woodoo-field { + margin-bottom: 1.25rem; +} +.woodoo-booking-form label { + display: block; + font-weight: 600; + margin-bottom: 6px; + font-size: .9rem; +} +.woodoo-booking-form input[type="date"], +.woodoo-booking-form textarea { + width: 100%; + max-width: 320px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: .9rem; +} +.woodoo-booking-form textarea { max-width: 480px; resize: vertical; } + +.woodoo-slots-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; +} +.woodoo-slot { + padding: 8px 14px; + border: 2px solid #d1d5db; + border-radius: 6px; + font-size: .875rem; + cursor: pointer; + background: #fff; + transition: border-color .15s, background .15s; + white-space: nowrap; +} +.woodoo-slot:hover { border-color: #2271b1; background: #eff6ff; } +.woodoo-slot.selected{ border-color: #2271b1; background: #dbeafe; font-weight: 600; } + +.woodoo-selected-slot-display { + margin-bottom: 1rem; + padding: 10px 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 4px; + font-size: .9rem; +} + +/* ── Meeting Cards ───────────────────────────────────────── */ +.woodoo-meetings-grid { + display: grid; + gap: 12px; +} +.woodoo-meeting-card { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} +.woodoo-meeting-card__date { + display: flex; + flex-direction: column; + align-items: center; + min-width: 44px; + background: #2271b1; + color: #fff; + border-radius: 6px; + padding: 6px 8px; + text-align: center; +} +.woodoo-meeting-card__day { font-size: 1.4rem; font-weight: 700; line-height: 1; } +.woodoo-meeting-card__month { font-size: .7rem; text-transform: uppercase; } +.woodoo-meeting-card__info { flex: 1; display: flex; flex-direction: column; gap: 3px; } +.woodoo-meeting-card__time { font-size: .85rem; color: #6b7280; } +.woodoo-meeting-card__video { font-size: .85rem; font-weight: 600; } +.woodoo-meeting-card__loc { font-size: .85rem; color: #6b7280; } + +/* ── Admin styles (woodoo-admin.css shares this file via enqueueing) ── */ +.woodoo-settings .woodoo-tab { padding: 16px 0; } +.woodoo-info-box { + background: #fff8e1; + border: 1px solid #ffe082; + border-radius: 4px; + padding: 12px 16px; + margin-top: 12px; + font-size: .875rem; +} +.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; } +.woodoo-info-box li { margin-bottom: 4px; } +.woodoo-order-meta { border-top: 1px solid #e5e7eb; padding-top: 10px; } diff --git a/assets/js/woodoo-admin.js b/assets/js/woodoo-admin.js new file mode 100644 index 0000000..8299ae3 --- /dev/null +++ b/assets/js/woodoo-admin.js @@ -0,0 +1,130 @@ +/* WooDoo Admin JS */ +jQuery( function ( $ ) { + + const cfg = window.WooDooAdmin || {}; + + // ── Tab navigation ────────────────────────────────────────────────── + $( '.woodoo-settings .nav-tab' ).on( 'click', function ( e ) { + e.preventDefault(); + const target = $( this ).attr( 'href' ); + + $( '.woodoo-settings .nav-tab' ).removeClass( 'nav-tab-active' ); + $( this ).addClass( 'nav-tab-active' ); + + $( '.woodoo-tab' ).hide(); + $( target ).show(); + } ); + + // ── Test Connection ────────────────────────────────────────────────── + $( '#woodoo-test-connection' ).on( 'click', function () { + const $btn = $( this ); + const $result = $( '#woodoo-test-result' ); + + $btn.prop( 'disabled', true ).text( cfg.i18n.testing ); + $result.html( '' ); + + $.post( cfg.ajax_url, { + action : 'woodoo_test_connection', + nonce : cfg.nonce, + } ) + .done( function ( res ) { + if ( res.success ) { + const d = res.data; + const cls = d.success ? 'success' : 'error'; + $result.html( + '' + + escHtml( d.message ) + '' + ); + } else { + $result.html( '' + escHtml( res.data || cfg.i18n.error ) + '' ); + } + } ) + .fail( function () { + $result.html( '' + escHtml( cfg.i18n.error ) + '' ); + } ) + .always( function () { + $btn.prop( 'disabled', false ).text( 'Test Connection' ); + } ); + } ); + + // ── Partner search on user profile ────────────────────────────────── + $( '#woodoo-partner-search-btn' ).on( 'click', function () { + const $input = $( '#woodoo-partner-search-input' ); + const $results = $( '#woodoo-partner-search-results' ); + const query = $input.val().trim(); + const userId = $input.data( 'user-id' ); + + if ( ! query ) return; + + $results.html( '' + escHtml( cfg.i18n.searching ) + '' ); + + $.post( cfg.ajax_url, { + action : 'woodoo_search_partners', + nonce : cfg.nonce, + query : query, + } ) + .done( function ( res ) { + if ( ! res.success || ! res.data.length ) { + $results.html( 'No partners found.' ); + return; + } + + let html = ''; + $results.html( html ); + } ) + .fail( function () { + $results.html( '' + escHtml( cfg.i18n.error ) + '' ); + } ); + } ); + + // ── Pick partner from search results ──────────────────────────────── + $( document ).on( 'click', '.woodoo-pick-partner', function () { + const $btn = $( this ); + const partnerId = $btn.data( 'id' ); + const name = $btn.data( 'name' ); + const userId = $btn.data( 'user-id' ); + + $( '#woodoo_odoo_partner_id' ).val( partnerId ); + $( '#woodoo-partner-name-display' ).text( name ).show(); + $( '#woodoo-partner-search-results' ).html( + '' + escHtml( cfg.i18n.link_done ) + ' → ' + escHtml( name ) + ' (#' + partnerId + ')' + ); + + // Persist immediately if we have the AJAX handler + if ( userId ) { + $.post( cfg.ajax_url, { + action : 'woodoo_link_customer', + nonce : cfg.nonce, + user_id : userId, + partner_id : partnerId, + } ); + } + } ); + + // ── Utility ────────────────────────────────────────────────────────── + function escHtml( str ) { + return String( str ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ); + } + + function escAttr( str ) { + return escHtml( str ).replace( /'/g, ''' ); + } + +} ); diff --git a/assets/js/woodoo-frontend.js b/assets/js/woodoo-frontend.js new file mode 100644 index 0000000..3554c8e --- /dev/null +++ b/assets/js/woodoo-frontend.js @@ -0,0 +1,248 @@ +/* WooDoo Frontend JS – Calendar Booking */ +( function () { + 'use strict'; + + const cfg = window.WooDooCalendar; + if ( ! cfg ) return; // not on calendar page + + const i18n = cfg.i18n; + const ajaxUrl = cfg.ajax_url; + const nonce = cfg.nonce; + + // ── Element refs ──────────────────────────────────────────────────── + const dateInput = document.getElementById( 'woodoo-booking-date' ); + const slotsWrap = document.getElementById( 'woodoo-slots-wrap' ); + const slotsGrid = document.getElementById( 'woodoo-slots-grid' ); + const confirmBlock = document.getElementById( 'woodoo-booking-confirm' ); + const bookBtn = document.getElementById( 'woodoo-book-btn' ); + const notesArea = document.getElementById( 'woodoo-booking-notes' ); + const slotLabel = document.getElementById( 'woodoo-selected-slot-label' ); + const successMsg = document.getElementById( 'woodoo-booking-success' ); + const errorMsg = document.getElementById( 'woodoo-booking-error' ); + + let selectedStart = null; + let selectedEnd = null; + + // ── Date change → load slots ──────────────────────────────────────── + if ( dateInput ) { + dateInput.addEventListener( 'change', function () { + const date = this.value; + if ( ! date ) return; + + slotsGrid.innerHTML = '' + esc( i18n.loading ) + ''; + slotsWrap.style.display = 'block'; + confirmBlock.style.display = 'none'; + successMsg.style.display = 'none'; + errorMsg.style.display = 'none'; + selectedStart = selectedEnd = null; + bookBtn.disabled = true; + + post( 'woodoo_get_slots', { date } ) + .then( res => { + if ( ! res.success ) { + slotsGrid.innerHTML = '' + esc( i18n.error ) + ''; + return; + } + + const slots = res.data; + if ( ! slots.length ) { + slotsGrid.innerHTML = '' + esc( i18n.no_slots ) + ''; + confirmBlock.style.display = 'none'; + return; + } + + slotsGrid.innerHTML = ''; + slots.forEach( slot => { + const btn = document.createElement( 'button' ); + btn.type = 'button'; + btn.className = 'woodoo-slot'; + btn.textContent = formatTime( slot.start ) + ' – ' + formatTime( slot.end ); + btn.dataset.start = slot.start; + btn.dataset.end = slot.end; + btn.addEventListener( 'click', onSlotClick ); + slotsGrid.appendChild( btn ); + } ); + } ) + .catch( () => { + slotsGrid.innerHTML = '' + esc( i18n.error ) + ''; + } ); + } ); + } + + // ── Slot selection ─────────────────────────────────────────────────── + function onSlotClick( e ) { + document.querySelectorAll( '.woodoo-slot' ) + .forEach( b => b.classList.remove( 'selected' ) ); + + const btn = e.currentTarget; + btn.classList.add( 'selected' ); + + selectedStart = btn.dataset.start; + selectedEnd = btn.dataset.end; + + slotLabel.textContent = + new Date( selectedStart ).toLocaleDateString( undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + } ) + ' ' + formatTime( selectedStart ) + ' – ' + formatTime( selectedEnd ); + + confirmBlock.style.display = 'block'; + bookBtn.disabled = false; + successMsg.style.display = 'none'; + errorMsg.style.display = 'none'; + } + + // ── Confirm booking ────────────────────────────────────────────────── + if ( bookBtn ) { + bookBtn.addEventListener( 'click', function () { + if ( ! selectedStart || ! selectedEnd ) return; + + bookBtn.disabled = true; + bookBtn.textContent = i18n.booking; + errorMsg.style.display = 'none'; + + post( 'woodoo_book_slot', { + start : selectedStart, + end : selectedEnd, + notes : notesArea ? notesArea.value : '', + } ) + .then( res => { + if ( res.success ) { + successMsg.textContent = res.data.message; + successMsg.style.display = 'block'; + confirmBlock.style.display = 'none'; + slotsWrap.style.display = 'none'; + dateInput.value = ''; + selectedStart = selectedEnd = null; + + // Re-fetch meetings after booking + refreshMeetings(); + } else { + showError( res.data || i18n.error ); + bookBtn.disabled = false; + bookBtn.textContent = i18n.book_btn; + } + } ) + .catch( () => { + showError( i18n.error ); + bookBtn.disabled = false; + bookBtn.textContent = i18n.book_btn; + } ); + } ); + } + + // ── Cancel meeting ─────────────────────────────────────────────────── + document.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => { + btn.addEventListener( 'click', function () { + if ( ! confirm( i18n.cancel_confirm ) ) return; + + const eventId = this.dataset.eventId; + const card = this.closest( '.woodoo-meeting-card' ); + + this.disabled = true; + this.textContent = i18n.cancelling; + + post( 'woodoo_cancel_meeting', { event_id: eventId } ) + .then( res => { + if ( res.success ) { + card.style.opacity = '0.4'; + card.style.transition = 'opacity .3s'; + setTimeout( () => card.remove(), 350 ); + } else { + alert( res.data || i18n.error ); + this.disabled = false; + this.textContent = 'Cancel'; + } + } ) + .catch( () => { + this.disabled = false; + this.textContent = 'Cancel'; + } ); + } ); + } ); + + // ── Refresh meetings list ──────────────────────────────────────────── + function refreshMeetings() { + const listEl = document.getElementById( 'woodoo-meetings-list' ); + if ( ! listEl ) return; + + post( 'woodoo_get_meetings', {} ) + .then( res => { + if ( ! res.success ) return; + const meetings = res.data; + if ( ! meetings.length ) { + listEl.innerHTML = '

' + esc( 'No upcoming meetings scheduled.' ) + '

'; + return; + } + + let html = '
'; + meetings.forEach( ev => { + const start = new Date( ev.start.replace( ' ', 'T' ) ); + const end = new Date( ev.stop.replace( ' ', 'T' ) ); + html += + '
' + + '
' + + '' + pad( start.getDate() ) + '' + + '' + monthShort( start ) + '' + + '
' + + '
' + + '' + esc( ev.name ) + '' + + '' + + timeStr( start ) + ' – ' + timeStr( end ) + + '' + + ( ev.videocall_location + ? 'Join Video Call' + : '' ) + + '
' + + '
' + + '' + + '
' + + '
'; + } ); + html += '
'; + listEl.innerHTML = html; + + // Re-bind cancel buttons + listEl.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => { + btn.addEventListener( 'click', function () { + // same handler as above – re-use via custom event trick + btn.dispatchEvent( new Event( 'woodoo:cancel' ) ); + } ); + } ); + } ); + } + + // ── Helpers ────────────────────────────────────────────────────────── + function post( action, data ) { + const body = new URLSearchParams( Object.assign( { action, nonce }, data ) ); + return fetch( ajaxUrl, { method: 'POST', body } ) + .then( r => r.json() ); + } + + function formatTime( datetimeStr ) { + return datetimeStr.slice( 11, 16 ); + } + + function timeStr( d ) { + return pad( d.getHours() ) + ':' + pad( d.getMinutes() ); + } + + function pad( n ) { return String( n ).padStart( 2, '0' ); } + + function monthShort( d ) { + return d.toLocaleDateString( undefined, { month: 'short' } ); + } + + function esc( str ) { + return String( str ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ); + } + + function showError( msg ) { + errorMsg.textContent = msg; + errorMsg.style.display = 'block'; + } + +} )(); diff --git a/includes/class-woodoo-admin.php b/includes/class-woodoo-admin.php new file mode 100644 index 0000000..29830b1 --- /dev/null +++ b/includes/class-woodoo-admin.php @@ -0,0 +1,402 @@ + __( 'Odoo URL (e.g. https://odoo.example.com)', 'woodoo' ), + 'woodoo_odoo_db' => __( 'Database Name', 'woodoo' ), + 'woodoo_odoo_username' => __( 'Username / Login', 'woodoo' ), + 'woodoo_odoo_api_key' => __( 'API Key', 'woodoo' ), + ]; + foreach ( $fields as $key => $label ) { + register_setting( 'woodoo_settings', $key, [ 'sanitize_callback' => 'sanitize_text_field' ] ); + } + + // Meeting settings + $meeting_fields = [ + 'woodoo_meeting_duration' => 30, // minutes + 'woodoo_available_days' => [ 1, 2, 3, 4, 5 ], // Mon-Fri + 'woodoo_available_from' => '09:00', + 'woodoo_available_to' => '17:00', + 'woodoo_meeting_title_prefix'=> 'Meeting via WooDoo', + ]; + foreach ( $meeting_fields as $key => $default ) { + register_setting( 'woodoo_settings', $key ); + } + + // Order sync toggle + register_setting( 'woodoo_settings', 'woodoo_sync_orders', [ 'sanitize_callback' => 'rest_sanitize_boolean' ] ); + } + + public static function enqueue_admin_assets( string $hook ): void { + if ( strpos( $hook, 'woodoo-settings' ) === false + && $hook !== 'user-edit.php' + && $hook !== 'profile.php' ) { + return; + } + wp_enqueue_style( + 'woodoo-admin', + WOODOO_URL . 'assets/css/woodoo-admin.css', + [], + WOODOO_VERSION + ); + wp_enqueue_script( + 'woodoo-admin', + WOODOO_URL . 'assets/js/woodoo-admin.js', + [ 'jquery' ], + WOODOO_VERSION, + true + ); + wp_localize_script( 'woodoo-admin', 'WooDooAdmin', [ + 'nonce' => wp_create_nonce( 'woodoo_admin' ), + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'i18n' => [ + 'testing' => __( 'Testing…', 'woodoo' ), + 'searching' => __( 'Searching…', 'woodoo' ), + 'link_done' => __( 'Linked!', 'woodoo' ), + 'error' => __( 'Error. Check console.', 'woodoo' ), + ], + ] ); + } + + // ── Settings Page HTML ──────────────────────────────────────────────── + + public static function render_settings_page(): void { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'Access denied.', 'woodoo' ) ); + } + + $days_map = [ + 1 => __( 'Monday', 'woodoo' ), + 2 => __( 'Tuesday', 'woodoo' ), + 3 => __( 'Wednesday', 'woodoo' ), + 4 => __( 'Thursday', 'woodoo' ), + 5 => __( 'Friday', 'woodoo' ), + 6 => __( 'Saturday', 'woodoo' ), + 7 => __( 'Sunday', 'woodoo' ), + ]; + $saved_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] ); + ?> +
+

+ + + +
+ + + +
+

+ + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+

+ + +

+
+ + + + + + + + + + + +
+
+ ID, 'woodoo_odoo_partner_id', true ); + $partner_name = get_user_meta( $user->ID, 'woodoo_odoo_partner_name', true ); + ?> +

+ + + + + + ID, 'woodoo_so_count', true ); + if ( $odoo_so_count ) : ?> + + + + + +
+ + + + + + +

+ +

+ + +

+
+ + +

+

+
+ +
+ 0 ) { + update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id ); + + // Resolve name from Odoo + $api = woodoo_api(); + if ( $api ) { + $partners = $api->read( 'res.partner', [ $partner_id ], [ 'name', 'email' ] ); + if ( ! empty( $partners[0]['name'] ) ) { + update_user_meta( $user_id, 'woodoo_odoo_partner_name', sanitize_text_field( $partners[0]['name'] ) ); + } + } + } else { + delete_user_meta( $user_id, 'woodoo_odoo_partner_id' ); + delete_user_meta( $user_id, 'woodoo_odoo_partner_name' ); + } + } + + // ── Users List Column ───────────────────────────────────────────────── + + public static function add_users_column( array $columns ): array { + $columns['woodoo_odoo'] = __( 'Odoo Partner', 'woodoo' ); + return $columns; + } + + public static function render_users_column( string $value, string $column, int $user_id ): string { + if ( $column !== 'woodoo_odoo' ) return $value; + $partner_id = get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + $partner_name = get_user_meta( $user_id, 'woodoo_odoo_partner_name', true ); + if ( $partner_id ) { + return '' . esc_html( $partner_name ?: "#$partner_id" ) . ''; + } + return ''; + } + + // ── AJAX Handlers ───────────────────────────────────────────────────── + + public static function ajax_test_connection(): void { + check_ajax_referer( 'woodoo_admin', 'nonce' ); + if ( ! current_user_can( 'manage_woocommerce' ) ) wp_send_json_error( 'Forbidden', 403 ); + + $api = woodoo_api(); + if ( ! $api ) { + wp_send_json_error( 'Credentials not saved yet. Save settings first.' ); + } + + wp_send_json_success( $api->test_connection() ); + } + + public static function ajax_search_partners(): void { + check_ajax_referer( 'woodoo_admin', 'nonce' ); + if ( ! current_user_can( 'edit_users' ) ) wp_send_json_error( 'Forbidden', 403 ); + + $q = sanitize_text_field( wp_unslash( $_POST['query'] ?? '' ) ); + $api = woodoo_api(); + if ( ! $api || empty( $q ) ) wp_send_json_success( [] ); + + $results = $api->search_read( + 'res.partner', + [ '|', [ 'name', 'ilike', $q ], [ 'email', 'ilike', $q ] ], + [ 'id', 'name', 'email', 'is_company' ], + 10 + ); + + wp_send_json_success( $results ); + } + + public static function ajax_link_customer(): void { + check_ajax_referer( 'woodoo_admin', 'nonce' ); + if ( ! current_user_can( 'edit_users' ) ) wp_send_json_error( 'Forbidden', 403 ); + + $user_id = absint( $_POST['user_id'] ?? 0 ); + $partner_id = absint( $_POST['partner_id'] ?? 0 ); + + if ( ! $user_id || ! $partner_id ) wp_send_json_error( 'Invalid IDs' ); + + $api = woodoo_api(); + if ( ! $api ) wp_send_json_error( 'API not configured' ); + + $partners = $api->read( 'res.partner', [ $partner_id ], [ 'name', 'email' ] ); + if ( empty( $partners ) ) wp_send_json_error( 'Partner not found in Odoo' ); + + update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id ); + update_user_meta( $user_id, 'woodoo_odoo_partner_name', sanitize_text_field( $partners[0]['name'] ) ); + + wp_send_json_success( [ + 'partner_id' => $partner_id, + 'partner_name' => $partners[0]['name'], + ] ); + } +} diff --git a/includes/class-woodoo-api.php b/includes/class-woodoo-api.php new file mode 100644 index 0000000..a2e804a --- /dev/null +++ b/includes/class-woodoo-api.php @@ -0,0 +1,259 @@ +url = $url; + $this->db = $db; + $this->username = $username; + $this->api_key = $api_key; + } + + // ── Low-level JSON-RPC ──────────────────────────────────────────────── + + /** + * POST to /jsonrpc. + * + * @param string $service "common" | "object" + * @param string $method e.g. "authenticate" | "execute_kw" + * @param array $args positional arguments + * @return mixed decoded result or WP_Error + */ + public function jsonrpc( string $service, string $method, array $args ): mixed { + $body = wp_json_encode( [ + 'jsonrpc' => '2.0', + 'method' => 'call', + 'id' => wp_rand( 1, 999999999 ), + 'params' => [ + 'service' => $service, + 'method' => $method, + 'args' => $args, + ], + ] ); + + $response = wp_remote_post( + $this->url . '/jsonrpc', + [ + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => $body, + 'timeout' => 30, + 'sslverify' => apply_filters( 'woodoo_ssl_verify', true ), + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $data['error'] ) ) { + $msg = $data['error']['data']['message'] + ?? $data['error']['message'] + ?? 'Unknown Odoo error'; + return new WP_Error( 'odoo_error', $msg, $data['error'] ); + } + + return $data['result'] ?? null; + } + + // ── Authentication ──────────────────────────────────────────────────── + + /** + * Authenticate and cache the uid for this request lifecycle. + */ + public function authenticate(): ?int { + if ( $this->uid ) return $this->uid; + + $result = $this->jsonrpc( 'common', 'authenticate', [ + $this->db, + $this->username, + $this->api_key, + [], + ] ); + + if ( is_wp_error( $result ) || ! is_int( $result ) || $result <= 0 ) { + return null; + } + + $this->uid = $result; + return $this->uid; + } + + // ── ORM execute_kw wrapper ──────────────────────────────────────────── + + /** + * Call any ORM method via execute_kw. + * + * @param string $model e.g. 'res.partner' + * @param string $method e.g. 'search_read' + * @param array $args positional args (list of lists usually) + * @param array $kwargs keyword args (fields, limit, offset, etc.) + */ + public function execute_kw( string $model, string $method, array $args = [], array $kwargs = [] ): mixed { + $uid = $this->authenticate(); + if ( ! $uid ) { + return new WP_Error( 'woodoo_auth', 'Could not authenticate with Odoo.' ); + } + + return $this->jsonrpc( 'object', 'execute_kw', [ + $this->db, + $uid, + $this->api_key, + $model, + $method, + $args, + $kwargs, + ] ); + } + + // ── Convenience helpers ─────────────────────────────────────────────── + + public function search_read( + string $model, + array $domain = [], + array $fields = [], + int $limit = 0, + int $offset = 0, + string $order = '' + ): array { + $kwargs = [ 'fields' => $fields ]; + if ( $limit > 0 ) $kwargs['limit'] = $limit; + if ( $offset > 0 ) $kwargs['offset'] = $offset; + if ( $order !== '' ) $kwargs['order'] = $order; + + $result = $this->execute_kw( $model, 'search_read', [ $domain ], $kwargs ); + return is_array( $result ) ? $result : []; + } + + public function search( + string $model, + array $domain = [], + int $limit = 0, + int $offset = 0, + string $order = '' + ): array { + $kwargs = []; + if ( $limit > 0 ) $kwargs['limit'] = $limit; + if ( $offset > 0 ) $kwargs['offset'] = $offset; + if ( $order !== '' ) $kwargs['order'] = $order; + + $result = $this->execute_kw( $model, 'search', [ $domain ], $kwargs ); + return is_array( $result ) ? $result : []; + } + + public function read( string $model, array $ids, array $fields = [] ): array { + $kwargs = $fields ? [ 'fields' => $fields ] : []; + $result = $this->execute_kw( $model, 'read', [ $ids ], $kwargs ); + return is_array( $result ) ? $result : []; + } + + public function create( string $model, array $values ): ?int { + $result = $this->execute_kw( $model, 'create', [ $values ] ); + return is_int( $result ) ? $result : null; + } + + public function write( string $model, array $ids, array $values ): bool { + $result = $this->execute_kw( $model, 'write', [ $ids, $values ] ); + return $result === true; + } + + public function search_count( string $model, array $domain = [] ): int { + $result = $this->execute_kw( $model, 'search_count', [ $domain ] ); + return is_int( $result ) ? $result : 0; + } + + public function unlink( string $model, array $ids ): bool { + $result = $this->execute_kw( $model, 'unlink', [ $ids ] ); + return $result === true; + } + + // ── Partner helpers ─────────────────────────────────────────────────── + + /** + * Find a partner by email or create one. + * Returns the Odoo partner ID. + */ + public function find_or_create_partner( string $email, string $name, array $extra = [] ): ?int { + $found = $this->search( 'res.partner', [ [ 'email', '=', $email ] ], 1 ); + if ( ! empty( $found ) ) { + return (int) $found[0]; + } + + return $this->create( 'res.partner', array_merge( [ + 'name' => $name, + 'email' => $email, + ], $extra ) ); + } + + // ── Product helpers ─────────────────────────────────────────────────── + + /** + * Find a product.product by its SKU (default_code). + * Returns product ID or null if not found. + */ + public function find_product_by_sku( string $sku ): ?int { + if ( empty( $sku ) ) return null; + $found = $this->search( 'product.product', [ [ 'default_code', '=', $sku ] ], 1 ); + return ! empty( $found ) ? (int) $found[0] : null; + } + + // ── Diagnostics ─────────────────────────────────────────────────────── + + /** + * Test connectivity and credentials. + * Returns ['success' => bool, 'message' => string, 'version' => string|null] + */ + public function test_connection(): array { + // Version check (no auth needed) + $version = $this->jsonrpc( 'common', 'version', [] ); + if ( is_wp_error( $version ) ) { + return [ + 'success' => false, + 'message' => 'Cannot reach Odoo: ' . $version->get_error_message(), + 'version' => null, + ]; + } + + $ver_str = $version['server_version'] ?? 'unknown'; + + // Try authenticate + $uid = $this->authenticate(); + if ( ! $uid ) { + return [ + 'success' => false, + 'message' => "Reached Odoo {$ver_str} but authentication failed. Check username / API key.", + 'version' => $ver_str, + ]; + } + + return [ + 'success' => true, + 'message' => "Connected to Odoo {$ver_str} as UID {$uid}.", + 'version' => $ver_str, + ]; + } +} diff --git a/includes/class-woodoo-calendar.php b/includes/class-woodoo-calendar.php new file mode 100644 index 0000000..a756c27 --- /dev/null +++ b/includes/class-woodoo-calendar.php @@ -0,0 +1,318 @@ +' . + esc_html__( 'Booking is not available right now. Please contact support.', 'woodoo' ) . + '

'; + return; + } + + // Upcoming bookings for this user + $upcoming = []; + if ( $partner_id ) { + $now = gmdate( 'Y-m-d H:i:s' ); + $upcoming = $api->search_read( + 'calendar.event', + [ + [ 'partner_ids', 'in', [ $partner_id ] ], + [ 'start', '>=', $now ], + [ 'active', '=', true ], + ], + [ 'id', 'name', 'start', 'stop', 'location', 'description', 'videocall_location' ], + 20, + 0, + 'start asc' + ); + } + + include WOODOO_DIR . 'templates/myaccount-calendar.php'; + } + + // ── Slot Calculation ────────────────────────────────────────────────── + + /** + * Returns available booking slots for a given date (Y-m-d). + * Filters out already-booked times from Odoo calendar. + * + * @return array List of ['start' => 'Y-m-d H:i', 'end' => 'Y-m-d H:i'] + */ + public static function get_available_slots( string $date ): array { + $available_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] ); + $from = get_option( 'woodoo_available_from', '09:00' ); + $to = get_option( 'woodoo_available_to', '17:00' ); + $duration = (int) get_option( 'woodoo_meeting_duration', 30 ); + + $ts = strtotime( $date ); + $day_of_w = (int) gmdate( 'N', $ts ); // 1=Mon … 7=Sun + + if ( ! in_array( $day_of_w, array_map( 'intval', $available_days ), true ) ) { + return []; + } + + // Build all slots for the day + $slots = []; + $from_ts = strtotime( $date . ' ' . $from . ':00' ); + $to_ts = strtotime( $date . ' ' . $to . ':00' ); + $step = $duration * 60; + + for ( $start = $from_ts; $start + $step <= $to_ts; $start += $step ) { + $slots[] = [ + 'start' => gmdate( 'Y-m-d H:i', $start ), + 'end' => gmdate( 'Y-m-d H:i', $start + $step ), + 'start_ts' => $start, + 'end_ts' => $start + $step, + ]; + } + + // Fetch existing events from Odoo for this day + $api = woodoo_api(); + if ( ! $api ) return $slots; + + $day_start = $date . ' 00:00:00'; + $day_end = $date . ' 23:59:59'; + + $cache_key = 'woodoo_booked_' . $date; + $booked = get_transient( $cache_key ); + + if ( false === $booked ) { + $booked = $api->search_read( + 'calendar.event', + [ + [ 'start', '>=', $day_start ], + [ 'start', '<=', $day_end ], + [ 'active', '=', true ], + ], + [ 'id', 'start', 'stop' ], + 100 + ); + set_transient( $cache_key, $booked, 120 ); + } + + // Remove slots that overlap with existing events + $available = array_filter( $slots, function ( $slot ) use ( $booked ) { + foreach ( $booked as $event ) { + $e_start = strtotime( $event['start'] ); + $e_end = strtotime( $event['stop'] ); + // Overlap: slot_start < e_end AND slot_end > e_start + if ( $slot['start_ts'] < $e_end && $slot['end_ts'] > $e_start ) { + return false; + } + } + return true; + } ); + + // Strip internal timestamps before returning + return array_values( array_map( fn($s) => [ + 'start' => $s['start'], + 'end' => $s['end'], + ], $available ) ); + } + + // ── AJAX: Get Slots ─────────────────────────────────────────────────── + + public static function ajax_get_slots(): void { + check_ajax_referer( 'woodoo_calendar', 'nonce' ); + if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 ); + + $date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) ); + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { + wp_send_json_error( 'Invalid date format' ); + } + + // Don't allow past dates + if ( strtotime( $date ) < strtotime( 'today' ) ) { + wp_send_json_success( [] ); + } + + wp_send_json_success( self::get_available_slots( $date ) ); + } + + // ── AJAX: Book Slot ─────────────────────────────────────────────────── + + public static function ajax_book_slot(): void { + check_ajax_referer( 'woodoo_calendar', 'nonce' ); + if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 ); + + $start = sanitize_text_field( wp_unslash( $_POST['start'] ?? '' ) ); + $end = sanitize_text_field( wp_unslash( $_POST['end'] ?? '' ) ); + $notes = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) ); + + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $start ) + || ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $end ) ) { + wp_send_json_error( 'Invalid date/time format' ); + } + + if ( strtotime( $start ) < time() ) { + wp_send_json_error( __( 'Cannot book a slot in the past.', 'woodoo' ) ); + } + + $user_id = get_current_user_id(); + $user = get_userdata( $user_id ); + $api = woodoo_api(); + + if ( ! $api ) wp_send_json_error( 'API not configured' ); + + // Find or create Odoo partner for the customer + $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + if ( ! $partner_id ) { + $partner_id = $api->find_or_create_partner( + $user->user_email, + $user->display_name + ); + if ( $partner_id ) { + update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id ); + } + } + + if ( ! $partner_id ) { + wp_send_json_error( __( 'Could not find your Odoo record.', 'woodoo' ) ); + } + + // Get the Odoo user (organizer) – the API user + $me_uid = $api->authenticate(); + // Find res.users for this uid to get partner_id of organizer + $organizer_users = $api->search_read( + 'res.users', + [ [ 'id', '=', $me_uid ] ], + [ 'partner_id' ], + 1 + ); + $organizer_partner_id = $organizer_users[0]['partner_id'][0] ?? null; + + $title = get_option( 'woodoo_meeting_title_prefix', 'Meeting via WooDoo' ) + . ' – ' . $user->display_name; + + $partner_ids = array_filter( [ $partner_id, $organizer_partner_id ] ); + + $event_vals = [ + 'name' => $title, + 'start' => $start . ':00', + 'stop' => $end . ':00', + 'description' => $notes ?: false, + 'partner_ids' => array_map( fn($id) => [ 4, $id, 0 ], $partner_ids ), + 'privacy' => 'confidential', + ]; + + $event_id = $api->create( 'calendar.event', $event_vals ); + + if ( ! $event_id ) { + wp_send_json_error( __( 'Could not create meeting in Odoo. Please try again.', 'woodoo' ) ); + } + + // Invalidate booked slots cache for this date + $date = substr( $start, 0, 10 ); + delete_transient( 'woodoo_booked_' . $date ); + + wp_send_json_success( [ + 'event_id' => $event_id, + 'start' => $start, + 'end' => $end, + 'message' => __( 'Meeting booked! You will receive a confirmation from Odoo.', 'woodoo' ), + ] ); + } + + // ── AJAX: Get User's Meetings ───────────────────────────────────────── + + public static function ajax_get_meetings(): void { + check_ajax_referer( 'woodoo_calendar', 'nonce' ); + if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 ); + + $user_id = get_current_user_id(); + $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + if ( ! $partner_id ) wp_send_json_success( [] ); + + $api = woodoo_api(); + if ( ! $api ) wp_send_json_error( 'API not configured' ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $meetings = $api->search_read( + 'calendar.event', + [ + [ 'partner_ids', 'in', [ $partner_id ] ], + [ 'start', '>=', $now ], + [ 'active', '=', true ], + ], + [ 'id', 'name', 'start', 'stop', 'location', 'videocall_location' ], + 20, + 0, + 'start asc' + ); + + wp_send_json_success( $meetings ); + } + + // ── AJAX: Cancel Meeting ────────────────────────────────────────────── + + public static function ajax_cancel_meeting(): void { + check_ajax_referer( 'woodoo_calendar', 'nonce' ); + if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 ); + + $event_id = absint( $_POST['event_id'] ?? 0 ); + $user_id = get_current_user_id(); + $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + + if ( ! $event_id || ! $partner_id ) wp_send_json_error( 'Invalid request' ); + + $api = woodoo_api(); + if ( ! $api ) wp_send_json_error( 'API not configured' ); + + // Verify the event belongs to this user + $events = $api->search( + 'calendar.event', + [ [ 'id', '=', $event_id ], [ 'partner_ids', 'in', [ $partner_id ] ] ], + 1 + ); + + if ( empty( $events ) ) { + wp_send_json_error( __( 'Meeting not found.', 'woodoo' ) ); + } + + // Unlink the customer partner from the event (don't delete the event) + $api->write( + 'calendar.event', + [ $event_id ], + [ 'partner_ids' => [ [ 3, $partner_id, 0 ] ] ] + ); + + wp_send_json_success( [ 'message' => __( 'Meeting cancelled.', 'woodoo' ) ] ); + } +} diff --git a/includes/class-woodoo-invoices.php b/includes/class-woodoo-invoices.php new file mode 100644 index 0000000..3707d0c --- /dev/null +++ b/includes/class-woodoo-invoices.php @@ -0,0 +1,219 @@ + $label ) { + $new[ $key ] = $label; + if ( $key === 'orders' ) { + $new[ self::ENDPOINT ] = __( 'Odoo Invoices', 'woodoo' ); + } + } + return $new; + } + + // ── Render ──────────────────────────────────────────────────────────── + + public static function render(): void { + $user_id = get_current_user_id(); + $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + + if ( ! $partner_id ) { + echo '

' . + esc_html__( 'Your account is not yet linked to Odoo. Please contact us.', 'woodoo' ) . + '

'; + return; + } + + $api = woodoo_api(); + if ( ! $api ) { + echo '

' . + esc_html__( 'Odoo integration is not configured. Please contact support.', 'woodoo' ) . + '

'; + return; + } + + // Page parameter + $paged = max( 1, (int) ( $_GET['invoice_page'] ?? 1 ) ); // phpcs:ignore + $per_page = 10; + $offset = ( $paged - 1 ) * $per_page; + + $domain = [ + [ 'partner_id', '=', $partner_id ], + [ 'move_type', '=', 'out_invoice' ], + [ 'state', '!=', 'cancel' ], + ]; + + $cache_key = 'woodoo_invoices_' . $partner_id . '_' . $paged; + $invoices = get_transient( $cache_key ); + + if ( false === $invoices ) { + $invoices = $api->search_read( + 'account.move', + $domain, + [ + 'id', 'name', 'invoice_date', 'invoice_date_due', + 'amount_total', 'amount_residual', 'payment_state', + 'currency_id', 'state', + ], + $per_page, + $offset, + 'invoice_date desc' + ); + set_transient( $cache_key, $invoices, 60 ); + } + + $total = $api->search_count( 'account.move', $domain ); + $num_pages = (int) ceil( $total / $per_page ); + + include WOODOO_DIR . 'templates/myaccount-invoices.php'; + } + + // ── PDF Proxy ───────────────────────────────────────────────────────── + + /** + * Download an invoice PDF from Odoo and serve it to the logged-in user. + * ?action=woodoo_invoice_pdf&invoice_id=123&nonce=... + */ + public static function ajax_download_pdf(): void { + $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) ); + if ( ! wp_verify_nonce( $nonce, 'woodoo_invoice_pdf' ) ) { + wp_die( esc_html__( 'Security check failed.', 'woodoo' ) ); + } + + if ( ! is_user_logged_in() ) { + wp_die( esc_html__( 'Please log in.', 'woodoo' ) ); + } + + $invoice_id = absint( $_GET['invoice_id'] ?? 0 ); + if ( ! $invoice_id ) wp_die( 'Invalid invoice ID.' ); + + // Verify the invoice belongs to this user + $user_id = get_current_user_id(); + $partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true ); + + if ( ! $partner_id ) wp_die( esc_html__( 'Account not linked.', 'woodoo' ) ); + + $api = woodoo_api(); + if ( ! $api ) wp_die( 'API not configured.' ); + + $invoices = $api->search( + 'account.move', + [ [ 'id', '=', $invoice_id ], [ 'partner_id', '=', $partner_id ] ], + 1 + ); + + if ( empty( $invoices ) ) { + wp_die( esc_html__( 'Invoice not found.', 'woodoo' ) ); + } + + // Fetch the PDF report from Odoo + $odoo_url = rtrim( get_option( 'woodoo_odoo_url', '' ), '/' ); + $pdf_url = $odoo_url . '/report/pdf/account.report_invoice/' . $invoice_id; + + // Build auth cookie by first doing session authenticate + $auth_response = wp_remote_post( + $odoo_url . '/web/session/authenticate', + [ + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => wp_json_encode( [ + 'jsonrpc' => '2.0', + 'method' => 'call', + 'id' => 1, + 'params' => [ + 'db' => get_option( 'woodoo_odoo_db' ), + 'login' => get_option( 'woodoo_odoo_username' ), + 'password' => get_option( 'woodoo_odoo_api_key' ), + ], + ] ), + 'timeout' => 20, + 'sslverify' => apply_filters( 'woodoo_ssl_verify', true ), + ] + ); + + if ( is_wp_error( $auth_response ) ) { + wp_die( esc_html__( 'Could not authenticate with Odoo.', 'woodoo' ) ); + } + + // Extract session cookie + $raw_headers = wp_remote_retrieve_headers( $auth_response ); + $session_cookie = ''; + if ( isset( $raw_headers['set-cookie'] ) ) { + $cookie_header = is_array( $raw_headers['set-cookie'] ) + ? $raw_headers['set-cookie'][0] + : $raw_headers['set-cookie']; + preg_match( '/session_id=([^;]+)/', $cookie_header, $m ); + if ( isset( $m[1] ) ) $session_cookie = 'session_id=' . $m[1]; + } + + $pdf_response = wp_remote_get( + $pdf_url, + [ + 'headers' => $session_cookie ? [ 'Cookie' => $session_cookie ] : [], + 'timeout' => 60, + 'sslverify' => apply_filters( 'woodoo_ssl_verify', true ), + ] + ); + + if ( is_wp_error( $pdf_response ) ) { + wp_die( esc_html__( 'Could not retrieve invoice PDF.', 'woodoo' ) ); + } + + $pdf_body = wp_remote_retrieve_body( $pdf_response ); + + header( 'Content-Type: application/pdf' ); + header( 'Content-Disposition: attachment; filename="invoice-' . $invoice_id . '.pdf"' ); + header( 'Content-Length: ' . strlen( $pdf_body ) ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $pdf_body; + exit; + } + + // ── Helper ──────────────────────────────────────────────────────────── + + public static function payment_state_label( string $state ): string { + $labels = [ + 'not_paid' => __( 'Unpaid', 'woodoo' ), + 'partial' => __( 'Partial', 'woodoo' ), + 'in_payment'=> __( 'In Payment', 'woodoo' ), + 'paid' => __( 'Paid', 'woodoo' ), + 'reversed' => __( 'Reversed', 'woodoo' ), + ]; + return $labels[ $state ] ?? ucfirst( $state ); + } + + public static function payment_state_class( string $state ): string { + return match ( $state ) { + 'paid' => 'woodoo-badge--green', + 'not_paid' => 'woodoo-badge--red', + 'partial' => 'woodoo-badge--orange', + 'in_payment'=> 'woodoo-badge--blue', + default => 'woodoo-badge--grey', + }; + } +} diff --git a/includes/class-woodoo-orders.php b/includes/class-woodoo-orders.php new file mode 100644 index 0000000..6ac7765 --- /dev/null +++ b/includes/class-woodoo-orders.php @@ -0,0 +1,254 @@ +get_meta( '_woodoo_so_id' ) ) return; + + $api = woodoo_api(); + if ( ! $api ) { + $order->add_order_note( __( '[WooDoo] Odoo integration not configured – order not synced.', 'woodoo' ) ); + return; + } + + $partner_id = self::resolve_partner( $api, $order ); + if ( ! $partner_id ) { + $order->add_order_note( __( '[WooDoo] Could not find/create Odoo partner – order not synced.', 'woodoo' ) ); + return; + } + + $so_id = self::create_sales_order( $api, $order, $partner_id ); + + if ( $so_id ) { + $order->update_meta_data( '_woodoo_so_id', $so_id ); + $order->update_meta_data( '_woodoo_partner_id', $partner_id ); + $order->save_meta_data(); + + $odoo_url = get_option( 'woodoo_odoo_url', '' ); + $so_link = $odoo_url ? sprintf( + '#%d', + esc_url( $odoo_url ), + $so_id, + $so_id + ) : "#$so_id"; + + /* translators: %s: Odoo SO link */ + $order->add_order_note( sprintf( __( '[WooDoo] Sales Order created in Odoo: %s', 'woodoo' ), $so_link ) ); + + // If the WC customer has no partner_id set yet, save it + $wc_user_id = $order->get_customer_id(); + if ( $wc_user_id && ! get_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', true ) ) { + update_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', $partner_id ); + // Count synced orders + $count = (int) get_user_meta( $wc_user_id, 'woodoo_so_count', true ); + update_user_meta( $wc_user_id, 'woodoo_so_count', $count + 1 ); + } else { + $count = (int) get_user_meta( $wc_user_id, 'woodoo_so_count', true ); + update_user_meta( $wc_user_id, 'woodoo_so_count', $count + 1 ); + } + } else { + $order->add_order_note( __( '[WooDoo] Failed to create Odoo Sales Order. Check WooDoo logs.', 'woodoo' ) ); + } + } + + // ── Partner Resolution ──────────────────────────────────────────────── + + private static function resolve_partner( WooDoo_API $api, WC_Order $order ): ?int { + // 1. Try from WC user meta first + $wc_user_id = $order->get_customer_id(); + if ( $wc_user_id ) { + $partner_id = (int) get_user_meta( $wc_user_id, 'woodoo_odoo_partner_id', true ); + if ( $partner_id > 0 ) return $partner_id; + } + + // 2. Search Odoo by billing email + $email = $order->get_billing_email(); + if ( $email ) { + $found = $api->search( 'res.partner', [ [ 'email', '=', $email ] ], 1 ); + if ( ! empty( $found ) ) return (int) $found[0]; + } + + // 3. Create new partner from order billing info + $name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); + if ( ! $name ) $name = $order->get_billing_company() ?: 'WooCommerce Customer'; + + $vals = [ + 'name' => $name, + 'email' => $email ?: false, + 'phone' => $order->get_billing_phone() ?: false, + 'street' => $order->get_billing_address_1() ?: false, + 'street2' => $order->get_billing_address_2() ?: false, + 'city' => $order->get_billing_city() ?: false, + 'zip' => $order->get_billing_postcode() ?: false, + 'is_company' => (bool) $order->get_billing_company(), + ]; + + // Country + $country_code = $order->get_billing_country(); + if ( $country_code ) { + $countries = $api->search_read( + 'res.country', + [ [ 'code', '=', $country_code ] ], + [ 'id' ], + 1 + ); + if ( ! empty( $countries ) ) { + $vals['country_id'] = $countries[0]['id']; + } + } + + // Remove false values + $vals = array_filter( $vals, fn( $v ) => $v !== false ); + + return $api->create( 'res.partner', $vals ); + } + + // ── Sales Order Creation ────────────────────────────────────────────── + + private static function create_sales_order( WooDoo_API $api, WC_Order $order, int $partner_id ): ?int { + $lines = self::build_order_lines( $api, $order ); + + $so_vals = [ + 'partner_id' => $partner_id, + 'client_order_ref' => 'WC-' . $order->get_order_number(), + 'note' => $order->get_customer_note() ?: false, + 'order_line' => $lines, + ]; + + // If there's a WC-created date set it + $date_created = $order->get_date_created(); + if ( $date_created ) { + $so_vals['date_order'] = $date_created->date( 'Y-m-d H:i:s' ); + } + + // Remove false values + $so_vals = array_filter( $so_vals, fn( $v ) => $v !== false ); + + return $api->create( 'sale.order', $so_vals ); + } + + // ── Order Line Builder ──────────────────────────────────────────────── + + private static function build_order_lines( WooDoo_API $api, WC_Order $order ): array { + $lines = []; + + foreach ( $order->get_items() as $item ) { + /** @var WC_Order_Item_Product $item */ + $sku = ''; + $product = $item->get_product(); + if ( $product ) $sku = $product->get_sku(); + + $odoo_product_id = $api->find_product_by_sku( $sku ); + + $line = [ + 'name' => $item->get_name(), + 'product_uom_qty' => (float) $item->get_quantity(), + 'price_unit' => (float) $order->get_item_subtotal( $item, false, false ), + ]; + + if ( $odoo_product_id ) { + $line['product_id'] = $odoo_product_id; + } + + // Command.create: [0, 0, vals] + $lines[] = [ 0, 0, $line ]; + } + + // Shipping line + $shipping_total = (float) $order->get_shipping_total(); + if ( $shipping_total > 0 ) { + $lines[] = [ 0, 0, [ + 'name' => sprintf( + __( 'Shipping: %s', 'woodoo' ), + $order->get_shipping_method() + ), + 'product_uom_qty' => 1, + 'price_unit' => $shipping_total, + ] ]; + } + + // Discount line (if any coupon was applied) + $discount = (float) $order->get_discount_total(); + if ( $discount > 0 ) { + $lines[] = [ 0, 0, [ + 'name' => __( 'Discount', 'woodoo' ), + 'product_uom_qty' => 1, + 'price_unit' => -$discount, + ] ]; + } + + return $lines; + } + + // ── Admin UI ────────────────────────────────────────────────────────── + + public static function show_so_meta( WC_Order $order ): void { + $so_id = $order->get_meta( '_woodoo_so_id' ); + $partner_id = $order->get_meta( '_woodoo_partner_id' ); + + if ( ! $so_id && ! $partner_id ) return; + + $odoo_url = get_option( 'woodoo_odoo_url', '' ); + + echo '
'; + echo '

' . esc_html__( 'Odoo', 'woodoo' ) . '

'; + + if ( $so_id ) { + $link = $odoo_url + ? sprintf( 'SO #%d', esc_url( $odoo_url ), (int) $so_id, (int) $so_id ) + : 'SO #' . esc_html( $so_id ); + echo '

' . esc_html__( 'Sales Order:', 'woodoo' ) . ' ' . wp_kses_post( $link ) . '

'; + } + + if ( $partner_id ) { + $link = $odoo_url + ? sprintf( 'Partner #%d', esc_url( $odoo_url ), (int) $partner_id, (int) $partner_id ) + : 'Partner #' . esc_html( $partner_id ); + echo '

' . esc_html__( 'Partner:', 'woodoo' ) . ' ' . wp_kses_post( $link ) . '

'; + } + + echo '
'; + } + + public static function add_order_action( array $actions ): array { + $actions['woodoo_resync'] = __( 'Re-sync to Odoo (WooDoo)', 'woodoo' ); + return $actions; + } + + public static function manual_resync( WC_Order $order ): void { + // Clear previous SO ID so sync runs fresh + $order->delete_meta_data( '_woodoo_so_id' ); + $order->save_meta_data(); + self::sync_order( $order->get_id(), $order ); + } +} diff --git a/templates/myaccount-calendar.php b/templates/myaccount-calendar.php new file mode 100644 index 0000000..dfbdfa5 --- /dev/null +++ b/templates/myaccount-calendar.php @@ -0,0 +1,146 @@ + wp_create_nonce( 'woodoo_calendar' ), + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'i18n' => [ + 'loading' => __( 'Loading…', 'woodoo' ), + 'no_slots' => __( 'No available slots for this day.', 'woodoo' ), + 'select_slot' => __( 'Select a time slot', 'woodoo' ), + 'booking' => __( 'Booking…', 'woodoo' ), + 'book_btn' => __( 'Book this slot', 'woodoo' ), + 'cancel_confirm' => __( 'Cancel this meeting?', 'woodoo' ), + 'cancelling' => __( 'Cancelling…', 'woodoo' ), + 'cancelled' => __( 'Meeting cancelled.', 'woodoo' ), + 'error' => __( 'Something went wrong. Please try again.', 'woodoo' ), + ], +] ); +?> + +
+ + +
+

+
+ +

+ +
+ +
+
+ + +
+
+ + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+ + +
+

+

+ +

+ +
+ + +
+ + +
+ + + + + + + + + + + + +
+
+ +
diff --git a/templates/myaccount-invoices.php b/templates/myaccount-invoices.php new file mode 100644 index 0000000..e1f99c7 --- /dev/null +++ b/templates/myaccount-invoices.php @@ -0,0 +1,119 @@ + + +
+

+ + +

+ +

+ + +
+ + + + + + + + + + + + + + 'woodoo_invoice_pdf', + 'invoice_id' => $inv['id'], + 'nonce' => wp_create_nonce( 'woodoo_invoice_pdf' ), + ], admin_url( 'admin-ajax.php' ) ); + ?> + + + + + + + + + + + +
+ + + + + ' . // phpcs:ignore WordPress.Security.EscapeOutput + esc_html( date_i18n( get_option( 'date_format' ), $due_ts ) ) . + ''; + if ( $overdue ) echo ' ' . esc_html__( 'Overdue', 'woodoo' ) . ''; + } else { + echo '—'; + } + ?> + + + + + + + + + + + + +
+
+ + 1 ) : + $base_url = wc_get_account_endpoint_url( WooDoo_Invoices::ENDPOINT ); + ?> + + + + +
diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..75e6644 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,45 @@ +query( "DELETE FROM {$wpdb->usermeta} WHERE meta_key IN ( + 'woodoo_odoo_partner_id', + 'woodoo_odoo_partner_name', + 'woodoo_so_count' +)" ); + +// Remove order meta +$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ( + '_woodoo_so_id', + '_woodoo_partner_id' +)" ); + +// Drop custom table +$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}woodoo_customer_links" ); + +// Clear any transients +$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_woodoo_%'" ); +$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_woodoo_%'" ); diff --git a/woodoo.php b/woodoo.php new file mode 100644 index 0000000..0dce037 --- /dev/null +++ b/woodoo.php @@ -0,0 +1,123 @@ + + print '

' . + esc_html__( 'WooDoo requires WooCommerce to be active.', 'woodoo' ) . + '

' + ); + return; + } + + WooDoo_Admin::init(); + WooDoo_Invoices::init(); + WooDoo_Calendar::init(); + WooDoo_Orders::init(); +} + +/** + * Returns a configured WooDoo_API instance using saved options. + * Returns null if credentials are not set. + */ +function woodoo_api(): ?WooDoo_API { + static $instance = null; + if ( $instance ) return $instance; + + $opts = [ + 'url' => get_option( 'woodoo_odoo_url', '' ), + 'db' => get_option( 'woodoo_odoo_db', '' ), + 'username' => get_option( 'woodoo_odoo_username', '' ), + 'api_key' => get_option( 'woodoo_odoo_api_key', '' ), + ]; + + foreach ( $opts as $v ) { + if ( empty( $v ) ) return null; + } + + $instance = new WooDoo_API( + rtrim( $opts['url'], '/' ), + $opts['db'], + $opts['username'], + $opts['api_key'] + ); + return $instance; +} + +// ── Activation / Deactivation ───────────────────────────────────────────── +register_activation_hook( __FILE__, 'woodoo_activate' ); +function woodoo_activate(): void { + // Create DB table for customer links (supplement to user meta) + global $wpdb; + $table = $wpdb->prefix . 'woodoo_customer_links'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE IF NOT EXISTS {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + wp_user_id BIGINT UNSIGNED NOT NULL, + odoo_partner_id INT UNSIGNED NOT NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY wp_user_id (wp_user_id), + KEY odoo_partner_id (odoo_partner_id) + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + // Register endpoints and flush so My Account tabs appear immediately + WC()->query->init_query_vars(); + WC()->query->add_endpoints(); + flush_rewrite_rules(); +}