feat: complete fusion-woodoo WordPress plugin with portal, REST endpoints, webhooks, and admin settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-03-31 20:48:16 -04:00
parent 102fbd65f2
commit 1f86a7c497
21 changed files with 2176 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/* Fusion WooDoo — Admin Styles */
.fusion-woodoo-admin h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.fusion-woodoo-header {
margin-bottom: 20px;
color: #555;
}
.fusion-woodoo-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 20px 24px;
margin-bottom: 20px;
max-width: 900px;
}
.fusion-woodoo-card h2 {
font-size: 1.1rem;
margin-top: 0;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
/* Status badges */
.fusion-woodoo-status {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.fusion-woodoo-status--ok {
background: #e8f5e9;
color: #2e7d32;
}
.fusion-woodoo-status--error {
background: #fce4ec;
color: #c62828;
}
/* Test connection result */
.fusion-woodoo-test-result {
font-weight: 500;
font-size: 0.9rem;
}
.fusion-woodoo-test-result.success {
color: #2e7d32;
}
.fusion-woodoo-test-result.error {
color: #c62828;
}
/* Webhook table */
.fusion-woodoo-card .widefat {
border-collapse: collapse;
width: 100%;
margin-top: 12px;
}
.fusion-woodoo-card .widefat th,
.fusion-woodoo-card .widefat td {
padding: 8px 12px;
font-size: 0.875rem;
}

View File

@@ -0,0 +1,376 @@
/* Fusion WooDoo — My Account / Frontend Styles */
/* ── Portal wrapper ─────────────────────────────────────────── */
.fusion-woodoo-portal {
font-size: 0.95rem;
}
.fusion-woodoo-portal h2 {
font-size: 1.3rem;
margin-bottom: 16px;
}
.fusion-woodoo-portal h3 {
font-size: 1.1rem;
margin: 24px 0 12px;
}
/* ── Tables ─────────────────────────────────────────────────── */
.fusion-woodoo-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
.fusion-woodoo-table th,
.fusion-woodoo-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.fusion-woodoo-table thead th {
background: #f8f9fa;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.fusion-woodoo-table tbody tr:hover {
background: #fafafa;
}
/* ── Status badges ──────────────────────────────────────────── */
.fusion-woodoo-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.78rem;
font-weight: 600;
background: #e9ecef;
color: #495057;
}
.fusion-woodoo-badge--sale,
.fusion-woodoo-badge--done,
.fusion-woodoo-badge--posted,
.fusion-woodoo-badge--paid {
background: #e8f5e9;
color: #2e7d32;
}
.fusion-woodoo-badge--draft,
.fusion-woodoo-badge--pending {
background: #fff8e1;
color: #f57f17;
}
.fusion-woodoo-badge--cancel {
background: #fce4ec;
color: #c62828;
}
.fusion-woodoo-badge--processing {
background: #e3f2fd;
color: #1565c0;
}
/* ── Utility ────────────────────────────────────────────────── */
.fusion-woodoo-empty {
color: #888;
font-style: italic;
padding: 16px 0;
}
.fusion-woodoo-muted {
color: #aaa;
}
.fusion-woodoo-notice {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.fusion-woodoo-notice--success {
background: #e8f5e9;
color: #2e7d32;
border-left: 4px solid #43a047;
}
.fusion-woodoo-notice--error {
background: #fce4ec;
color: #c62828;
border-left: 4px solid #e53935;
}
.fusion-woodoo-notice--warning {
background: #fff8e1;
color: #f57f17;
border-left: 4px solid #ffb300;
}
/* ── PDF button ─────────────────────────────────────────────── */
.fusion-woodoo-btn-pdf {
font-size: 0.82rem !important;
padding: 4px 10px !important;
}
/* ── Forms ──────────────────────────────────────────────────── */
.fusion-woodoo-form {
max-width: 580px;
}
.fusion-woodoo-form-row {
margin-bottom: 18px;
}
.fusion-woodoo-form-row label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
}
.fusion-woodoo-form-row select,
.fusion-woodoo-form-row textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
background: #fff;
}
.fusion-woodoo-form-row textarea {
resize: vertical;
}
/* Item checkboxes in return form */
.fw-return-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.fw-return-item:last-child {
border-bottom: none;
}
.fw-return-item input[type="checkbox"] {
flex-shrink: 0;
}
.fw-return-item-qty {
width: 60px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
/* ── Order Timeline ─────────────────────────────────────────── */
.fusion-woodoo-timeline-wrap {
margin: 28px 0 12px;
padding: 20px 0;
border-top: 1px solid #f0f0f0;
}
.fusion-woodoo-timeline-wrap h3 {
font-size: 1rem;
margin-bottom: 20px;
color: #333;
}
.fusion-woodoo-timeline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
}
.fusion-woodoo-timeline__step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.fusion-woodoo-timeline__dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #ccc;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.2s ease;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__dot {
background: #43a047;
border-color: #43a047;
color: #fff;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__dot svg {
width: 14px;
height: 14px;
}
.fusion-woodoo-timeline__step--active .fusion-woodoo-timeline__dot {
background: #1976d2;
border-color: #1976d2;
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.15);
}
.fusion-woodoo-timeline__step--pending .fusion-woodoo-timeline__dot {
background: #f5f5f5;
border-color: #ddd;
}
.fusion-woodoo-timeline__label {
margin-top: 8px;
font-size: 0.78rem;
text-align: center;
color: #555;
max-width: 80px;
}
.fusion-woodoo-timeline__step--active .fusion-woodoo-timeline__label {
color: #1976d2;
font-weight: 600;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__label {
color: #2e7d32;
}
.fusion-woodoo-timeline__tracking {
display: block;
font-size: 0.75rem;
color: #777;
margin-top: 4px;
max-width: 100px;
word-break: break-all;
}
.fusion-woodoo-timeline__connector {
flex: 1;
height: 2px;
background: #ddd;
margin-bottom: 28px;
min-width: 20px;
}
.fusion-woodoo-timeline__connector--done {
background: #43a047;
}
/* ── Communication / Messages ───────────────────────────────── */
.fusion-woodoo-communication {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.fusion-woodoo-messages {
display: flex;
flex-direction: column;
gap: 12px;
}
.fusion-woodoo-message {
border: 1px solid #ebebeb;
border-radius: 6px;
padding: 12px 16px;
background: #fafafa;
}
.fusion-woodoo-message--email {
border-left: 4px solid #1976d2;
}
.fusion-woodoo-message--note {
border-left: 4px solid #f9a825;
background: #fffde7;
}
.fusion-woodoo-message__meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 0.82rem;
color: #666;
}
.fusion-woodoo-message__author {
font-weight: 600;
color: #333;
}
.fusion-woodoo-message__type-badge {
padding: 1px 7px;
border-radius: 10px;
background: #e3f2fd;
color: #1565c0;
font-size: 0.75rem;
font-weight: 500;
}
.fusion-woodoo-message__type-badge--note {
background: #fff8e1;
color: #f57f17;
}
.fusion-woodoo-message__body {
font-size: 0.9rem;
line-height: 1.6;
color: #444;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.fusion-woodoo-table th,
.fusion-woodoo-table td {
padding: 8px 8px;
font-size: 0.85rem;
}
.fusion-woodoo-timeline {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.fusion-woodoo-timeline__step {
flex-direction: row;
align-items: flex-start;
gap: 12px;
min-width: unset;
}
.fusion-woodoo-timeline__connector {
width: 2px;
height: 24px;
flex: none;
margin: 0 13px 0;
min-width: unset;
}
.fusion-woodoo-timeline__label {
text-align: left;
max-width: unset;
margin-top: 4px;
}
}

View File

@@ -0,0 +1,32 @@
/* Fusion WooDoo — Admin JS */
(function ($) {
'use strict';
$(function () {
const $btn = $('#fusion-woodoo-test-connection');
const $result = $('#fusion-woodoo-test-result');
$btn.on('click', function () {
$btn.prop('disabled', true).text('Testing…');
$result.hide().removeClass('success error');
$.post(fusionWooDooAdmin.ajaxUrl, {
action: 'fusion_woodoo_test_connection',
nonce: fusionWooDooAdmin.nonce,
})
.done(function (res) {
if (res.success) {
$result.addClass('success').text(res.data.message).show();
} else {
$result.addClass('error').text(res.data.message || 'Connection failed.').show();
}
})
.fail(function () {
$result.addClass('error').text('Request failed. Check your network connection.').show();
})
.always(function () {
$btn.prop('disabled', false).text('Test Connection');
});
});
});
}(jQuery));

View File

@@ -0,0 +1,134 @@
/* Fusion WooDoo — My Account JS */
(function ($) {
'use strict';
/* ── Return form ──────────────────────────────────────────── */
var ordersData = {};
try {
var raw = document.getElementById('fw-orders-data');
if (raw) ordersData = JSON.parse(raw.textContent || '{}');
} catch (e) {}
// Populate item list when order changes
$(document).on('change', '#fw-order-select', function () {
var orderId = $(this).val();
var $container = $('#fw-items-container');
var $list = $('#fw-items-list');
$list.empty();
if (!orderId || !ordersData[orderId]) {
$container.hide();
return;
}
var items = ordersData[orderId];
if (!items.length) {
$container.hide();
return;
}
items.forEach(function (item) {
var $row = $('<div class="fw-return-item"></div>');
$row.append(
$('<input type="checkbox" />').attr({
name: 'items[' + item.product_id + '][select]',
'data-product-id': item.product_id,
value: '1',
})
);
$row.append($('<span></span>').text(item.name));
$row.append(
$('<input type="number" class="fw-return-item-qty" />').attr({
name: 'items[' + item.product_id + '][qty]',
min: 1,
max: item.qty,
value: item.qty,
disabled: true,
})
);
$list.append($row);
});
$container.show();
});
// Enable/disable qty field with checkbox
$(document).on('change', '.fw-return-item input[type="checkbox"]', function () {
var $qty = $(this).closest('.fw-return-item').find('.fw-return-item-qty');
$qty.prop('disabled', !this.checked);
});
// Submit return form via AJAX
$(document).on('submit', '#fusion-woodoo-return-form', function (e) {
e.preventDefault();
var $form = $(this);
var $notice = $('#fusion-woodoo-return-notice');
var $submit = $form.find('[type=submit]');
// Collect selected items
var items = [];
$form.find('.fw-return-item input[type="checkbox"]:checked').each(function () {
var productId = $(this).data('product-id');
var qty = parseInt($(this).closest('.fw-return-item').find('.fw-return-item-qty').val(), 10) || 1;
items.push({ product_id: productId, qty: qty });
});
var payload = {
action: 'fusion_woodoo_submit_return',
nonce: fusionWooDoo.nonce,
order_id: $form.find('[name=order_id]').val(),
reason: $form.find('[name=reason]').val(),
items: items,
};
$submit.prop('disabled', true).text('Submitting…');
$notice.hide().removeClass('fusion-woodoo-notice--success fusion-woodoo-notice--error');
$.post(fusionWooDoo.ajaxUrl, payload)
.done(function (res) {
if (res.success) {
$notice.addClass('fusion-woodoo-notice--success').text(res.data.message).show();
$form[0].reset();
$('#fw-items-container').hide();
} else {
$notice.addClass('fusion-woodoo-notice--error').text(res.data.message || 'Submission failed.').show();
}
})
.fail(function () {
$notice.addClass('fusion-woodoo-notice--error').text('Request failed. Please try again.').show();
})
.always(function () {
$submit.prop('disabled', false).text('Submit Return Request');
$('html, body').animate({ scrollTop: $notice.offset().top - 40 }, 300);
});
});
/* ── Reorder button ───────────────────────────────────────── */
$(document).on('click', '.fusion-woodoo-reorder', function () {
var $btn = $(this);
var orderId = $btn.data('order-id');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('Adding…');
$.post(fusionWooDoo.ajaxUrl, {
action: 'fusion_woodoo_reorder',
nonce: nonce,
order_id: orderId,
})
.done(function (res) {
if (res.success && res.data.cart_url) {
window.location.href = res.data.cart_url;
} else {
alert(res.data.message || 'Could not reorder. Please try again.');
$btn.prop('disabled', false).text('Reorder');
}
})
.fail(function () {
alert('Request failed. Please try again.');
$btn.prop('disabled', false).text('Reorder');
});
});
}(jQuery));

View File

@@ -0,0 +1,51 @@
<?php
/**
* Plugin Name: Fusion WooDoo
* Plugin URI: https://fusionsoft.ca
* Description: Seamless Odoo integration for WooCommerce — sync products, orders, invoices, and inventory.
* Version: 1.0.0
* Author: Fusion Central
* Author URI: https://fusionsoft.ca
* Requires at least: 6.0
* Requires PHP: 8.0
* WC requires at least: 8.0
* WC tested up to: 9.0
* Text Domain: fusion-woodoo
* Domain Path: /languages
* License: GPL v2 or later
*/
if (!defined('ABSPATH')) exit;
define('FUSION_WOODOO_VERSION', '1.0.0');
define('FUSION_WOODOO_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('FUSION_WOODOO_PLUGIN_URL', plugin_dir_url(__FILE__));
add_action('plugins_loaded', function() {
if (!class_exists('WooCommerce')) {
add_action('admin_notices', function() {
echo '<div class="error"><p><strong>Fusion WooDoo</strong> requires WooCommerce to be installed and active.</p></div>';
});
return;
}
require_once FUSION_WOODOO_PLUGIN_DIR . 'includes/class-fusion-woodoo.php';
Fusion_WooDoo::instance();
});
register_activation_hook(__FILE__, function() {
$dirs = ['invoices', 'deliveries'];
foreach ($dirs as $dir) {
$path = wp_upload_dir()['basedir'] . '/fusion-woodoo/' . $dir;
wp_mkdir_p($path);
file_put_contents($path . '/.htaccess', 'deny from all');
}
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, function() {
if (file_exists(FUSION_WOODOO_PLUGIN_DIR . 'includes/class-webhooks.php')) {
require_once FUSION_WOODOO_PLUGIN_DIR . 'includes/class-webhooks.php';
Fusion_WooDoo_Webhooks::unregister_all();
}
flush_rewrite_rules();
});

View File

@@ -0,0 +1,138 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Admin_Settings {
const OPTION_ODOO_URL = 'fusion_woodoo_odoo_url';
const OPTION_API_KEY = 'fusion_woodoo_api_key';
const OPTION_SHOW_ORDERS = 'fusion_woodoo_show_orders';
const OPTION_SHOW_INVOICES = 'fusion_woodoo_show_invoices';
const OPTION_SHOW_DELIVERIES = 'fusion_woodoo_show_deliveries';
const OPTION_SHOW_RETURNS = 'fusion_woodoo_show_returns';
public function __construct() {
add_action('admin_menu', [$this, 'add_settings_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_post_fusion_woodoo_save', [$this, 'handle_save']);
add_action('wp_ajax_fusion_woodoo_test_connection', [$this, 'ajax_test_connection']);
add_action('update_option_' . self::OPTION_ODOO_URL, [$this, 'on_url_changed'], 10, 0);
}
public function add_settings_page(): void {
add_submenu_page(
'woocommerce',
__('Fusion WooDoo', 'fusion-woodoo'),
__('Fusion WooDoo', 'fusion-woodoo'),
'manage_woocommerce',
'fusion-woodoo-settings',
[$this, 'render_settings_page']
);
}
public function register_settings(): void {
$options = [
self::OPTION_ODOO_URL,
self::OPTION_API_KEY,
self::OPTION_SHOW_ORDERS,
self::OPTION_SHOW_INVOICES,
self::OPTION_SHOW_DELIVERIES,
self::OPTION_SHOW_RETURNS,
];
foreach ($options as $option) {
register_setting('fusion_woodoo_settings', $option, ['sanitize_callback' => 'sanitize_text_field']);
}
add_settings_section(
'fusion_woodoo_connection',
__('Odoo Connection', 'fusion-woodoo'),
null,
'fusion-woodoo-settings'
);
add_settings_field(
self::OPTION_ODOO_URL,
__('Odoo URL', 'fusion-woodoo'),
[$this, 'field_odoo_url'],
'fusion-woodoo-settings',
'fusion_woodoo_connection'
);
add_settings_field(
self::OPTION_API_KEY,
__('API Key', 'fusion-woodoo'),
[$this, 'field_api_key'],
'fusion-woodoo-settings',
'fusion_woodoo_connection'
);
add_settings_section(
'fusion_woodoo_portal',
__('Customer Portal Tabs', 'fusion-woodoo'),
null,
'fusion-woodoo-settings'
);
$toggles = [
self::OPTION_SHOW_ORDERS => __('Show Sales Orders tab', 'fusion-woodoo'),
self::OPTION_SHOW_INVOICES => __('Show Invoices tab', 'fusion-woodoo'),
self::OPTION_SHOW_DELIVERIES => __('Show Deliveries tab', 'fusion-woodoo'),
self::OPTION_SHOW_RETURNS => __('Show Returns tab', 'fusion-woodoo'),
];
foreach ($toggles as $option => $label) {
add_settings_field(
$option,
$label,
[$this, 'field_checkbox'],
'fusion-woodoo-settings',
'fusion_woodoo_portal',
['option' => $option]
);
}
}
public function field_odoo_url(): void {
$value = esc_attr(get_option(self::OPTION_ODOO_URL, ''));
echo '<input type="url" name="' . self::OPTION_ODOO_URL . '" value="' . $value . '" class="regular-text" placeholder="https://erp.example.com" />';
echo '<p class="description">' . __('Base URL of your Odoo instance — no trailing slash.', 'fusion-woodoo') . '</p>';
}
public function field_api_key(): void {
$value = esc_attr(get_option(self::OPTION_API_KEY, ''));
echo '<input type="password" name="' . self::OPTION_API_KEY . '" value="' . $value . '" class="regular-text" autocomplete="new-password" />';
echo '<p class="description">' . __('API key from the Fusion WooCommerce Odoo module.', 'fusion-woodoo') . '</p>';
}
public function field_checkbox(array $args): void {
$option = $args['option'];
$checked = checked(1, get_option($option, 1), false);
echo '<label><input type="checkbox" name="' . esc_attr($option) . '" value="1" ' . $checked . ' /> ' . __('Enabled', 'fusion-woodoo') . '</label>';
}
public function render_settings_page(): void {
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/admin/settings.php';
if (file_exists($template)) {
include $template;
}
}
public function on_url_changed(): void {
if (class_exists('Fusion_WooDoo_Webhooks')) {
Fusion_WooDoo_Webhooks::unregister_all();
(new Fusion_WooDoo_Webhooks())->register_webhooks();
}
}
public function ajax_test_connection(): void {
check_ajax_referer('fusion_woodoo_admin_nonce', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'fusion-woodoo')]);
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->test_connection();
if ($result['success']) {
wp_send_json_success(['message' => __('Connection successful! Odoo is reachable.', 'fusion-woodoo')]);
} else {
wp_send_json_error(['message' => $result['error'] ?: __('Connection failed.', 'fusion-woodoo')]);
}
}
}

View File

@@ -0,0 +1,91 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_API_Client {
private string $odoo_url;
private string $api_key;
public function __construct() {
$this->odoo_url = rtrim(get_option('fusion_woodoo_odoo_url', ''), '/');
$this->api_key = get_option('fusion_woodoo_api_key', '');
}
/**
* Make a POST request to an Odoo endpoint.
*
* @param string $endpoint e.g. '/woo/api/order/status'
* @param array $data
* @return array{success: bool, data: mixed, error: string}
*/
public function request(string $endpoint, array $data = []): array {
if (empty($this->odoo_url) || empty($this->api_key)) {
return ['success' => false, 'data' => null, 'error' => 'Odoo URL or API key not configured.'];
}
$url = $this->odoo_url . $endpoint;
$response = wp_remote_post($url, [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->api_key,
],
'body' => wp_json_encode($data),
'data_format' => 'body',
]);
if (is_wp_error($response)) {
return ['success' => false, 'data' => null, 'error' => $response->get_error_message()];
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code >= 200 && $code < 300) {
return ['success' => true, 'data' => $body, 'error' => ''];
}
$error_msg = $body['message'] ?? $body['error'] ?? 'Unexpected response from Odoo (HTTP ' . $code . ').';
return ['success' => false, 'data' => $body, 'error' => $error_msg];
}
/**
* Test connectivity to Odoo.
*/
public function test_connection(): array {
return $this->request('/woo/api/order/status', ['ping' => true]);
}
/**
* Get order status from Odoo.
*
* @param int|string $order_id WooCommerce order ID
*/
public function get_order_status(int|string $order_id): array {
return $this->request('/woo/api/order/status', ['order_id' => $order_id]);
}
/**
* Submit a return request to Odoo.
*
* @param int|string $order_id
* @param array $items [['product_id' => ..., 'qty' => ...], ...]
* @param string $reason
*/
public function submit_return(int|string $order_id, array $items, string $reason): array {
return $this->request('/woo/api/return/create', [
'order_id' => $order_id,
'items' => $items,
'reason' => $reason,
]);
}
/**
* Fetch existing returns for a customer.
*
* @param int|string $customer_id WooCommerce user ID
*/
public function get_returns(int|string $customer_id): array {
return $this->request('/woo/api/return/list', ['customer_id' => $customer_id]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo {
private static ?Fusion_WooDoo $instance = null;
public static function instance(): Fusion_WooDoo {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->load_includes();
$this->init_hooks();
}
private function load_includes(): void {
$includes = [
'class-api-client.php',
'class-admin-settings.php',
'class-rest-endpoints.php',
'class-webhooks.php',
'class-my-account.php',
'class-order-timeline.php',
'class-returns.php',
];
foreach ($includes as $file) {
$path = FUSION_WOODOO_PLUGIN_DIR . 'includes/' . $file;
if (file_exists($path)) {
require_once $path;
}
}
}
private function init_hooks(): void {
add_action('init', [$this, 'load_textdomain']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// Instantiate components
new Fusion_WooDoo_Admin_Settings();
new Fusion_WooDoo_REST_Endpoints();
new Fusion_WooDoo_Webhooks();
new Fusion_WooDoo_My_Account();
new Fusion_WooDoo_Order_Timeline();
new Fusion_WooDoo_Returns();
}
public function load_textdomain(): void {
load_plugin_textdomain(
'fusion-woodoo',
false,
dirname(plugin_basename(FUSION_WOODOO_PLUGIN_DIR . 'fusion-woodoo.php')) . '/languages'
);
}
public function enqueue_frontend_assets(): void {
if (is_account_page() || is_woocommerce()) {
wp_enqueue_style(
'fusion-woodoo-my-account',
FUSION_WOODOO_PLUGIN_URL . 'assets/css/my-account.css',
[],
FUSION_WOODOO_VERSION
);
wp_enqueue_script(
'fusion-woodoo-my-account',
FUSION_WOODOO_PLUGIN_URL . 'assets/js/my-account.js',
['jquery'],
FUSION_WOODOO_VERSION,
true
);
wp_localize_script('fusion-woodoo-my-account', 'fusionWooDoo', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
]);
}
}
public function enqueue_admin_assets(string $hook): void {
if (strpos($hook, 'fusion-woodoo') === false) {
return;
}
wp_enqueue_style(
'fusion-woodoo-admin',
FUSION_WOODOO_PLUGIN_URL . 'assets/css/admin.css',
[],
FUSION_WOODOO_VERSION
);
wp_enqueue_script(
'fusion-woodoo-admin',
FUSION_WOODOO_PLUGIN_URL . 'assets/js/admin.js',
['jquery'],
FUSION_WOODOO_VERSION,
true
);
wp_localize_script('fusion-woodoo-admin', 'fusionWooDooAdmin', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('fusion_woodoo_admin_nonce'),
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_My_Account {
const ENDPOINTS = [
'odoo-sales-orders' => 'Sales Orders',
'odoo-invoices' => 'Invoices',
'odoo-deliveries' => 'Deliveries',
'odoo-returns' => 'Returns',
];
const OPTION_MAP = [
'odoo-sales-orders' => 'fusion_woodoo_show_orders',
'odoo-invoices' => 'fusion_woodoo_show_invoices',
'odoo-deliveries' => 'fusion_woodoo_show_deliveries',
'odoo-returns' => 'fusion_woodoo_show_returns',
];
const TEMPLATE_MAP = [
'odoo-sales-orders' => 'sales-orders.php',
'odoo-invoices' => 'invoices.php',
'odoo-deliveries' => 'deliveries.php',
'odoo-returns' => 'returns.php',
];
public function __construct() {
add_action('init', [$this, 'add_rewrite_endpoints']);
add_filter('woocommerce_account_menu_items', [$this, 'add_menu_items']);
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
add_action('woocommerce_account_' . $endpoint . '_endpoint', [$this, 'render_endpoint_content']);
}
// AJAX handler for PDF downloads
add_action('wp_ajax_fusion_woodoo_download_pdf', [$this, 'ajax_download_pdf']);
}
public function add_rewrite_endpoints(): void {
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
add_rewrite_endpoint($endpoint, EP_ROOT | EP_PAGES);
}
}
public function add_menu_items(array $items): array {
$new_items = [];
foreach ($items as $key => $label) {
$new_items[$key] = $label;
// Insert Odoo tabs after 'orders'
if ($key === 'orders') {
foreach (self::ENDPOINTS as $endpoint => $title) {
if (get_option(self::OPTION_MAP[$endpoint], 1)) {
$new_items[$endpoint] = __($title, 'fusion-woodoo');
}
}
}
}
return $new_items;
}
/**
* Generic renderer — dispatches to the correct template.
*/
public function render_endpoint_content(): void {
// Determine which endpoint triggered this action
$current = null;
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
if (did_action('woocommerce_account_' . $endpoint . '_endpoint')) {
$current = $endpoint;
break;
}
}
if (!$current || !isset(self::TEMPLATE_MAP[$current])) {
return;
}
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/my-account/' . self::TEMPLATE_MAP[$current];
if (file_exists($template)) {
include $template;
}
}
/**
* AJAX: serve a stored PDF file to the logged-in customer who owns the order.
*/
public function ajax_download_pdf(): void {
check_ajax_referer('fusion_woodoo_nonce', 'nonce');
$order_id = (int) ($_GET['order_id'] ?? 0);
$type = sanitize_key($_GET['type'] ?? 'invoice');
if (!$order_id || !is_user_logged_in()) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
$order = wc_get_order($order_id);
if (!$order || $order->get_customer_id() !== get_current_user_id()) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
$meta_key = $type === 'delivery' ? '_odoo_delivery_pdf' : '_odoo_invoice_pdf';
$rel_path = $order->get_meta($meta_key);
if (empty($rel_path)) {
wp_die(__('PDF not found.', 'fusion-woodoo'), 404);
}
$upload = wp_upload_dir();
$file_path = $upload['basedir'] . $rel_path;
if (!file_exists($file_path) || !is_file($file_path)) {
wp_die(__('PDF file not found on server.', 'fusion-woodoo'), 404);
}
// Security: ensure path stays within uploads dir
$real_base = realpath($upload['basedir']);
$real_file = realpath($file_path);
if (!$real_file || strpos($real_file, $real_base) !== 0) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($real_file) . '"');
header('Content-Length: ' . filesize($real_file));
readfile($real_file);
exit;
}
}

View File

@@ -0,0 +1,42 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Order_Timeline {
/**
* Timeline stages in order. Key = _odoo_order_status value(s) that map to this stage.
*/
const STAGES = [
'confirmed' => 'Confirmed',
'processing' => 'Processing',
'shipped' => 'Shipped',
'delivered' => 'Delivered',
'done' => 'Completed',
];
public function __construct() {
add_action('woocommerce_order_details_after_order_table', [$this, 'render_timeline']);
}
public function render_timeline(WC_Order $order): void {
$odoo_status = strtolower((string) $order->get_meta('_odoo_order_status'));
if (empty($odoo_status)) {
return;
}
$tracking_number = $order->get_meta('_odoo_tracking_number');
$shipping_carrier = $order->get_meta('_odoo_shipping_carrier');
$stage_keys = array_keys(self::STAGES);
$current_index = array_search($odoo_status, $stage_keys);
if ($current_index === false) {
$current_index = 0;
}
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/my-account/order-timeline.php';
if (file_exists($template)) {
include $template;
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_REST_Endpoints {
public function __construct() {
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
$namespace = 'fusion-woodoo/v1';
register_rest_route($namespace, '/order/update', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_update'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'status' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'tracking_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'shipping_carrier' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
],
]);
register_rest_route($namespace, '/order/invoice', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_invoice'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'pdf_data' => ['required' => true],
'filename' => ['required' => false, 'sanitize_callback' => 'sanitize_file_name'],
],
]);
register_rest_route($namespace, '/order/delivery', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_delivery'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'pdf_data' => ['required' => true],
'filename' => ['required' => false, 'sanitize_callback' => 'sanitize_file_name'],
],
]);
register_rest_route($namespace, '/order/messages', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_messages'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'messages' => ['required' => true],
],
]);
}
/**
* Validate Bearer token from Authorization header.
*/
public function verify_api_key(WP_REST_Request $request): bool|WP_Error {
$auth_header = $request->get_header('authorization');
if (empty($auth_header) || !str_starts_with($auth_header, 'Bearer ')) {
return new WP_Error('missing_auth', __('Authorization header missing or malformed.', 'fusion-woodoo'), ['status' => 401]);
}
$provided_key = trim(substr($auth_header, 7));
$stored_key = get_option('fusion_woodoo_api_key', '');
if (empty($stored_key) || !hash_equals($stored_key, $provided_key)) {
return new WP_Error('invalid_api_key', __('Invalid API key.', 'fusion-woodoo'), ['status' => 403]);
}
return true;
}
/**
* POST /order/update — update order status and tracking info.
*/
public function handle_order_update(WP_REST_Request $request): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
if ($status = $request->get_param('status')) {
$order->update_meta_data('_odoo_order_status', sanitize_text_field($status));
}
if ($tracking = $request->get_param('tracking_number')) {
$order->update_meta_data('_odoo_tracking_number', sanitize_text_field($tracking));
}
if ($carrier = $request->get_param('shipping_carrier')) {
$order->update_meta_data('_odoo_shipping_carrier', sanitize_text_field($carrier));
}
$order->save();
return rest_ensure_response(['success' => true, 'order_id' => $order_id]);
}
/**
* POST /order/invoice — store base64-encoded invoice PDF.
*/
public function handle_order_invoice(WP_REST_Request $request): WP_REST_Response|WP_Error {
return $this->save_pdf($request, 'invoices', '_odoo_invoice_pdf');
}
/**
* POST /order/delivery — store base64-encoded delivery PDF.
*/
public function handle_order_delivery(WP_REST_Request $request): WP_REST_Response|WP_Error {
return $this->save_pdf($request, 'deliveries', '_odoo_delivery_pdf');
}
/**
* POST /order/messages — store Odoo messages array against the order.
*/
public function handle_order_messages(WP_REST_Request $request): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
$messages = $request->get_param('messages');
if (!is_array($messages)) {
return new WP_Error('invalid_messages', __('Messages must be a JSON array.', 'fusion-woodoo'), ['status' => 400]);
}
// Sanitize each message entry
$sanitized = array_map(function($msg) {
return [
'author' => sanitize_text_field($msg['author'] ?? ''),
'date' => sanitize_text_field($msg['date'] ?? ''),
'body' => wp_kses_post($msg['body'] ?? ''),
'type' => sanitize_text_field($msg['type'] ?? 'note'),
];
}, $messages);
$order->update_meta_data('_odoo_messages', $sanitized);
$order->save();
return rest_ensure_response(['success' => true, 'count' => count($sanitized)]);
}
/**
* Shared logic to decode and store a PDF file.
*/
private function save_pdf(WP_REST_Request $request, string $folder, string $meta_key): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
$pdf_data = $request->get_param('pdf_data');
$decoded = base64_decode($pdf_data, true);
if ($decoded === false) {
return new WP_Error('invalid_pdf', __('Invalid base64-encoded PDF data.', 'fusion-woodoo'), ['status' => 400]);
}
$filename = $request->get_param('filename') ?: ($folder . '-' . $order_id . '-' . time() . '.pdf');
$filename = sanitize_file_name($filename);
$upload = wp_upload_dir();
$save_dir = $upload['basedir'] . '/fusion-woodoo/' . $folder . '/';
if (!file_exists($save_dir)) {
wp_mkdir_p($save_dir);
file_put_contents($save_dir . '.htaccess', 'deny from all');
}
$file_path = $save_dir . $filename;
$bytes = file_put_contents($file_path, $decoded);
if ($bytes === false) {
return new WP_Error('file_write_error', __('Could not save PDF to disk.', 'fusion-woodoo'), ['status' => 500]);
}
$relative = str_replace($upload['basedir'], '', $file_path);
$order->update_meta_data($meta_key, $relative);
$order->save();
return rest_ensure_response(['success' => true, 'path' => $relative]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Returns {
public function __construct() {
add_action('wp_ajax_fusion_woodoo_submit_return', [$this, 'ajax_submit_return']);
}
/**
* Get the last N orders for the current customer.
*
* @param int $limit
* @return WC_Order[]
*/
public function get_customer_orders(int $limit = 20): array {
if (!is_user_logged_in()) {
return [];
}
return wc_get_orders([
'customer' => get_current_user_id(),
'limit' => $limit,
'status' => ['completed', 'processing', 'on-hold'],
'orderby' => 'date',
'order' => 'DESC',
]);
}
/**
* Fetch existing returns for the current customer from Odoo.
*
* @return array
*/
public function get_existing_returns(): array {
if (!is_user_logged_in()) {
return [];
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->get_returns(get_current_user_id());
return $result['success'] ? ($result['data']['returns'] ?? []) : [];
}
/**
* AJAX handler for return form submission.
*/
public function ajax_submit_return(): void {
check_ajax_referer('fusion_woodoo_nonce', 'nonce');
if (!is_user_logged_in()) {
wp_send_json_error(['message' => __('You must be logged in to submit a return.', 'fusion-woodoo')]);
}
$order_id = (int) ($_POST['order_id'] ?? 0);
$reason = sanitize_textarea_field($_POST['reason'] ?? '');
$items = $_POST['items'] ?? [];
if (!$order_id) {
wp_send_json_error(['message' => __('Please select an order.', 'fusion-woodoo')]);
}
if (empty($items) || !is_array($items)) {
wp_send_json_error(['message' => __('Please select at least one item to return.', 'fusion-woodoo')]);
}
if (empty($reason)) {
wp_send_json_error(['message' => __('Please provide a reason for the return.', 'fusion-woodoo')]);
}
// Verify the order belongs to the current user
$order = wc_get_order($order_id);
if (!$order || $order->get_customer_id() !== get_current_user_id()) {
wp_send_json_error(['message' => __('Order not found or access denied.', 'fusion-woodoo')]);
}
// Sanitize and validate items
$sanitized_items = [];
foreach ($items as $item) {
$product_id = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['qty'] ?? 1));
if ($product_id > 0) {
$sanitized_items[] = ['product_id' => $product_id, 'qty' => $qty];
}
}
if (empty($sanitized_items)) {
wp_send_json_error(['message' => __('No valid items selected for return.', 'fusion-woodoo')]);
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->submit_return($order_id, $sanitized_items, $reason);
if ($result['success']) {
wp_send_json_success([
'message' => __('Return request submitted successfully. Our team will review it shortly.', 'fusion-woodoo'),
'return_id' => $result['data']['return_id'] ?? null,
]);
} else {
wp_send_json_error([
'message' => $result['error'] ?: __('Failed to submit return. Please try again or contact support.', 'fusion-woodoo'),
]);
}
}
}

View File

@@ -0,0 +1,106 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Webhooks {
const WEBHOOK_PREFIX = 'fusion-woodoo-';
public function __construct() {
add_action('woocommerce_settings_saved', [$this, 'maybe_register']);
}
/**
* Register all required WooCommerce webhooks pointing at the configured Odoo URL.
*/
public function register_webhooks(): void {
$odoo_url = rtrim(get_option('fusion_woodoo_odoo_url', ''), '/');
if (empty($odoo_url)) {
return;
}
$webhooks = [
[
'name' => self::WEBHOOK_PREFIX . 'order-created',
'topic' => 'order.created',
'api_url' => $odoo_url . '/woo/webhook/order',
],
[
'name' => self::WEBHOOK_PREFIX . 'order-updated',
'topic' => 'order.updated',
'api_url' => $odoo_url . '/woo/webhook/order',
],
[
'name' => self::WEBHOOK_PREFIX . 'product-updated',
'topic' => 'product.updated',
'api_url' => $odoo_url . '/woo/webhook/product',
],
[
'name' => self::WEBHOOK_PREFIX . 'customer-created',
'topic' => 'customer.created',
'api_url' => $odoo_url . '/woo/webhook/customer',
],
[
'name' => self::WEBHOOK_PREFIX . 'customer-updated',
'topic' => 'customer.updated',
'api_url' => $odoo_url . '/woo/webhook/customer',
],
];
// Remove stale webhooks first to avoid duplicates
self::unregister_all();
foreach ($webhooks as $wh_data) {
$webhook = new WC_Webhook();
$webhook->set_name($wh_data['name']);
$webhook->set_topic($wh_data['topic']);
$webhook->set_delivery_url($wh_data['api_url']);
$webhook->set_secret(get_option('fusion_woodoo_api_key', wp_generate_password(32)));
$webhook->set_status('active');
$webhook->save();
}
}
/**
* Delete all fusion-woodoo webhooks from WooCommerce.
*/
public static function unregister_all(): void {
$data_store = WC_Data_Store::load('webhook');
$all_ids = $data_store->get_webhooks_ids('active');
foreach ($all_ids as $id) {
$webhook = wc_get_webhook($id);
if ($webhook && str_starts_with($webhook->get_name(), self::WEBHOOK_PREFIX)) {
$webhook->delete(true);
}
}
}
/**
* Ping the Odoo URL and return whether it is reachable.
*/
public function check_stale(): bool {
$odoo_url = get_option('fusion_woodoo_odoo_url', '');
if (empty($odoo_url)) {
return false;
}
$response = wp_remote_get(rtrim($odoo_url, '/') . '/web/health', [
'timeout' => 10,
'sslverify' => apply_filters('fusion_woodoo_sslverify', true),
]);
return !is_wp_error($response) && wp_remote_retrieve_response_code($response) < 500;
}
/**
* Re-register webhooks when Odoo URL changes (triggered from settings).
*/
public function maybe_register(): void {
if (
isset($_POST[Fusion_WooDoo_Admin_Settings::OPTION_ODOO_URL]) &&
!empty($_POST[Fusion_WooDoo_Admin_Settings::OPTION_ODOO_URL])
) {
$this->register_webhooks();
}
}
}

View File

@@ -0,0 +1,91 @@
=== Fusion WooDoo ===
Contributors: fusioncentral
Tags: woocommerce, odoo, integration, erp, orders, invoices, inventory, sync
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.0
Stable tag: 1.0.0
WC requires at least: 8.0
WC tested up to: 9.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Seamless Odoo 19 integration for WooCommerce — sync orders, invoices, deliveries, and inventory in real time.
== Description ==
Fusion WooDoo bridges your WooCommerce store with an Odoo 19 instance running the `fusion_woocommerce` module. It provides:
* **Real-time order sync** via WooCommerce webhooks → Odoo
* **Status & tracking push-back** from Odoo → WooCommerce order meta
* **Customer portal tabs** in My Account: Sales Orders, Invoices, Deliveries, Returns
* **Visual order timeline** on the order detail page (Confirmed → Processing → Shipped → Delivered → Completed)
* **Invoice & delivery PDF storage** — Odoo pushes base64-encoded PDFs; customers download securely
* **Return requests** — customers submit returns from their account; routed to Odoo automatically
* **Odoo message thread** display on order detail pages
* **Admin settings page** under WooCommerce with one-click connection test and webhook status dashboard
== Requirements ==
* WordPress 6.0 or higher
* WooCommerce 8.0 or higher
* PHP 8.0 or higher
* Odoo 19 with the `fusion_woocommerce` Odoo module installed and configured
== Installation ==
1. Upload the `fusion-woodoo` folder to `/wp-content/plugins/`.
2. Activate the plugin through the **Plugins** screen in WordPress.
3. Go to **WooCommerce → Fusion WooDoo** in the WordPress admin.
4. Enter your Odoo base URL (e.g. `https://erp.example.com`) and the API key generated in the Odoo `fusion_woocommerce` module settings.
5. Save — webhooks will be registered automatically.
6. Click **Test Connection** to verify Odoo is reachable.
== Frequently Asked Questions ==
= Where do I get the API key? =
In Odoo, open the `fusion_woocommerce` module settings and copy the generated API key. Paste it into **WooCommerce → Fusion WooDoo → API Key**.
= Which WooCommerce webhooks does the plugin register? =
Five webhooks: `order.created`, `order.updated`, `product.updated`, `customer.created`, and `customer.updated`. All point to your Odoo instance. They are removed automatically on plugin deactivation.
= Where are invoice and delivery PDFs stored? =
Under `wp-content/uploads/fusion-woodoo/invoices/` and `.../deliveries/`. Both directories are protected by `.htaccess` (deny from all). PDFs are served only to the order owner via a signed AJAX request.
= Can customers reorder from the Sales Orders tab? =
Yes — the Reorder button adds all items from a previous order to the WooCommerce cart.
= How do I hide portal tabs I don't need? =
Go to **WooCommerce → Fusion WooDoo** and uncheck the portal tab toggles you want to hide.
== Screenshots ==
1. Admin settings page — connection details and webhook status.
2. My Account — Sales Orders tab.
3. My Account — Invoices tab with PDF download.
4. My Account — Deliveries tab with tracking links.
5. My Account — Returns tab with request form.
6. Order detail page — visual timeline.
== Changelog ==
= 1.0.0 =
* Initial release.
* Admin settings with connection test and webhook status.
* WP REST API endpoints: order/update, order/invoice, order/delivery, order/messages.
* WooCommerce webhook registration for orders, products, and customers.
* My Account portal tabs: Sales Orders, Invoices, Deliveries, Returns.
* Visual order timeline on order detail pages.
* Secure PDF download handler.
* Return request form with Odoo submission.
* Odoo message thread display.
== Upgrade Notice ==
= 1.0.0 =
Initial release — no upgrade steps required.

View File

@@ -0,0 +1,85 @@
<?php
if (!defined('ABSPATH')) exit;
$webhooks_active = class_exists('Fusion_WooDoo_Webhooks') ? (new Fusion_WooDoo_Webhooks())->check_stale() : false;
?>
<div class="wrap fusion-woodoo-admin">
<h1><?php esc_html_e('Fusion WooDoo Settings', 'fusion-woodoo'); ?></h1>
<div class="fusion-woodoo-header">
<p><?php esc_html_e('Configure your Odoo connection and customer portal settings below.', 'fusion-woodoo'); ?></p>
</div>
<div class="fusion-woodoo-card">
<form method="post" action="options.php">
<?php
settings_fields('fusion_woodoo_settings');
do_settings_sections('fusion-woodoo-settings');
submit_button(__('Save Settings', 'fusion-woodoo'));
?>
</form>
</div>
<div class="fusion-woodoo-card">
<h2><?php esc_html_e('Connection Status', 'fusion-woodoo'); ?></h2>
<p>
<strong><?php esc_html_e('Odoo URL:', 'fusion-woodoo'); ?></strong>
<?php echo esc_html(get_option('fusion_woodoo_odoo_url', __('Not configured', 'fusion-woodoo'))); ?>
</p>
<p>
<strong><?php esc_html_e('Odoo Reachable:', 'fusion-woodoo'); ?></strong>
<?php if ($webhooks_active): ?>
<span class="fusion-woodoo-status fusion-woodoo-status--ok"><?php esc_html_e('Yes', 'fusion-woodoo'); ?></span>
<?php else: ?>
<span class="fusion-woodoo-status fusion-woodoo-status--error"><?php esc_html_e('Not confirmed', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</p>
<button type="button" id="fusion-woodoo-test-connection" class="button button-secondary">
<?php esc_html_e('Test Connection', 'fusion-woodoo'); ?>
</button>
<span id="fusion-woodoo-test-result" class="fusion-woodoo-test-result" style="display:none;margin-left:12px;"></span>
</div>
<div class="fusion-woodoo-card">
<h2><?php esc_html_e('Webhook Status', 'fusion-woodoo'); ?></h2>
<p><?php esc_html_e('Webhooks push real-time order, product, and customer updates from WooCommerce to Odoo.', 'fusion-woodoo'); ?></p>
<?php
$data_store = WC_Data_Store::load('webhook');
$all_ids = $data_store->get_webhooks_ids('active');
$wh_list = [];
foreach ($all_ids as $id) {
$wh = wc_get_webhook($id);
if ($wh && str_starts_with($wh->get_name(), 'fusion-woodoo-')) {
$wh_list[] = $wh;
}
}
?>
<?php if (empty($wh_list)): ?>
<p><em><?php esc_html_e('No Fusion WooDoo webhooks registered. Save a valid Odoo URL to register them automatically.', 'fusion-woodoo'); ?></em></p>
<?php else: ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Name', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Topic', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Delivery URL', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($wh_list as $wh): ?>
<tr>
<td><?php echo esc_html($wh->get_name()); ?></td>
<td><?php echo esc_html($wh->get_topic()); ?></td>
<td><?php echo esc_html($wh->get_delivery_url()); ?></td>
<td>
<span class="fusion-woodoo-status fusion-woodoo-status--<?php echo $wh->get_status() === 'active' ? 'ok' : 'error'; ?>">
<?php echo esc_html(ucfirst($wh->get_status())); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Renders the Odoo message thread for a given order.
* Include from the order details page — $order must be in scope.
*
* @var WC_Order $order
*/
$messages = $order->get_meta('_odoo_messages');
if (empty($messages) || !is_array($messages)) {
return;
}
?>
<div class="fusion-woodoo-communication">
<h3><?php esc_html_e('Messages', 'fusion-woodoo'); ?></h3>
<div class="fusion-woodoo-messages">
<?php foreach ($messages as $msg):
$type = sanitize_key($msg['type'] ?? 'note');
?>
<div class="fusion-woodoo-message fusion-woodoo-message--<?php echo esc_attr($type); ?>">
<div class="fusion-woodoo-message__meta">
<span class="fusion-woodoo-message__author"><?php echo esc_html($msg['author'] ?? __('Odoo', 'fusion-woodoo')); ?></span>
<span class="fusion-woodoo-message__date"><?php echo esc_html($msg['date'] ?? ''); ?></span>
<?php if ($type === 'email'): ?>
<span class="fusion-woodoo-message__type-badge"><?php esc_html_e('Email', 'fusion-woodoo'); ?></span>
<?php elseif ($type === 'note'): ?>
<span class="fusion-woodoo-message__type-badge fusion-woodoo-message__type-badge--note"><?php esc_html_e('Note', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</div>
<div class="fusion-woodoo-message__body">
<?php echo wp_kses_post($msg['body'] ?? ''); ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>

View File

@@ -0,0 +1,105 @@
<?php
if (!defined('ABSPATH')) exit;
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/delivery/list', ['customer_id' => get_current_user_id()]);
$deliveries = $result['success'] ? ($result['data']['deliveries'] ?? []) : [];
// Also pull locally stored delivery PDFs
$wc_orders = wc_get_orders([
'customer' => get_current_user_id(),
'limit' => 50,
'meta_key' => '_odoo_delivery_pdf',
]);
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Deliveries', 'fusion-woodoo'); ?></h2>
<?php if (empty($deliveries) && empty($wc_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No deliveries found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Reference', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Scheduled Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Carrier', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Tracking', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('PDF', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($deliveries as $del): ?>
<tr>
<td><?php echo esc_html($del['name'] ?? '—'); ?></td>
<td><?php echo esc_html($del['wc_order_id'] ? '#' . $del['wc_order_id'] : '—'); ?></td>
<td><?php echo esc_html($del['scheduled_date'] ?? '—'); ?></td>
<td><?php echo esc_html($del['carrier'] ?? '—'); ?></td>
<td>
<?php if (!empty($del['tracking_number'])): ?>
<?php if (!empty($del['tracking_url'])): ?>
<a href="<?php echo esc_url($del['tracking_url']); ?>" target="_blank" rel="noopener">
<?php echo esc_html($del['tracking_number']); ?>
</a>
<?php else: ?>
<?php echo esc_html($del['tracking_number']); ?>
<?php endif; ?>
<?php else: ?>
<span class="fusion-woodoo-muted"><?php esc_html_e('N/A', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($del['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($del['state'] ?? '—')); ?>
</span>
</td>
<td>
<?php if (!empty($del['wc_order_id'])): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'delivery',
'order_id' => (int) $del['wc_order_id'],
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($wc_orders as $order):
$tracking_number = $order->get_meta('_odoo_tracking_number');
$shipping_carrier = $order->get_meta('_odoo_shipping_carrier');
?>
<tr>
<td><?php esc_html_e('Delivery', 'fusion-woodoo'); ?></td>
<td><?php echo esc_html('#' . $order->get_order_number()); ?></td>
<td><?php echo esc_html($order->get_date_completed() ? $order->get_date_completed()->date_i18n('Y-m-d') : '—'); ?></td>
<td><?php echo esc_html($shipping_carrier ?: '—'); ?></td>
<td><?php echo esc_html($tracking_number ?: '—'); ?></td>
<td><span class="fusion-woodoo-badge"><?php echo esc_html(ucfirst($order->get_status())); ?></span></td>
<td>
<?php if (!empty($order->get_meta('_odoo_delivery_pdf'))): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'delivery',
'order_id' => $order->get_id(),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,88 @@
<?php
if (!defined('ABSPATH')) exit;
// Load invoices from Odoo
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/invoice/list', ['customer_id' => get_current_user_id()]);
$invoices = $result['success'] ? ($result['data']['invoices'] ?? []) : [];
// Also pull locally stored invoices from WC orders
$wc_orders = wc_get_orders([
'customer' => get_current_user_id(),
'limit' => 50,
'meta_key' => '_odoo_invoice_pdf',
]);
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Invoices', 'fusion-woodoo'); ?></h2>
<?php if (empty($invoices) && empty($wc_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No invoices found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Invoice #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Amount Due', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('PDF', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $inv): ?>
<tr>
<td><?php echo esc_html($inv['name'] ?? '—'); ?></td>
<td><?php echo esc_html($inv['wc_order_id'] ?? '—'); ?></td>
<td><?php echo esc_html($inv['invoice_date'] ?? '—'); ?></td>
<td><?php echo '$' . number_format((float) ($inv['amount_due'] ?? 0), 2); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($inv['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($inv['state'] ?? '—')); ?>
</span>
</td>
<td>
<?php if (!empty($inv['wc_order_id'])): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'invoice',
'order_id' => (int) $inv['wc_order_id'],
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted"><?php esc_html_e('Unavailable', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($wc_orders as $order):
if (in_array($order->get_id(), array_column($invoices, 'wc_order_id'))) continue;
$has_pdf = !empty($order->get_meta('_odoo_invoice_pdf'));
if (!$has_pdf) continue;
?>
<tr>
<td><?php esc_html_e('Invoice', 'fusion-woodoo'); ?></td>
<td><?php echo esc_html('#' . $order->get_order_number()); ?></td>
<td><?php echo esc_html($order->get_date_created() ? $order->get_date_created()->date_i18n('Y-m-d') : '—'); ?></td>
<td><?php echo '$' . number_format((float) $order->get_total(), 2); ?></td>
<td><span class="fusion-woodoo-badge"><?php echo esc_html(ucfirst($order->get_status())); ?></span></td>
<td>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'invoice',
'order_id' => $order->get_id(),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,49 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Variables available from class-order-timeline.php:
* @var WC_Order $order
* @var string $odoo_status
* @var string $tracking_number
* @var string $shipping_carrier
* @var int $current_index
* @var array $stage_keys (from Fusion_WooDoo_Order_Timeline::STAGES)
*/
$stages = Fusion_WooDoo_Order_Timeline::STAGES;
?>
<div class="fusion-woodoo-timeline-wrap">
<h3><?php esc_html_e('Order Status', 'fusion-woodoo'); ?></h3>
<div class="fusion-woodoo-timeline">
<?php foreach ($stages as $key => $label):
$idx = array_search($key, array_keys($stages));
$is_done = $idx < $current_index;
$is_active = $idx === $current_index;
$state_class = $is_done ? 'done' : ($is_active ? 'active' : 'pending');
?>
<div class="fusion-woodoo-timeline__step fusion-woodoo-timeline__step--<?php echo esc_attr($state_class); ?>">
<div class="fusion-woodoo-timeline__dot">
<?php if ($is_done): ?>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<?php endif; ?>
</div>
<div class="fusion-woodoo-timeline__label">
<?php echo esc_html(__($label, 'fusion-woodoo')); ?>
<?php if ($is_active && $key === 'shipped' && ($tracking_number || $shipping_carrier)): ?>
<span class="fusion-woodoo-timeline__tracking">
<?php if ($shipping_carrier): ?>
<?php echo esc_html($shipping_carrier); ?>
<?php endif; ?>
<?php if ($tracking_number): ?>
— <?php esc_html_e('Tracking:', 'fusion-woodoo'); ?>
<strong><?php echo esc_html($tracking_number); ?></strong>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
</div>
<?php if ($idx < count($stages) - 1): ?>
<div class="fusion-woodoo-timeline__connector fusion-woodoo-timeline__connector--<?php echo $is_done ? 'done' : 'pending'; ?>"></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>

View File

@@ -0,0 +1,99 @@
<?php
if (!defined('ABSPATH')) exit;
$returns_handler = new Fusion_WooDoo_Returns();
$customer_orders = $returns_handler->get_customer_orders();
$existing = $returns_handler->get_existing_returns();
?>
<div class="fusion-woodoo-portal fusion-woodoo-returns">
<h2><?php esc_html_e('Returns', 'fusion-woodoo'); ?></h2>
<?php if (!empty($existing)): ?>
<h3><?php esc_html_e('Your Return Requests', 'fusion-woodoo'); ?></h3>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Return #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Reason', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($existing as $ret): ?>
<tr>
<td><?php echo esc_html($ret['name'] ?? '—'); ?></td>
<td><?php echo esc_html($ret['wc_order_id'] ? '#' . $ret['wc_order_id'] : '—'); ?></td>
<td><?php echo esc_html($ret['date'] ?? '—'); ?></td>
<td><?php echo esc_html($ret['reason'] ?? '—'); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($ret['state'] ?? 'pending'); ?>">
<?php echo esc_html(ucfirst($ret['state'] ?? 'Pending')); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h3><?php esc_html_e('Submit a New Return', 'fusion-woodoo'); ?></h3>
<?php if (empty($customer_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No eligible orders found for return.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<div id="fusion-woodoo-return-notice" style="display:none;" class="fusion-woodoo-notice"></div>
<form id="fusion-woodoo-return-form" class="fusion-woodoo-form">
<?php wp_nonce_field('fusion_woodoo_nonce', 'nonce'); ?>
<div class="fusion-woodoo-form-row">
<label for="fw-order-select"><?php esc_html_e('Select Order', 'fusion-woodoo'); ?></label>
<select id="fw-order-select" name="order_id" required>
<option value=""><?php esc_html_e('— Choose an order —', 'fusion-woodoo'); ?></option>
<?php foreach ($customer_orders as $order): ?>
<option value="<?php echo esc_attr($order->get_id()); ?>">
#<?php echo esc_html($order->get_order_number()); ?>
— <?php echo esc_html($order->get_date_created() ? $order->get_date_created()->date_i18n('Y-m-d') : ''); ?>
(<?php echo '$' . number_format((float) $order->get_total(), 2); ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div id="fw-items-container" class="fusion-woodoo-form-row" style="display:none;">
<label><?php esc_html_e('Select Items to Return', 'fusion-woodoo'); ?></label>
<div id="fw-items-list"></div>
</div>
<div class="fusion-woodoo-form-row">
<label for="fw-return-reason"><?php esc_html_e('Reason for Return', 'fusion-woodoo'); ?></label>
<textarea id="fw-return-reason" name="reason" rows="4" required placeholder="<?php esc_attr_e('Please describe why you are returning these items.', 'fusion-woodoo'); ?>"></textarea>
</div>
<button type="submit" class="button button-primary">
<?php esc_html_e('Submit Return Request', 'fusion-woodoo'); ?>
</button>
</form>
<script type="application/json" id="fw-orders-data">
<?php
$orders_data = [];
foreach ($customer_orders as $order) {
$items = [];
foreach ($order->get_items() as $item) {
$items[] = [
'product_id' => $item->get_product_id(),
'variation_id' => $item->get_variation_id(),
'name' => $item->get_name(),
'qty' => $item->get_quantity(),
];
}
$orders_data[$order->get_id()] = $items;
}
echo wp_json_encode($orders_data);
?>
</script>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,56 @@
<?php
if (!defined('ABSPATH')) exit;
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/sales/list', ['customer_id' => get_current_user_id()]);
$orders = $result['success'] ? ($result['data']['orders'] ?? []) : [];
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Sales Orders', 'fusion-woodoo'); ?></h2>
<?php if (empty($orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No sales orders found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table woocommerce-orders-table">
<thead>
<tr>
<th><?php esc_html_e('Order #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Total', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Actions', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $order): ?>
<tr>
<td><?php echo esc_html($order['name'] ?? '—'); ?></td>
<td><?php echo esc_html($order['date_order'] ?? '—'); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($order['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($order['state'] ?? '—')); ?>
</span>
</td>
<td><?php echo '$' . number_format((float) ($order['amount_total'] ?? 0), 2); ?></td>
<td>
<?php if (!empty($order['wc_order_id'])): ?>
<button
class="button fusion-woodoo-reorder"
data-order-id="<?php echo esc_attr($order['wc_order_id']); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('fusion_woodoo_nonce')); ?>">
<?php esc_html_e('Reorder', 'fusion-woodoo'); ?>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (!$result['success']): ?>
<div class="fusion-woodoo-notice fusion-woodoo-notice--warning">
<p><?php esc_html_e('Could not load sales orders from Odoo at this time. Please try again later.', 'fusion-woodoo'); ?></p>
</div>
<?php endif; ?>
</div>