commit 68c1ff445559de069da622078dcb503fd71cd325
Author: Malin ' + esc( 'No upcoming meetings scheduled.' ) + '
+
+
+ ';
+ res.data.forEach( function ( p ) {
+ 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 = '
+
+ 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' ) .
+ '
+
+ ID, 'woodoo_so_count', true );
+ if ( $odoo_so_count ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
' . + 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 ''; + } + + 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' ), + ], +] ); +?> + ++ +
+ ++ +
+ + +| + | + | + | + | + | + | + |
|---|---|---|---|---|---|---|
| + + | ++ + | ++ ' . // phpcs:ignore WordPress.Security.EscapeOutput + esc_html( date_i18n( get_option( 'date_format' ), $due_ts ) ) . + ''; + if ( $overdue ) echo ' ' . esc_html__( 'Overdue', 'woodoo' ) . ''; + } else { + echo '—'; + } + ?> + | ++ + | ++ + | ++ + + + | ++ + + + | +
' . + esc_html__( 'WooDoo requires WooCommerce to be active.', 'woodoo' ) . + '