From 2865956c562ed74f5414b071ade5a8a95bbdf0f0 Mon Sep 17 00:00:00 2001 From: magdev Date: Tue, 3 Feb 2026 22:40:36 +0100 Subject: [PATCH] Add WooCommerce integration for payments, invoices, and order management (v0.11.0) - Product sync: Virtual WC products for rooms with bidirectional linking - Cart/Checkout: Booking data in cart items, availability validation, dynamic pricing - Orders: Automatic booking creation on payment, status mapping, guest record creation - Invoices: PDF generation via mPDF, auto-attach to emails, configurable numbering - Refunds: Full refund cancels booking, partial refund records amount only - Admin: Cross-linked columns and row actions between bookings and orders - Settings: WooCommerce tab with subtabs (General, Products, Orders, Invoices) - HPOS compatibility declared for High-Performance Order Storage Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 78 +++ PLAN.md | 12 +- assets/css/admin.css | 26 +- assets/css/wc-integration.css | 443 ++++++++++++ assets/js/wc-integration.js | 358 ++++++++++ src/Integration/WooCommerce/AdminColumns.php | 282 ++++++++ src/Integration/WooCommerce/CartHandler.php | 545 +++++++++++++++ .../WooCommerce/CheckoutHandler.php | 347 ++++++++++ .../WooCommerce/InvoiceGenerator.php | 633 ++++++++++++++++++ src/Integration/WooCommerce/Manager.php | 435 ++++++++++++ src/Integration/WooCommerce/OrderHandler.php | 584 ++++++++++++++++ src/Integration/WooCommerce/ProductSync.php | 515 ++++++++++++++ src/Integration/WooCommerce/RefundHandler.php | 394 +++++++++++ src/Plugin.php | 389 +++++++++++ wp-bnb.php | 4 +- 15 files changed, 5036 insertions(+), 9 deletions(-) create mode 100644 assets/css/wc-integration.css create mode 100644 assets/js/wc-integration.js create mode 100644 src/Integration/WooCommerce/AdminColumns.php create mode 100644 src/Integration/WooCommerce/CartHandler.php create mode 100644 src/Integration/WooCommerce/CheckoutHandler.php create mode 100644 src/Integration/WooCommerce/InvoiceGenerator.php create mode 100644 src/Integration/WooCommerce/Manager.php create mode 100644 src/Integration/WooCommerce/OrderHandler.php create mode 100644 src/Integration/WooCommerce/ProductSync.php create mode 100644 src/Integration/WooCommerce/RefundHandler.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c4ebd..c1d72d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 2026-02-03 + +### Added + +- WooCommerce Integration System: + - New `src/Integration/WooCommerce/` directory with complete integration + - `Manager.php` - Core integration manager with HPOS compatibility declaration + - `ProductSync.php` - Room-to-WooCommerce-product synchronization + - `CartHandler.php` - Cart item data, availability validation, dynamic pricing + - `CheckoutHandler.php` - Checkout field customization and pre-fill + - `OrderHandler.php` - Booking creation on payment completion + - `InvoiceGenerator.php` - PDF invoice generation using mPDF + - `RefundHandler.php` - Booking cancellation on full refund + - `AdminColumns.php` - Admin list cross-links between bookings and orders +- Product Synchronization: + - Virtual WooCommerce products created for rooms (SKU: `bnb-room-{id}`) + - Auto-sync on room save, delete on room deletion + - Manual "Sync All Rooms" button in settings + - Bidirectional meta linking (room ↔ product) +- Cart & Checkout: + - Booking data stored in cart items (room, dates, guests, services) + - Availability validation before add-to-cart and at checkout + - Dynamic price calculation based on dates and services + - Cart item display shows booking details (dates, guests, nights) + - Special requests and arrival time fields at checkout + - Booking summary display in checkout and order received page +- Order & Booking Integration: + - Automatic booking creation on `woocommerce_payment_complete` + - Guest record creation from order billing info + - Bidirectional order-booking links via meta keys + - Status synchronization (WC status → Booking status mapping) + - Booking reference generation (BNB-YYYY-NNNNN) +- Invoice Generation: + - PDF invoices using existing mPDF dependency + - Invoice numbering with configurable prefix and start number + - Auto-attach invoices to WooCommerce order emails + - Download invoice button in admin order actions + - Secure storage in `wp-content/uploads/wp-bnb-invoices/` +- Refund Handling: + - Full refund triggers booking cancellation + - Partial refund stores amount in booking meta without cancellation + - Refund info displayed in booking admin + - `wp_bnb_wc_should_cancel_on_refund` filter for customization +- Admin Enhancements: + - "WC Order" column in bookings list with order link and status + - "Booking" column in WC orders list with dates and status + - Row actions for cross-navigation between bookings and orders + - HPOS (High-Performance Order Storage) support +- WooCommerce Settings Tab with Subtabs: + - General: Enable integration, auto-confirm on payment, WC status indicator + - Products: Auto-sync toggle, product category selection, sync button + - Orders: Status mapping reference table + - Invoices: Auto-attach, prefix, starting number, logo, footer text +- Frontend Assets: + - `assets/css/wc-integration.css` - Cart, checkout, and booking form styles + - `assets/js/wc-integration.js` - Booking form handler, AJAX operations + +### Changed + +- Plugin.php updated to initialize WooCommerce integration when WC is active +- Settings page now has eight tabs: General, Pricing, License, Updates, Metrics, API, WooCommerce +- HPOS compatibility declared via `FeaturesUtil::declare_compatibility()` + +### Security + +- Invoice storage protected with .htaccess (deny all) +- Nonce verification on all AJAX operations +- Capability checks for admin actions +- HPOS-compatible meta access using `$order->get_meta()` / `$order->update_meta_data()` + ## [0.10.1] - 2026-02-03 ### Added @@ -634,6 +704,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input sanitization and output escaping - Server secret masking in license settings +[0.11.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.11.0 +[0.10.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.10.1 +[0.10.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.10.0 +[0.9.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.9.0 +[0.8.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.8.0 +[0.7.2]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.2 +[0.7.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.1 +[0.7.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.7.0 [0.6.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.6.1 [0.6.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.6.0 [0.5.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.5.0 diff --git a/PLAN.md b/PLAN.md index 58bf494..07424dd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,12 +204,12 @@ This document outlines the implementation plan for the WP BnB Management plugin. - [x] Transient-based rate limiting with tiered limits - [x] API settings tab with enable/disable toggles -### Phase 11: WooCommerce Integration (v0.11.0) +### Phase 11: WooCommerce Integration (v0.11.0) - Complete -- [ ] Payment processing -- [ ] Invoice generation -- [ ] Order management -- [ ] Refund handling +- [x] Payment processing +- [x] Invoice generation +- [x] Order management +- [x] Refund handling ## Phase 12: Security Audit (v0.12.0) @@ -359,6 +359,6 @@ The plugin will provide extensive hooks for customization: | 0.8.0 | Dashboard | Complete | | 0.9.0 | Prometheus Metrics | Complete | | 0.10.0 | API Endpoints | Complete | -| 0.11.0 | WooCommerce Integration | TBD | +| 0.11.0 | WooCommerce Integration | Complete | | 0.12.0 | Security Audit | TBD | | 1.0.0 | Stable Release | TBD | diff --git a/assets/css/admin.css b/assets/css/admin.css index 49c9829..a2593f5 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -598,12 +598,36 @@ display: inline-block; padding: 3px 8px; border-radius: 3px; - color: #fff; font-size: 12px; font-weight: 600; text-transform: uppercase; } +.bnb-status-pending { + background: #fff8e5; + color: #9d6a00; +} + +.bnb-status-confirmed { + background: #e6f4ea; + color: #0a6e31; +} + +.bnb-status-checked_in { + background: #e3f2fd; + color: #1565c0; +} + +.bnb-status-checked_out { + background: #f5f5f5; + color: #616161; +} + +.bnb-status-cancelled { + background: #ffeaea; + color: #d63638; +} + /* Room Details Meta Box */ #bnb_room_details .form-table td label { display: inline-block; diff --git a/assets/css/wc-integration.css b/assets/css/wc-integration.css new file mode 100644 index 0000000..0cbcac7 --- /dev/null +++ b/assets/css/wc-integration.css @@ -0,0 +1,443 @@ +/** + * WooCommerce Integration Styles + * + * Styles for WP BnB - WooCommerce integration + * + * @package Magdev\WpBnb + */ + +/* ========================================================================== + Cart Item - Booking Data Display + ========================================================================== */ + +.woocommerce-cart .cart_item .bnb-booking-info { + margin-top: 8px; + padding: 10px; + background: #f9f9f9; + border-radius: 4px; + font-size: 0.9em; +} + +.woocommerce-cart .cart_item .bnb-booking-info dt { + display: inline-block; + font-weight: 600; + min-width: 80px; + color: #50575e; +} + +.woocommerce-cart .cart_item .bnb-booking-info dd { + display: inline-block; + margin: 0 0 4px 0; +} + +/* ========================================================================== + Checkout - Booking Summary + ========================================================================== */ + +.bnb-checkout-booking-summary { + margin: 20px 0; + padding: 15px; + background: #f8f9fa; + border: 1px solid #e1e4e8; + border-radius: 4px; +} + +.bnb-checkout-booking-summary h3 { + margin: 0 0 15px 0; + padding: 0 0 10px 0; + border-bottom: 1px solid #e1e4e8; + font-size: 1.1em; + color: #2271b1; +} + +.bnb-booking-item { + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px dashed #e1e4e8; +} + +.bnb-booking-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.bnb-booking-item strong { + display: block; + font-size: 1em; + color: #1d2327; +} + +.bnb-building-name { + display: block; + font-size: 0.85em; + color: #787c82; + margin-top: 2px; +} + +.bnb-booking-details { + margin-top: 8px; + font-size: 0.9em; + color: #50575e; +} + +.bnb-booking-details span { + display: inline-block; + margin-right: 15px; +} + +.bnb-booking-details span::before { + content: "•"; + margin-right: 5px; + color: #c3c4c7; +} + +.bnb-booking-details span:first-child::before { + content: ""; + margin-right: 0; +} + +/* ========================================================================== + Thank You Page - Booking Confirmation + ========================================================================== */ + +.woocommerce-booking-confirmation { + margin: 30px 0; + padding: 20px; + background: #f0f8f1; + border: 1px solid #d1e7d7; + border-radius: 4px; +} + +.woocommerce-booking-confirmation h2 { + margin: 0 0 15px 0; + color: #00a32a; + font-size: 1.3em; +} + +.woocommerce-table--booking-details { + width: 100%; + margin-bottom: 15px; +} + +.woocommerce-table--booking-details th { + text-align: left; + width: 40%; + padding: 8px 12px 8px 0; + color: #50575e; + font-weight: 600; +} + +.woocommerce-table--booking-details td { + padding: 8px 0; +} + +.woocommerce-table--booking-details small { + display: block; + color: #787c82; + margin-top: 2px; +} + +.woocommerce-booking-reference { + margin: 20px 0; + padding: 10px 15px; + background: #f6f7f7; + border-radius: 4px; +} + +.woocommerce-booking-reference .bnb-status-badge { + margin-left: 10px; +} + +/* ========================================================================== + Status Badges + ========================================================================== */ + +.bnb-status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.bnb-status-pending { + background: #fff8e5; + color: #9d6a00; +} + +.bnb-status-confirmed { + background: #e6f4ea; + color: #0a6e31; +} + +.bnb-status-checked_in { + background: #e3f2fd; + color: #1565c0; +} + +.bnb-status-checked_out { + background: #f5f5f5; + color: #616161; +} + +.bnb-status-cancelled { + background: #ffeaea; + color: #d63638; +} + +/* ========================================================================== + Booking Form (Frontend) + ========================================================================== */ + +.bnb-wc-booking-form { + margin: 20px 0; + padding: 20px; + background: #fff; + border: 1px solid #e1e4e8; + border-radius: 4px; +} + +.bnb-wc-booking-form h3 { + margin: 0 0 20px 0; + padding: 0 0 15px 0; + border-bottom: 1px solid #e1e4e8; +} + +.bnb-wc-booking-form .form-row { + margin-bottom: 15px; +} + +.bnb-wc-booking-form label { + display: block; + margin-bottom: 5px; + font-weight: 600; +} + +.bnb-wc-booking-form input[type="date"], +.bnb-wc-booking-form input[type="number"], +.bnb-wc-booking-form select { + width: 100%; + padding: 10px; + border: 1px solid #c3c4c7; + border-radius: 4px; + font-size: 1em; +} + +.bnb-wc-booking-form input[type="date"]:focus, +.bnb-wc-booking-form input[type="number"]:focus, +.bnb-wc-booking-form select:focus { + border-color: #2271b1; + outline: none; + box-shadow: 0 0 0 1px #2271b1; +} + +.bnb-wc-booking-form .form-row-inline { + display: flex; + gap: 15px; +} + +.bnb-wc-booking-form .form-row-inline > div { + flex: 1; +} + +/* Availability status */ +.bnb-availability-status { + margin: 15px 0; + padding: 10px 15px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.bnb-availability-status.checking { + background: #f6f7f7; + color: #787c82; +} + +.bnb-availability-status.available { + background: #e6f4ea; + color: #0a6e31; +} + +.bnb-availability-status.unavailable { + background: #ffeaea; + color: #d63638; +} + +.bnb-availability-status .dashicons { + font-size: 18px; + width: 18px; + height: 18px; +} + +/* Price display */ +.bnb-wc-price-display { + margin: 15px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 4px; +} + +.bnb-wc-price-display .price-row { + display: flex; + justify-content: space-between; + padding: 5px 0; +} + +.bnb-wc-price-display .price-row.total { + border-top: 1px solid #e1e4e8; + margin-top: 10px; + padding-top: 10px; + font-weight: 700; + font-size: 1.1em; +} + +/* Services selection */ +.bnb-wc-services { + margin: 15px 0; +} + +.bnb-wc-services h4 { + margin: 0 0 10px 0; + font-size: 1em; +} + +.bnb-wc-service-item { + display: flex; + align-items: center; + padding: 10px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.bnb-wc-service-item:hover { + background: #f0f0f0; +} + +.bnb-wc-service-item.selected { + background: #e6f4ea; + border: 1px solid #d1e7d7; +} + +.bnb-wc-service-item input[type="checkbox"] { + margin-right: 10px; +} + +.bnb-wc-service-item .service-name { + flex: 1; +} + +.bnb-wc-service-item .service-price { + color: #2271b1; + font-weight: 600; +} + +.bnb-wc-service-item .service-qty { + margin-left: 10px; + width: 60px; +} + +/* Add to cart button */ +.bnb-wc-booking-form .add-to-cart-btn { + width: 100%; + padding: 12px 20px; + background: #2271b1; + color: #fff; + border: none; + border-radius: 4px; + font-size: 1.1em; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.bnb-wc-booking-form .add-to-cart-btn:hover { + background: #135e96; +} + +.bnb-wc-booking-form .add-to-cart-btn:disabled { + background: #c3c4c7; + cursor: not-allowed; +} + +/* ========================================================================== + Admin Order - Booking Info + ========================================================================== */ + +.bnb-order-booking-info { + margin-top: 20px; + padding: 15px; + background: #f6f7f7; + border-radius: 4px; +} + +.bnb-order-booking-info h3 { + margin: 0 0 12px 0; + font-size: 13px; + color: #1d2327; +} + +.bnb-order-booking-info p { + margin: 0 0 8px 0; + font-size: 13px; +} + +.bnb-order-booking-info a { + color: #2271b1; +} + +.bnb-order-booking-info a:hover { + color: #135e96; +} + +/* ========================================================================== + Admin List Table - Order Columns + ========================================================================== */ + +.column-wc_order { + width: 10%; +} + +.column-bnb_booking { + width: 15%; +} + +.column-bnb_booking small { + display: block; + color: #787c82; + margin-top: 2px; +} + +/* Order action buttons */ +.wc-action-button-view_booking::after { + font-family: dashicons; + content: "\f513"; +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media screen and (max-width: 768px) { + .bnb-wc-booking-form .form-row-inline { + flex-direction: column; + gap: 0; + } + + .bnb-booking-details span { + display: block; + margin-right: 0; + margin-bottom: 4px; + } + + .bnb-booking-details span::before { + content: ""; + margin-right: 0; + } +} diff --git a/assets/js/wc-integration.js b/assets/js/wc-integration.js new file mode 100644 index 0000000..783dcfe --- /dev/null +++ b/assets/js/wc-integration.js @@ -0,0 +1,358 @@ +/** + * WooCommerce Integration Scripts + * + * Handles booking form interactions for WooCommerce integration. + * + * @package Magdev\WpBnb + */ + +(function ($) { + 'use strict'; + + /** + * WP BnB WooCommerce Integration + */ + const WpBnbWC = { + /** + * Settings from localization + */ + settings: {}, + + /** + * Initialize + */ + init: function () { + this.settings = window.wpBnbWC || {}; + this.bindEvents(); + this.initBookingForms(); + }, + + /** + * Bind global events + */ + bindEvents: function () { + // Admin: Sync all rooms button + $(document).on('click', '.bnb-sync-rooms-btn', this.handleSyncRooms.bind(this)); + + // Admin: Generate invoice button + $(document).on('click', '.bnb-generate-invoice-btn', this.handleGenerateInvoice.bind(this)); + }, + + /** + * Initialize booking forms + */ + initBookingForms: function () { + $('.bnb-wc-booking-form').each(function () { + new BookingForm($(this)); + }); + }, + + /** + * Handle sync all rooms button + */ + handleSyncRooms: function (e) { + e.preventDefault(); + const $btn = $(e.currentTarget); + const $status = $btn.siblings('.sync-status'); + + $btn.prop('disabled', true).addClass('updating'); + $status.text(this.settings.i18n?.syncing || 'Syncing...'); + + $.ajax({ + url: this.settings.ajaxUrl, + type: 'POST', + data: { + action: 'wp_bnb_sync_all_rooms', + nonce: this.settings.nonce + }, + success: function (response) { + if (response.success) { + $status.html('' + response.data.message + ''); + } else { + $status.html('' + (response.data?.message || 'Error') + ''); + } + }, + error: function () { + $status.html('' + (WpBnbWC.settings.i18n?.error || 'Error occurred') + ''); + }, + complete: function () { + $btn.prop('disabled', false).removeClass('updating'); + } + }); + }, + + /** + * Handle generate invoice button + */ + handleGenerateInvoice: function (e) { + e.preventDefault(); + const $btn = $(e.currentTarget); + const orderId = $btn.data('order-id'); + + $btn.prop('disabled', true).addClass('updating'); + + $.ajax({ + url: this.settings.ajaxUrl, + type: 'POST', + data: { + action: 'wp_bnb_generate_invoice', + nonce: this.settings.nonce, + order_id: orderId + }, + success: function (response) { + if (response.success) { + location.reload(); + } else { + alert(response.data?.message || 'Error generating invoice'); + } + }, + error: function () { + alert(WpBnbWC.settings.i18n?.error || 'Error occurred'); + }, + complete: function () { + $btn.prop('disabled', false).removeClass('updating'); + } + }); + } + }; + + /** + * Booking Form Handler + */ + class BookingForm { + constructor($form) { + this.$form = $form; + this.roomId = $form.data('room-id'); + this.productId = $form.data('product-id'); + this.checkAvailabilityTimeout = null; + + this.bindEvents(); + } + + bindEvents() { + this.$form.on('change', '.bnb-date-input', this.onDateChange.bind(this)); + this.$form.on('change', '.bnb-guests-input', this.onGuestsChange.bind(this)); + this.$form.on('change', '.bnb-service-checkbox', this.onServiceChange.bind(this)); + this.$form.on('change', '.bnb-service-qty', this.onServiceQtyChange.bind(this)); + this.$form.on('submit', this.onSubmit.bind(this)); + } + + onDateChange() { + const checkIn = this.$form.find('[name="bnb_check_in"]').val(); + const checkOut = this.$form.find('[name="bnb_check_out"]').val(); + + // Validate dates + if (!checkIn || !checkOut) { + this.updateAvailabilityStatus(''); + return; + } + + const checkInDate = new Date(checkIn); + const checkOutDate = new Date(checkOut); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (checkInDate < today) { + this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.pastDate || 'Check-in cannot be in the past'); + return; + } + + if (checkOutDate <= checkInDate) { + this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.invalidDates || 'Check-out must be after check-in'); + return; + } + + // Check availability + this.checkAvailability(checkIn, checkOut); + } + + onGuestsChange() { + // Re-validate if needed + const checkIn = this.$form.find('[name="bnb_check_in"]').val(); + const checkOut = this.$form.find('[name="bnb_check_out"]').val(); + + if (checkIn && checkOut) { + this.checkAvailability(checkIn, checkOut); + } + } + + onServiceChange(e) { + const $checkbox = $(e.currentTarget); + const $item = $checkbox.closest('.bnb-wc-service-item'); + const $qtyInput = $item.find('.bnb-service-qty'); + + if ($checkbox.is(':checked')) { + $item.addClass('selected'); + $qtyInput.prop('disabled', false); + } else { + $item.removeClass('selected'); + $qtyInput.prop('disabled', true); + } + + this.updatePriceDisplay(); + } + + onServiceQtyChange() { + this.updatePriceDisplay(); + } + + checkAvailability(checkIn, checkOut) { + // Debounce + clearTimeout(this.checkAvailabilityTimeout); + + this.updateAvailabilityStatus('checking', WpBnbWC.settings.i18n?.checking || 'Checking availability...'); + + this.checkAvailabilityTimeout = setTimeout(() => { + const guests = this.$form.find('[name="bnb_guests"]').val() || 1; + + $.ajax({ + url: WpBnbWC.settings.ajaxUrl, + type: 'POST', + data: { + action: 'wp_bnb_get_availability', + nonce: WpBnbWC.settings.nonce, + room_id: this.roomId, + check_in: checkIn, + check_out: checkOut, + guests: guests + }, + success: (response) => { + if (response.success) { + const data = response.data; + + if (data.available) { + this.updateAvailabilityStatus('available', WpBnbWC.settings.i18n?.available || 'Available'); + this.updatePriceDisplay(data.price, data.breakdown); + this.enableSubmit(); + } else { + this.updateAvailabilityStatus('unavailable', data.message || WpBnbWC.settings.i18n?.unavailable || 'Not available'); + this.disableSubmit(); + } + } else { + this.updateAvailabilityStatus('unavailable', response.data?.message || 'Error checking availability'); + this.disableSubmit(); + } + }, + error: () => { + this.updateAvailabilityStatus('unavailable', WpBnbWC.settings.i18n?.error || 'Error checking availability'); + this.disableSubmit(); + } + }); + }, 500); + } + + updateAvailabilityStatus(status, message) { + const $statusEl = this.$form.find('.bnb-availability-status'); + + if (!status) { + $statusEl.hide(); + return; + } + + $statusEl.removeClass('checking available unavailable').addClass(status); + + let icon = ''; + switch (status) { + case 'checking': + icon = ''; + break; + case 'available': + icon = ''; + break; + case 'unavailable': + icon = ''; + break; + } + + $statusEl.html(icon + ' ' + message).show(); + } + + updatePriceDisplay(roomPrice, breakdown) { + const $priceDisplay = this.$form.find('.bnb-wc-price-display'); + + if (!roomPrice) { + $priceDisplay.hide(); + return; + } + + // Calculate services total + let servicesTotal = 0; + const nights = breakdown?.nights || 1; + + this.$form.find('.bnb-wc-service-item').each(function () { + const $item = $(this); + const $checkbox = $item.find('.bnb-service-checkbox'); + + if ($checkbox.is(':checked')) { + const price = parseFloat($item.data('price')) || 0; + const pricingType = $item.data('pricing-type'); + const qty = parseInt($item.find('.bnb-service-qty').val()) || 1; + + if (pricingType === 'per_night') { + servicesTotal += price * qty * nights; + } else if (pricingType === 'per_booking') { + servicesTotal += price * qty; + } + } + }); + + const grandTotal = roomPrice + servicesTotal; + + // Update display + let html = '
' + (WpBnbWC.settings.i18n?.roomTotal || 'Room') + '' + WpBnbWC.formatPrice(roomPrice) + '
'; + + if (servicesTotal > 0) { + html += '
' + (WpBnbWC.settings.i18n?.services || 'Services') + '' + WpBnbWC.formatPrice(servicesTotal) + '
'; + } + + html += '
' + (WpBnbWC.settings.i18n?.total || 'Total') + '' + WpBnbWC.formatPrice(grandTotal) + '
'; + + $priceDisplay.html(html).show(); + } + + enableSubmit() { + this.$form.find('.add-to-cart-btn').prop('disabled', false); + } + + disableSubmit() { + this.$form.find('.add-to-cart-btn').prop('disabled', true); + } + + onSubmit(e) { + // Form will submit normally - WooCommerce handles the add to cart + // Just validate one more time + const checkIn = this.$form.find('[name="bnb_check_in"]').val(); + const checkOut = this.$form.find('[name="bnb_check_out"]').val(); + + if (!checkIn || !checkOut) { + e.preventDefault(); + alert(WpBnbWC.settings.i18n?.selectDates || 'Please select check-in and check-out dates'); + return false; + } + } + } + + /** + * Format price + */ + WpBnbWC.formatPrice = function (price) { + const currency = this.settings.currency || 'CHF'; + const symbol = this.settings.currencySymbol || currency; + const decimals = this.settings.priceDecimals || 2; + const decimalSep = this.settings.decimalSeparator || '.'; + const thousandSep = this.settings.thousandSeparator || "'"; + + const formatted = price.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep).replace('.', decimalSep); + + return symbol + ' ' + formatted; + }; + + // Initialize on DOM ready + $(document).ready(function () { + WpBnbWC.init(); + }); + + // Export for external use + window.WpBnbWC = WpBnbWC; + +})(jQuery); diff --git a/src/Integration/WooCommerce/AdminColumns.php b/src/Integration/WooCommerce/AdminColumns.php new file mode 100644 index 0000000..e758bec --- /dev/null +++ b/src/Integration/WooCommerce/AdminColumns.php @@ -0,0 +1,282 @@ + $value ) { + $new_columns[ $key ] = $value; + + if ( 'status' === $key ) { + $new_columns['wc_order'] = __( 'WC Order', 'wp-bnb' ); + } + } + + // If status column doesn't exist, add at the end. + if ( ! isset( $new_columns['wc_order'] ) ) { + $new_columns['wc_order'] = __( 'WC Order', 'wp-bnb' ); + } + + return $new_columns; + } + + /** + * Render booking admin column. + * + * @param string $column Column name. + * @param int $post_id Post ID. + * @return void + */ + public static function render_booking_column( string $column, int $post_id ): void { + if ( 'wc_order' !== $column ) { + return; + } + + $order = Manager::get_order_for_booking( $post_id ); + + if ( ! $order ) { + echo ''; + return; + } + + $order_number = $order->get_order_number(); + $order_status = $order->get_status(); + $edit_url = $order->get_edit_order_url(); + + printf( + '#%s', + esc_url( $edit_url ), + esc_attr__( 'View order', 'wp-bnb' ), + esc_html( $order_number ) + ); + + // Status badge. + $status_name = wc_get_order_status_name( $order_status ); + printf( + '
%s', + esc_attr( $order_status ), + esc_html( $status_name ) + ); + } + + /** + * Add columns to WooCommerce orders list. + * + * @param array $columns Existing columns. + * @return array Modified columns. + */ + public static function add_order_columns( array $columns ): array { + // Insert Booking column after order_status. + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + $new_columns[ $key ] = $value; + + if ( 'order_status' === $key ) { + $new_columns['bnb_booking'] = __( 'Booking', 'wp-bnb' ); + } + } + + // If status column doesn't exist, add before actions. + if ( ! isset( $new_columns['bnb_booking'] ) ) { + $columns_before = array_slice( $columns, 0, -1, true ); + $columns_after = array_slice( $columns, -1, null, true ); + + $new_columns = $columns_before + array( 'bnb_booking' => __( 'Booking', 'wp-bnb' ) ) + $columns_after; + } + + return $new_columns; + } + + /** + * Render order admin column (legacy post-based orders). + * + * @param string $column Column name. + * @param int $post_id Post ID (Order ID). + * @return void + */ + public static function render_order_column( string $column, int $post_id ): void { + if ( 'bnb_booking' !== $column ) { + return; + } + + $order = wc_get_order( $post_id ); + + if ( ! $order ) { + echo ''; + return; + } + + self::render_booking_info_for_order( $order ); + } + + /** + * Render order admin column (HPOS). + * + * @param string $column Column name. + * @param \WC_Order $order Order object. + * @return void + */ + public static function render_order_column_hpos( string $column, $order ): void { + if ( 'bnb_booking' !== $column ) { + return; + } + + if ( ! $order instanceof \WC_Order ) { + echo ''; + return; + } + + self::render_booking_info_for_order( $order ); + } + + /** + * Render booking info for an order. + * + * @param \WC_Order $order WooCommerce order. + * @return void + */ + private static function render_booking_info_for_order( \WC_Order $order ): void { + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + echo ''; + return; + } + + $booking = get_post( $booking_id ); + $check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true ); + $status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + + // Booking link. + printf( + '%s', + esc_url( get_edit_post_link( $booking_id ) ), + esc_attr__( 'View booking', 'wp-bnb' ), + $booking ? esc_html( wp_trim_words( $booking->post_title, 3 ) ) : '#' . esc_html( $booking_id ) + ); + + // Dates. + if ( $check_in && $check_out ) { + $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in ); + $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out ); + + if ( $check_in_date && $check_out_date ) { + printf( + '
%s - %s', + esc_html( $check_in_date->format( 'd.m' ) ), + esc_html( $check_out_date->format( 'd.m.y' ) ) + ); + } + } + + // Status badge. + if ( $status ) { + printf( + '
%s', + esc_attr( $status ), + esc_html( ucfirst( str_replace( '_', ' ', $status ) ) ) + ); + } + } + + /** + * Add row actions to booking list. + * + * @param array $actions Existing actions. + * @param \WP_Post $post Post object. + * @return array Modified actions. + */ + public static function add_booking_row_actions( array $actions, \WP_Post $post ): array { + if ( Booking::POST_TYPE !== $post->post_type ) { + return $actions; + } + + $order = Manager::get_order_for_booking( $post->ID ); + + if ( $order ) { + $actions['view_order'] = sprintf( + '%s', + esc_url( $order->get_edit_order_url() ), + /* translators: %s: Order number */ + esc_attr( sprintf( __( 'View order #%s', 'wp-bnb' ), $order->get_order_number() ) ), + __( 'View Order', 'wp-bnb' ) + ); + } + + return $actions; + } + + /** + * Add actions to WooCommerce order row. + * + * @param array $actions Existing actions. + * @param \WC_Order $order Order object. + * @return array Modified actions. + */ + public static function add_order_actions( array $actions, \WC_Order $order ): array { + $booking_id = Manager::get_booking_for_order( $order ); + + if ( $booking_id ) { + $actions['view_booking'] = array( + 'url' => get_edit_post_link( $booking_id ), + 'name' => __( 'View Booking', 'wp-bnb' ), + 'action' => 'view_booking', + ); + } + + return $actions; + } +} diff --git a/src/Integration/WooCommerce/CartHandler.php b/src/Integration/WooCommerce/CartHandler.php new file mode 100644 index 0000000..eef62e3 --- /dev/null +++ b/src/Integration/WooCommerce/CartHandler.php @@ -0,0 +1,545 @@ +session->set( + 'bnb_pending_booking', + array( + 'room_id' => $room_id, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'guests' => $guests, + 'services' => $services, + ) + ); + + // Add to cart. + $cart_item_key = WC()->cart->add_to_cart( $product_id, 1 ); + + // Clean up session. + WC()->session->set( 'bnb_pending_booking', null ); + + return $cart_item_key; + } + + /** + * Add booking data to cart item when adding to cart. + * + * @param array $cart_item_data Cart item data. + * @param int $product_id Product ID. + * @param int $variation_id Variation ID. + * @return array Modified cart item data. + */ + public static function add_cart_item_data( array $cart_item_data, int $product_id, int $variation_id ): array { + // Check if this is a room product. + $room_id = ProductSync::get_room_for_product( $product_id ); + + if ( ! $room_id ) { + return $cart_item_data; + } + + // Get booking data from session or POST. + $booking_data = WC()->session->get( 'bnb_pending_booking' ); + + if ( ! $booking_data ) { + // Try to get from POST (for direct form submissions). + // phpcs:disable WordPress.Security.NonceVerification.Missing + $booking_data = array( + 'room_id' => isset( $_POST['bnb_room_id'] ) ? absint( $_POST['bnb_room_id'] ) : $room_id, + 'check_in' => isset( $_POST['bnb_check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_in'] ) ) : '', + 'check_out' => isset( $_POST['bnb_check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_out'] ) ) : '', + 'guests' => isset( $_POST['bnb_guests'] ) ? absint( $_POST['bnb_guests'] ) : 1, + 'services' => isset( $_POST['bnb_services'] ) ? self::sanitize_services( $_POST['bnb_services'] ) : array(), + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + // Validate required fields. + if ( empty( $booking_data['check_in'] ) || empty( $booking_data['check_out'] ) ) { + return $cart_item_data; + } + + // Calculate nights. + $check_in = new \DateTime( $booking_data['check_in'] ); + $check_out = new \DateTime( $booking_data['check_out'] ); + $nights = (int) $check_in->diff( $check_out )->days; + + if ( $nights < 1 ) { + return $cart_item_data; + } + + // Calculate price breakdown. + $calculator = new Calculator( $room_id, $booking_data['check_in'], $booking_data['check_out'] ); + $price_breakdown = $calculator->calculate(); + $room_total = $price_breakdown['total']; + + // Calculate services total. + $services_total = 0; + $services_data = array(); + + foreach ( $booking_data['services'] as $service_selection ) { + $service_id = $service_selection['service_id'] ?? 0; + $quantity = $service_selection['quantity'] ?? 1; + + if ( ! $service_id ) { + continue; + } + + $service_price = Service::calculate_service_price( $service_id, $quantity, $nights ); + $service_data = Service::get_service_data( $service_id ); + + $services_data[] = array( + 'service_id' => $service_id, + 'quantity' => $quantity, + 'price' => $service_price, + 'pricing_type' => $service_data['pricing_type'] ?? 'per_booking', + 'name' => $service_data['name'] ?? '', + ); + + $services_total += $service_price; + } + + // Store booking data. + $cart_item_data[ Manager::CART_ITEM_KEY ] = array( + 'room_id' => $room_id, + 'check_in' => $booking_data['check_in'], + 'check_out' => $booking_data['check_out'], + 'guests' => $booking_data['guests'], + 'nights' => $nights, + 'services' => $services_data, + 'price_breakdown' => array( + 'room_total' => $room_total, + 'services_total' => $services_total, + 'grand_total' => $room_total + $services_total, + 'full_breakdown' => $price_breakdown, + ), + ); + + // Generate unique key based on booking data to allow multiple bookings. + $cart_item_data['unique_key'] = md5( + $room_id . $booking_data['check_in'] . $booking_data['check_out'] . microtime() + ); + + return $cart_item_data; + } + + /** + * Restore booking data from session. + * + * @param array $cart_item Cart item data. + * @param array $values Session values. + * @return array Modified cart item data. + */ + public static function get_cart_item_from_session( array $cart_item, array $values ): array { + if ( isset( $values[ Manager::CART_ITEM_KEY ] ) ) { + $cart_item[ Manager::CART_ITEM_KEY ] = $values[ Manager::CART_ITEM_KEY ]; + } + return $cart_item; + } + + /** + * Validate room availability before adding to cart. + * + * @param bool $passed Whether validation passed. + * @param int $product_id Product ID. + * @param int $quantity Quantity. + * @param int $variation_id Variation ID. + * @param array $variations Variations. + * @return bool + */ + public static function validate_add_to_cart( bool $passed, int $product_id, int $quantity, int $variation_id = 0, array $variations = array() ): bool { + // Check if this is a room product. + $room_id = ProductSync::get_room_for_product( $product_id ); + + if ( ! $room_id ) { + return $passed; + } + + // Get booking data. + $booking_data = WC()->session->get( 'bnb_pending_booking' ); + + if ( ! $booking_data ) { + // phpcs:disable WordPress.Security.NonceVerification.Missing + $booking_data = array( + 'check_in' => isset( $_POST['bnb_check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_in'] ) ) : '', + 'check_out' => isset( $_POST['bnb_check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_check_out'] ) ) : '', + 'guests' => isset( $_POST['bnb_guests'] ) ? absint( $_POST['bnb_guests'] ) : 1, + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + // Validate dates provided. + if ( empty( $booking_data['check_in'] ) || empty( $booking_data['check_out'] ) ) { + wc_add_notice( __( 'Please select check-in and check-out dates.', 'wp-bnb' ), 'error' ); + return false; + } + + // Validate date format. + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] ); + + if ( ! $check_in || ! $check_out ) { + wc_add_notice( __( 'Invalid date format. Please use the date picker.', 'wp-bnb' ), 'error' ); + return false; + } + + // Validate check-out after check-in. + if ( $check_out <= $check_in ) { + wc_add_notice( __( 'Check-out date must be after check-in date.', 'wp-bnb' ), 'error' ); + return false; + } + + // Validate not in past. + $today = new \DateTime( 'today' ); + if ( $check_in < $today ) { + wc_add_notice( __( 'Check-in date cannot be in the past.', 'wp-bnb' ), 'error' ); + return false; + } + + // Check availability. + $is_available = Availability::check_availability( + $room_id, + $booking_data['check_in'], + $booking_data['check_out'] + ); + + if ( ! $is_available ) { + wc_add_notice( __( 'Sorry, this room is not available for the selected dates.', 'wp-bnb' ), 'error' ); + return false; + } + + // Check capacity. + $capacity = get_post_meta( $room_id, '_bnb_room_capacity', true ); + if ( $capacity && $booking_data['guests'] > (int) $capacity ) { + wc_add_notice( + sprintf( + /* translators: %d: Room capacity */ + __( 'This room has a maximum capacity of %d guests.', 'wp-bnb' ), + $capacity + ), + 'error' + ); + return false; + } + + // Check if same room with same dates already in cart. + foreach ( WC()->cart->get_cart() as $cart_item ) { + if ( isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + $existing = $cart_item[ Manager::CART_ITEM_KEY ]; + if ( $existing['room_id'] === $room_id + && $existing['check_in'] === $booking_data['check_in'] + && $existing['check_out'] === $booking_data['check_out'] + ) { + wc_add_notice( __( 'This room with the same dates is already in your cart.', 'wp-bnb' ), 'error' ); + return false; + } + } + } + + return $passed; + } + + /** + * Validate cart items on cart load. + * + * @param \WC_Cart $cart Cart object. + * @return void + */ + public static function validate_cart_items( \WC_Cart $cart ): void { + $items_to_remove = array(); + + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { + if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + continue; + } + + $booking_data = $cart_item[ Manager::CART_ITEM_KEY ]; + + // Check if dates are still valid. + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $today = new \DateTime( 'today' ); + + if ( $check_in < $today ) { + $items_to_remove[] = array( + 'key' => $cart_item_key, + 'message' => __( 'A room booking was removed because the check-in date has passed.', 'wp-bnb' ), + ); + continue; + } + + // Check if room still available. + $is_available = Availability::check_availability( + $booking_data['room_id'], + $booking_data['check_in'], + $booking_data['check_out'] + ); + + if ( ! $is_available ) { + $items_to_remove[] = array( + 'key' => $cart_item_key, + 'message' => __( 'A room booking was removed because the room is no longer available for those dates.', 'wp-bnb' ), + ); + } + } + + // Remove invalid items. + foreach ( $items_to_remove as $item ) { + $cart->remove_cart_item( $item['key'] ); + wc_add_notice( $item['message'], 'error' ); + } + } + + /** + * Display booking info in cart. + * + * @param array $item_data Item data for display. + * @param array $cart_item Cart item. + * @return array Modified item data. + */ + public static function display_cart_item_data( array $item_data, array $cart_item ): array { + if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + return $item_data; + } + + $booking_data = $cart_item[ Manager::CART_ITEM_KEY ]; + + // Format dates. + $date_format = get_option( 'date_format' ); + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] ); + + $item_data[] = array( + 'key' => __( 'Check-in', 'wp-bnb' ), + 'value' => $check_in ? $check_in->format( $date_format ) : $booking_data['check_in'], + ); + + $item_data[] = array( + 'key' => __( 'Check-out', 'wp-bnb' ), + 'value' => $check_out ? $check_out->format( $date_format ) : $booking_data['check_out'], + ); + + $item_data[] = array( + 'key' => __( 'Nights', 'wp-bnb' ), + 'value' => $booking_data['nights'], + ); + + $item_data[] = array( + 'key' => __( 'Guests', 'wp-bnb' ), + 'value' => $booking_data['guests'], + ); + + // Display services. + if ( ! empty( $booking_data['services'] ) ) { + $services_list = array(); + foreach ( $booking_data['services'] as $service ) { + $services_list[] = $service['name'] . ' × ' . $service['quantity']; + } + $item_data[] = array( + 'key' => __( 'Services', 'wp-bnb' ), + 'value' => implode( ', ', $services_list ), + ); + } + + return $item_data; + } + + /** + * Calculate dynamic prices for cart items. + * + * @param \WC_Cart $cart Cart object. + * @return void + */ + public static function calculate_cart_item_prices( \WC_Cart $cart ): void { + if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { + return; + } + + foreach ( $cart->get_cart() as $cart_item ) { + if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + continue; + } + + $booking_data = $cart_item[ Manager::CART_ITEM_KEY ]; + $grand_total = $booking_data['price_breakdown']['grand_total'] ?? 0; + + /** + * Filter the cart item price for a booking. + * + * @param float $price The calculated price. + * @param array $cart_item The cart item. + */ + $grand_total = apply_filters( 'wp_bnb_wc_cart_item_price', $grand_total, $cart_item ); + + // Set the price. + $cart_item['data']->set_price( $grand_total ); + } + } + + /** + * Lock quantity to 1 for room products. + * + * @param array $args Input arguments. + * @param \WC_Product $product Product object. + * @return array Modified arguments. + */ + public static function lock_quantity( array $args, \WC_Product $product ): array { + if ( ProductSync::is_room_product( $product ) ) { + $args['min_value'] = 1; + $args['max_value'] = 1; + $args['readonly'] = true; + } + return $args; + } + + /** + * Add booking data to order item meta. + * + * @param \WC_Order_Item_Product $item Order item. + * @param string $cart_item_key Cart item key. + * @param array $values Cart item values. + * @param \WC_Order $order Order object. + * @return void + */ + public static function add_order_item_meta( \WC_Order_Item_Product $item, string $cart_item_key, array $values, \WC_Order $order ): void { + if ( ! isset( $values[ Manager::CART_ITEM_KEY ] ) ) { + return; + } + + $booking_data = $values[ Manager::CART_ITEM_KEY ]; + + // Store booking data in order item meta. + $item->add_meta_data( '_bnb_room_id', $booking_data['room_id'] ); + $item->add_meta_data( '_bnb_check_in', $booking_data['check_in'] ); + $item->add_meta_data( '_bnb_check_out', $booking_data['check_out'] ); + $item->add_meta_data( '_bnb_guests', $booking_data['guests'] ); + $item->add_meta_data( '_bnb_nights', $booking_data['nights'] ); + $item->add_meta_data( '_bnb_services', wp_json_encode( $booking_data['services'] ) ); + $item->add_meta_data( '_bnb_price_breakdown', wp_json_encode( $booking_data['price_breakdown'] ) ); + + // Add visible meta for admin display. + $date_format = get_option( 'date_format' ); + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] ); + + $item->add_meta_data( __( 'Check-in', 'wp-bnb' ), $check_in ? $check_in->format( $date_format ) : $booking_data['check_in'] ); + $item->add_meta_data( __( 'Check-out', 'wp-bnb' ), $check_out ? $check_out->format( $date_format ) : $booking_data['check_out'] ); + $item->add_meta_data( __( 'Nights', 'wp-bnb' ), $booking_data['nights'] ); + $item->add_meta_data( __( 'Guests', 'wp-bnb' ), $booking_data['guests'] ); + } + + /** + * Sanitize services array from POST data. + * + * @param mixed $services Raw services data. + * @return array Sanitized services array. + */ + private static function sanitize_services( $services ): array { + if ( ! is_array( $services ) ) { + return array(); + } + + $sanitized = array(); + + foreach ( $services as $service ) { + if ( ! is_array( $service ) ) { + continue; + } + + $service_id = isset( $service['service_id'] ) ? absint( $service['service_id'] ) : 0; + $quantity = isset( $service['quantity'] ) ? absint( $service['quantity'] ) : 1; + + if ( $service_id > 0 ) { + $sanitized[] = array( + 'service_id' => $service_id, + 'quantity' => max( 1, $quantity ), + ); + } + } + + return $sanitized; + } +} diff --git a/src/Integration/WooCommerce/CheckoutHandler.php b/src/Integration/WooCommerce/CheckoutHandler.php new file mode 100644 index 0000000..b8fce0b --- /dev/null +++ b/src/Integration/WooCommerce/CheckoutHandler.php @@ -0,0 +1,347 @@ + 'textarea', + 'label' => __( 'Special Requests', 'wp-bnb' ), + 'placeholder' => __( 'Any special requests, dietary requirements, or preferences...', 'wp-bnb' ), + 'class' => array( 'form-row-wide' ), + 'required' => false, + 'priority' => 90, + ); + + // Add expected arrival time. + $fields['order']['bnb_arrival_time'] = array( + 'type' => 'select', + 'label' => __( 'Expected Arrival Time', 'wp-bnb' ), + 'class' => array( 'form-row-wide' ), + 'required' => false, + 'priority' => 85, + 'options' => self::get_arrival_time_options(), + ); + + return $fields; + } + + /** + * Pre-fill checkout fields from guest data. + * + * @param mixed $value Current value. + * @param string $input Input field name. + * @return mixed Pre-filled value. + */ + public static function prefill_checkout_fields( $value, string $input ) { + // Only for logged-in users. + if ( ! is_user_logged_in() ) { + return $value; + } + + // Try to find an existing guest record by email. + $current_user = wp_get_current_user(); + $guest = self::find_guest_by_email( $current_user->user_email ); + + if ( ! $guest ) { + return $value; + } + + $mappings = array( + 'billing_first_name' => '_bnb_guest_first_name', + 'billing_last_name' => '_bnb_guest_last_name', + 'billing_email' => '_bnb_guest_email', + 'billing_phone' => '_bnb_guest_phone', + 'billing_address_1' => '_bnb_guest_address', + 'billing_city' => '_bnb_guest_city', + 'billing_postcode' => '_bnb_guest_postal_code', + 'billing_country' => '_bnb_guest_country', + ); + + if ( isset( $mappings[ $input ] ) ) { + $meta_value = get_post_meta( $guest->ID, $mappings[ $input ], true ); + if ( $meta_value ) { + return $meta_value; + } + } + + return $value; + } + + /** + * Validate checkout for BnB bookings. + * + * @param array $data Checkout data. + * @param \WP_Error $errors Error object. + * @return void + */ + public static function validate_checkout( array $data, \WP_Error $errors ): void { + // Re-validate availability for all bookings. + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + continue; + } + + $booking_data = $cart_item[ Manager::CART_ITEM_KEY ]; + + // Check availability one more time. + $is_available = Availability::check_availability( + $booking_data['room_id'], + $booking_data['check_in'], + $booking_data['check_out'] + ); + + if ( ! $is_available ) { + $room = get_post( $booking_data['room_id'] ); + $errors->add( + 'bnb_room_unavailable', + sprintf( + /* translators: %s: Room name */ + __( 'Sorry, %s is no longer available for the selected dates. Please update your cart.', 'wp-bnb' ), + $room ? $room->post_title : __( 'the room', 'wp-bnb' ) + ) + ); + } + + // Validate check-in date not in past. + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $today = new \DateTime( 'today' ); + + if ( $check_in < $today ) { + $errors->add( + 'bnb_date_passed', + __( 'A booking in your cart has a check-in date in the past. Please update your cart.', 'wp-bnb' ) + ); + } + } + } + + /** + * Display booking summary in order review section. + * + * @return void + */ + public static function display_booking_summary(): void { + if ( ! self::cart_has_bookings() ) { + return; + } + + $bookings = array(); + + foreach ( WC()->cart->get_cart() as $cart_item ) { + if ( ! isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + continue; + } + + $booking_data = $cart_item[ Manager::CART_ITEM_KEY ]; + $room = get_post( $booking_data['room_id'] ); + + if ( ! $room ) { + continue; + } + + $building = Room::get_building( $booking_data['room_id'] ); + + $date_format = get_option( 'date_format' ); + $check_in = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_in'] ); + $check_out = \DateTime::createFromFormat( 'Y-m-d', $booking_data['check_out'] ); + + $bookings[] = array( + 'room_name' => $room->post_title, + 'building_name' => $building ? $building->post_title : '', + 'check_in' => $check_in ? $check_in->format( $date_format ) : '', + 'check_out' => $check_out ? $check_out->format( $date_format ) : '', + 'nights' => $booking_data['nights'], + 'guests' => $booking_data['guests'], + ); + } + + if ( empty( $bookings ) ) { + return; + } + ?> +
+

+ +
+ + + + +
+ + + + + + + + + +
+
+ +
+ update_meta_data( + '_bnb_guest_notes', + sanitize_textarea_field( wp_unslash( $_POST['bnb_guest_notes'] ) ) + ); + } + + if ( isset( $_POST['bnb_arrival_time'] ) && ! empty( $_POST['bnb_arrival_time'] ) ) { + $order->update_meta_data( + '_bnb_arrival_time', + sanitize_text_field( wp_unslash( $_POST['bnb_arrival_time'] ) ) + ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + /** + * Check if cart contains BnB bookings. + * + * @return bool + */ + public static function cart_has_bookings(): bool { + if ( ! WC()->cart ) { + return false; + } + + foreach ( WC()->cart->get_cart() as $cart_item ) { + if ( isset( $cart_item[ Manager::CART_ITEM_KEY ] ) ) { + return true; + } + } + + return false; + } + + /** + * Get arrival time options. + * + * @return array + */ + private static function get_arrival_time_options(): array { + $options = array( + '' => __( 'Select arrival time (optional)', 'wp-bnb' ), + '0-2' => __( 'Early morning (00:00 - 02:00)', 'wp-bnb' ), + '2-6' => __( 'Night (02:00 - 06:00)', 'wp-bnb' ), + '6-10' => __( 'Morning (06:00 - 10:00)', 'wp-bnb' ), + '10-14' => __( 'Late morning (10:00 - 14:00)', 'wp-bnb' ), + '14-18' => __( 'Afternoon (14:00 - 18:00)', 'wp-bnb' ), + '18-22' => __( 'Evening (18:00 - 22:00)', 'wp-bnb' ), + '22-24' => __( 'Late evening (22:00 - 00:00)', 'wp-bnb' ), + ); + + return $options; + } + + /** + * Find guest by email address. + * + * @param string $email Email address. + * @return \WP_Post|null Guest post or null. + */ + private static function find_guest_by_email( string $email ): ?\WP_Post { + $guests = get_posts( + array( + 'post_type' => Guest::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'meta_query' => array( + array( + 'key' => '_bnb_guest_email', + 'value' => $email, + ), + ), + ) + ); + + return $guests[0] ?? null; + } +} diff --git a/src/Integration/WooCommerce/InvoiceGenerator.php b/src/Integration/WooCommerce/InvoiceGenerator.php new file mode 100644 index 0000000..ad839c5 --- /dev/null +++ b/src/Integration/WooCommerce/InvoiceGenerator.php @@ -0,0 +1,633 @@ + 'utf-8', + 'format' => 'A4', + 'margin_left' => 15, + 'margin_right' => 15, + 'margin_top' => 15, + 'margin_bottom' => 20, + 'tempDir' => $temp_dir, + ) + ); + + $mpdf->SetTitle( sprintf( 'Invoice %s', $invoice_number ) ); + $mpdf->SetAuthor( get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ) ); + $mpdf->SetCreator( 'WP BnB' ); + + $mpdf->WriteHTML( $html ); + + // Save to file. + $file_path = self::get_invoice_path( $order, $invoice_number ); + $mpdf->Output( $file_path, 'F' ); + + // Store invoice number and path in order meta. + $order->update_meta_data( '_bnb_invoice_number', $invoice_number ); + $order->update_meta_data( '_bnb_invoice_path', $file_path ); + $order->update_meta_data( '_bnb_invoice_date', current_time( 'mysql' ) ); + $order->save(); + + /** + * Fires after generating an invoice. + * + * @param \WC_Order $order WooCommerce order. + * @param string $file_path Invoice file path. + */ + do_action( 'wp_bnb_wc_after_invoice_generate', $order, $file_path ); + + return $file_path; + + } catch ( \Exception $e ) { + error_log( 'WP BnB Invoice generation failed: ' . $e->getMessage() ); + return null; + } + } + + /** + * Get invoice number for an order. + * + * @param \WC_Order $order WooCommerce order. + * @return string Invoice number. + */ + public static function get_invoice_number( \WC_Order $order ): string { + // Check if already has invoice number. + $existing = $order->get_meta( '_bnb_invoice_number', true ); + + if ( $existing ) { + return $existing; + } + + // Generate new invoice number. + return Manager::get_next_invoice_number(); + } + + /** + * Get invoice file path. + * + * @param \WC_Order $order WooCommerce order. + * @param string $invoice_number Invoice number. + * @return string File path. + */ + private static function get_invoice_path( \WC_Order $order, string $invoice_number ): string { + $upload_dir = wp_upload_dir(); + $invoice_dir = $upload_dir['basedir'] . '/' . self::INVOICE_DIR; + + // Create directory if needed. + if ( ! file_exists( $invoice_dir ) ) { + wp_mkdir_p( $invoice_dir ); + + // Add .htaccess to protect invoices. + $htaccess = $invoice_dir . '/.htaccess'; + if ( ! file_exists( $htaccess ) ) { + file_put_contents( $htaccess, 'Deny from all' ); + } + + // Add index.php for extra protection. + $index = $invoice_dir . '/index.php'; + if ( ! file_exists( $index ) ) { + file_put_contents( $index, 'get_id() . '.pdf'; + } + + /** + * Check if invoice exists for an order. + * + * @param \WC_Order $order WooCommerce order. + * @return bool + */ + public static function invoice_exists( \WC_Order $order ): bool { + $path = $order->get_meta( '_bnb_invoice_path', true ); + + return $path && file_exists( $path ); + } + + /** + * Attach invoice to email. + * + * @param array $attachments Attachments array. + * @param string $email_id Email ID. + * @param \WC_Order $order WooCommerce order. + * @param \WC_Email $email Email object. + * @return array Modified attachments. + */ + public static function attach_invoice_to_email( array $attachments, string $email_id, $order, $email ): array { + // Only attach to specific emails. + $allowed_emails = array( + 'customer_completed_order', + 'customer_processing_order', + 'customer_invoice', + ); + + if ( ! in_array( $email_id, $allowed_emails, true ) ) { + return $attachments; + } + + // Check if order is a WC_Order. + if ( ! $order instanceof \WC_Order ) { + return $attachments; + } + + // Check if invoice attachment is enabled. + if ( ! Manager::is_invoice_attach_enabled() ) { + return $attachments; + } + + // Check if this order has a booking. + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + return $attachments; + } + + // Generate invoice if it doesn't exist. + if ( ! self::invoice_exists( $order ) ) { + self::generate_invoice( $order ); + } + + // Get invoice path. + $invoice_path = $order->get_meta( '_bnb_invoice_path', true ); + + if ( $invoice_path && file_exists( $invoice_path ) ) { + $attachments[] = $invoice_path; + } + + return $attachments; + } + + /** + * Add order action button for invoice. + * + * @param \WC_Order $order WooCommerce order. + * @return void + */ + public static function add_order_action_button( \WC_Order $order ): void { + // Check if this order has a booking. + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + return; + } + + // Generate download URL. + $download_url = add_query_arg( + array( + 'bnb_download_invoice' => $order->get_id(), + '_wpnonce' => wp_create_nonce( 'bnb_download_invoice_' . $order->get_id() ), + ), + admin_url( 'admin.php' ) + ); + + ?> + + + + __( 'Permission denied.', 'wp-bnb' ) ) ); + } + + $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0; + + if ( ! $order_id ) { + wp_send_json_error( array( 'message' => __( 'Invalid order ID.', 'wp-bnb' ) ) ); + } + + $order = wc_get_order( $order_id ); + + if ( ! $order ) { + wp_send_json_error( array( 'message' => __( 'Order not found.', 'wp-bnb' ) ) ); + } + + $file_path = self::generate_invoice( $order ); + + if ( $file_path ) { + wp_send_json_success( array( 'message' => __( 'Invoice generated successfully.', 'wp-bnb' ) ) ); + } else { + wp_send_json_error( array( 'message' => __( 'Failed to generate invoice.', 'wp-bnb' ) ) ); + } + } + + /** + * Handle invoice download request. + * + * @return void + */ + public static function handle_invoice_download(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['bnb_download_invoice'] ) ) { + return; + } + + $order_id = absint( $_GET['bnb_download_invoice'] ); + + // Verify nonce. + if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'bnb_download_invoice_' . $order_id ) ) { + wp_die( esc_html__( 'Security check failed.', 'wp-bnb' ) ); + } + + // Check capabilities. + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'You do not have permission to download invoices.', 'wp-bnb' ) ); + } + + $order = wc_get_order( $order_id ); + + if ( ! $order ) { + wp_die( esc_html__( 'Order not found.', 'wp-bnb' ) ); + } + + // Generate invoice if needed. + if ( ! self::invoice_exists( $order ) ) { + self::generate_invoice( $order ); + } + + $invoice_path = $order->get_meta( '_bnb_invoice_path', true ); + + if ( ! $invoice_path || ! file_exists( $invoice_path ) ) { + wp_die( esc_html__( 'Invoice not found.', 'wp-bnb' ) ); + } + + $invoice_number = $order->get_meta( '_bnb_invoice_number', true ); + $filename = 'invoice-' . sanitize_file_name( $invoice_number ) . '.pdf'; + + // Output file. + header( 'Content-Type: application/pdf' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Content-Length: ' . filesize( $invoice_path ) ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + + readfile( $invoice_path ); + exit; + } + + /** + * Get invoice HTML content. + * + * @param \WC_Order $order WooCommerce order. + * @param string $invoice_number Invoice number. + * @param int|null $booking_id Booking ID. + * @return string HTML content. + */ + private static function get_invoice_html( \WC_Order $order, string $invoice_number, ?int $booking_id ): string { + // Get business info. + $business = self::get_business_info(); + + // Get booking details. + $check_in = ''; + $check_out = ''; + $room_name = ''; + $building_name = ''; + $nights = 0; + $guests = 0; + + if ( $booking_id ) { + $check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true ); + $guests = get_post_meta( $booking_id, '_bnb_booking_adults', true ); + $room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true ); + + if ( $room_id ) { + $room = get_post( $room_id ); + $room_name = $room ? $room->post_title : ''; + $building = Room::get_building( $room_id ); + $building_name = $building ? $building->post_title : ''; + } + + if ( $check_in && $check_out ) { + $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in ); + $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out ); + if ( $check_in_date && $check_out_date ) { + $nights = $check_in_date->diff( $check_out_date )->days; + } + } + } + + // Format dates. + $date_format = get_option( 'date_format' ); + $check_in_display = $check_in ? date_i18n( $date_format, strtotime( $check_in ) ) : ''; + $check_out_display = $check_out ? date_i18n( $date_format, strtotime( $check_out ) ) : ''; + $order_date = $order->get_date_created() ? $order->get_date_created()->date_i18n( $date_format ) : ''; + + // Get logo. + $logo_html = ''; + $logo_id = Manager::get_invoice_logo(); + if ( $logo_id ) { + $logo_url = wp_get_attachment_url( $logo_id ); + if ( $logo_url ) { + $logo_html = ''; + } + } + + // Get footer. + $footer_text = Manager::get_invoice_footer(); + + // Build HTML. + $html = ''; + + // Header. + $html .= '
'; + $html .= ''; + $html .= '
'; + $html .= '

' . esc_html__( 'INVOICE', 'wp-bnb' ) . '

'; + $html .= '

' . esc_html( $invoice_number ) . '

'; + $html .= '

' . esc_html( $order_date ) . '

'; + $html .= '
'; + $html .= '
'; + + // Addresses. + $html .= '
'; + + // From. + $html .= '
'; + $html .= '

' . esc_html__( 'From', 'wp-bnb' ) . '

'; + $html .= '

' . esc_html( $business['name'] ) . '

'; + if ( $business['street'] ) { + $html .= '

' . esc_html( $business['street'] ) . '

'; + } + if ( $business['city'] || $business['postal'] ) { + $html .= '

' . esc_html( $business['postal'] . ' ' . $business['city'] ) . '

'; + } + if ( $business['country'] ) { + $html .= '

' . esc_html( $business['country'] ) . '

'; + } + if ( $business['email'] ) { + $html .= '

' . esc_html( $business['email'] ) . '

'; + } + if ( $business['phone'] ) { + $html .= '

' . esc_html( $business['phone'] ) . '

'; + } + $html .= '
'; + + // To. + $html .= '
'; + $html .= '

' . esc_html__( 'Bill To', 'wp-bnb' ) . '

'; + $html .= '

' . esc_html( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ) . '

'; + if ( $order->get_billing_address_1() ) { + $html .= '

' . esc_html( $order->get_billing_address_1() ) . '

'; + } + if ( $order->get_billing_city() || $order->get_billing_postcode() ) { + $html .= '

' . esc_html( $order->get_billing_postcode() . ' ' . $order->get_billing_city() ) . '

'; + } + if ( $order->get_billing_country() ) { + $html .= '

' . esc_html( WC()->countries->countries[ $order->get_billing_country() ] ?? $order->get_billing_country() ) . '

'; + } + if ( $order->get_billing_email() ) { + $html .= '

' . esc_html( $order->get_billing_email() ) . '

'; + } + $html .= '
'; + $html .= '
'; + + // Booking details. + if ( $booking_id && $room_name ) { + $html .= '
'; + $html .= '

' . esc_html__( 'Booking Details', 'wp-bnb' ) . '

'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
' . esc_html__( 'Room', 'wp-bnb' ) . '' . esc_html( $room_name ); + if ( $building_name ) { + $html .= ' (' . esc_html( $building_name ) . ')'; + } + $html .= '
' . esc_html__( 'Check-in', 'wp-bnb' ) . '' . esc_html( $check_in_display ) . '
' . esc_html__( 'Check-out', 'wp-bnb' ) . '' . esc_html( $check_out_display ) . '
' . esc_html__( 'Nights', 'wp-bnb' ) . '' . esc_html( $nights ) . '
' . esc_html__( 'Guests', 'wp-bnb' ) . '' . esc_html( $guests ) . '
'; + $html .= '
'; + } + + // Line items. + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ( $order->get_items() as $item ) { + $qty = $item->get_quantity(); + $total = $item->get_total(); + $price = $qty > 0 ? $total / $qty : $total; + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= ''; + $html .= '
' . esc_html__( 'Description', 'wp-bnb' ) . '' . esc_html__( 'Qty', 'wp-bnb' ) . '' . esc_html__( 'Price', 'wp-bnb' ) . '' . esc_html__( 'Total', 'wp-bnb' ) . '
' . esc_html( $item->get_name() ) . '' . esc_html( $qty ) . '' . wc_price( $price ) . '' . wc_price( $total ) . '
'; + $html .= '
'; + + // Totals. + $html .= '
'; + $html .= ''; + $html .= ''; + + if ( $order->get_total_tax() > 0 ) { + $html .= ''; + } + + $html .= ''; + $html .= '
' . esc_html__( 'Subtotal', 'wp-bnb' ) . '' . wc_price( $order->get_subtotal() ) . '
' . esc_html__( 'Tax', 'wp-bnb' ) . '' . wc_price( $order->get_total_tax() ) . '
' . esc_html__( 'Total', 'wp-bnb' ) . '' . wc_price( $order->get_total() ) . '
'; + $html .= '
'; + + // Payment info. + $html .= '
'; + $html .= '

' . esc_html__( 'Payment Status:', 'wp-bnb' ) . ' '; + + if ( $order->is_paid() ) { + $html .= ''; + } else { + $html .= '' . esc_html__( 'PENDING', 'wp-bnb' ) . ''; + } + + $html .= '

'; + $html .= '

' . esc_html__( 'Payment Method:', 'wp-bnb' ) . ' ' . esc_html( $order->get_payment_method_title() ) . '

'; + $html .= '
'; + + // Footer. + $html .= ''; + + $html .= ''; + + /** + * Filter the invoice HTML. + * + * @param string $html Invoice HTML. + * @param \WC_Order $order WooCommerce order. + */ + return apply_filters( 'wp_bnb_wc_invoice_html', $html, $order ); + } + + /** + * Get invoice CSS styles. + * + * @return string CSS content. + */ + private static function get_invoice_css(): string { + return ' + body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #333; line-height: 1.4; } + h1 { font-size: 24pt; color: #2271b1; margin: 0; text-align: right; } + h3 { font-size: 11pt; color: #50575e; margin: 15pt 0 5pt 0; } + + .invoice-header { margin-bottom: 30pt; overflow: hidden; } + .logo { float: left; width: 50%; } + .invoice-title { float: right; width: 50%; text-align: right; } + .invoice-number { font-size: 14pt; font-weight: bold; color: #333; margin: 5pt 0; } + .invoice-date { font-size: 10pt; color: #787c82; margin: 0; } + + .addresses { margin-bottom: 20pt; overflow: hidden; } + .address { width: 48%; } + .address.from { float: left; } + .address.to { float: right; } + .address p { margin: 2pt 0; font-size: 9pt; } + + .booking-details { margin-bottom: 20pt; background: #f9f9f9; padding: 10pt; border-radius: 4pt; } + .details-table { width: 100%; border-collapse: collapse; } + .details-table td { padding: 4pt 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; } + .details-table td:first-child { width: 120pt; } + + .line-items { margin-bottom: 20pt; } + .items-table { width: 100%; border-collapse: collapse; } + .items-table th { background: #f6f7f7; text-align: left; padding: 8pt; font-size: 9pt; border-bottom: 2px solid #c3c4c7; } + .items-table td { padding: 8pt; font-size: 9pt; border-bottom: 1px solid #e1e4e8; } + .items-table .qty, .items-table .price, .items-table .total { text-align: right; width: 70pt; } + + .totals { margin-bottom: 20pt; } + .totals-table { width: 250pt; margin-left: auto; border-collapse: collapse; } + .totals-table td { padding: 6pt 8pt; font-size: 10pt; } + .totals-table td:last-child { text-align: right; } + .totals-table .grand-total td { border-top: 2px solid #333; font-size: 12pt; } + + .payment-info { margin-bottom: 30pt; padding: 10pt; background: #f9f9f9; border-radius: 4pt; } + .payment-info p { margin: 4pt 0; font-size: 9pt; } + .payment-info .paid { color: #00a32a; font-weight: bold; } + .payment-info .unpaid { color: #dba617; font-weight: bold; } + + .footer { text-align: center; margin-top: 40pt; padding-top: 15pt; border-top: 1px solid #c3c4c7; } + .footer p { font-size: 9pt; color: #787c82; margin: 3pt 0; } + .custom-footer { font-size: 8pt; } + '; + } + + /** + * Get business information from settings. + * + * @return array Business info. + */ + private static function get_business_info(): array { + return array( + 'name' => get_option( 'wp_bnb_business_name', get_bloginfo( 'name' ) ), + 'street' => get_option( 'wp_bnb_address_street', '' ), + 'city' => get_option( 'wp_bnb_address_city', '' ), + 'postal' => get_option( 'wp_bnb_address_postal', '' ), + 'country' => get_option( 'wp_bnb_address_country', '' ), + 'email' => get_option( 'wp_bnb_contact_email', get_option( 'admin_email' ) ), + 'phone' => get_option( 'wp_bnb_contact_phone', '' ), + ); + } +} diff --git a/src/Integration/WooCommerce/Manager.php b/src/Integration/WooCommerce/Manager.php new file mode 100644 index 0000000..e68fc2d --- /dev/null +++ b/src/Integration/WooCommerce/Manager.php @@ -0,0 +1,435 @@ + WC product). + ProductSync::init(); + + // Cart handling (booking data, availability, pricing). + CartHandler::init(); + + // Checkout customization. + CheckoutHandler::init(); + + // Order handling (booking creation on payment). + OrderHandler::init(); + + // Invoice generation. + InvoiceGenerator::init(); + + // Refund handling. + RefundHandler::init(); + + // Admin column enhancements. + if ( is_admin() ) { + AdminColumns::init(); + } + } + + /** + * Check if WooCommerce is active. + * + * @return bool + */ + public static function is_wc_active(): bool { + return class_exists( 'WooCommerce' ) || class_exists( 'WC_Product' ); + } + + /** + * Check if WooCommerce integration is enabled. + * + * @return bool + */ + public static function is_enabled(): bool { + return 'yes' === get_option( self::OPTION_ENABLED, 'no' ); + } + + /** + * Enable WooCommerce integration. + * + * @return void + */ + public static function enable(): void { + update_option( self::OPTION_ENABLED, 'yes' ); + } + + /** + * Disable WooCommerce integration. + * + * @return void + */ + public static function disable(): void { + update_option( self::OPTION_ENABLED, 'no' ); + } + + /** + * Check if auto-sync products is enabled. + * + * @return bool + */ + public static function is_auto_sync_enabled(): bool { + return 'yes' === get_option( self::OPTION_AUTO_SYNC, 'yes' ); + } + + /** + * Check if auto-confirm booking is enabled. + * + * @return bool + */ + public static function is_auto_confirm_enabled(): bool { + return 'yes' === get_option( self::OPTION_AUTO_CONFIRM, 'yes' ); + } + + /** + * Check if invoice attachment is enabled. + * + * @return bool + */ + public static function is_invoice_attach_enabled(): bool { + return 'yes' === get_option( self::OPTION_INVOICE_ATTACH, 'yes' ); + } + + /** + * Get the WooCommerce product category for rooms. + * + * @return int Product category term ID, or 0 if not set. + */ + public static function get_product_category(): int { + return absint( get_option( self::OPTION_PRODUCT_CATEGORY, 0 ) ); + } + + /** + * Get invoice number prefix. + * + * @return string + */ + public static function get_invoice_prefix(): string { + return get_option( self::OPTION_INVOICE_PREFIX, 'INV-' ); + } + + /** + * Get invoice starting number. + * + * @return int + */ + public static function get_invoice_start_number(): int { + return absint( get_option( self::OPTION_INVOICE_START_NUMBER, 1000 ) ); + } + + /** + * Get and increment the next invoice number. + * + * @return string The next invoice number with prefix. + */ + public static function get_next_invoice_number(): string { + $last_number = absint( get_option( self::OPTION_INVOICE_LAST_NUMBER, 0 ) ); + $start_number = self::get_invoice_start_number(); + + // Use start number if no invoices generated yet. + $next_number = ( 0 === $last_number ) ? $start_number : $last_number + 1; + + // Update the last number. + update_option( self::OPTION_INVOICE_LAST_NUMBER, $next_number ); + + return self::get_invoice_prefix() . str_pad( (string) $next_number, 5, '0', STR_PAD_LEFT ); + } + + /** + * Get invoice logo attachment ID. + * + * @return int Attachment ID or 0. + */ + public static function get_invoice_logo(): int { + return absint( get_option( self::OPTION_INVOICE_LOGO, 0 ) ); + } + + /** + * Get invoice footer text. + * + * @return string + */ + public static function get_invoice_footer(): string { + return get_option( self::OPTION_INVOICE_FOOTER, '' ); + } + + /** + * Display admin notices. + * + * @return void + */ + public static function admin_notices(): void { + // Show notice if integration enabled but WC not active. + if ( self::is_enabled() && ! self::is_wc_active() ) { + ?> +
+

+ + +

+
+ 'pending', + 'on-hold' => 'pending', + 'processing' => self::is_auto_confirm_enabled() ? 'confirmed' : 'pending', + 'completed' => 'confirmed', + 'cancelled' => 'cancelled', + 'refunded' => 'cancelled', + 'failed' => 'cancelled', + ); + + /** + * Filter the WooCommerce to booking status mapping. + * + * @param array $map Status mapping array. + */ + $map = apply_filters( 'wp_bnb_wc_status_map', $map ); + + return $map[ $wc_status ] ?? 'pending'; + } + + /** + * Map booking status to WooCommerce order status. + * + * @param string $booking_status Booking status. + * @return string WooCommerce order status (without 'wc-' prefix). + */ + public static function map_booking_status_to_wc( string $booking_status ): string { + $map = array( + 'pending' => 'on-hold', + 'confirmed' => 'processing', + 'checked_in' => 'processing', + 'checked_out' => 'completed', + 'cancelled' => 'cancelled', + ); + + return $map[ $booking_status ] ?? 'on-hold'; + } + + /** + * Get WooCommerce order for a booking. + * + * @param int $booking_id Booking post ID. + * @return \WC_Order|null WooCommerce order or null. + */ + public static function get_order_for_booking( int $booking_id ): ?\WC_Order { + $order_id = get_post_meta( $booking_id, self::BOOKING_ORDER_META, true ); + + if ( ! $order_id ) { + return null; + } + + $order = wc_get_order( $order_id ); + + return $order instanceof \WC_Order ? $order : null; + } + + /** + * Get booking ID for a WooCommerce order. + * + * @param \WC_Order|int $order WooCommerce order or order ID. + * @return int|null Booking post ID or null. + */ + public static function get_booking_for_order( $order ): ?int { + if ( is_int( $order ) ) { + $order = wc_get_order( $order ); + } + + if ( ! $order instanceof \WC_Order ) { + return null; + } + + $booking_id = $order->get_meta( self::ORDER_BOOKING_META, true ); + + return $booking_id ? absint( $booking_id ) : null; + } + + /** + * Link a booking to a WooCommerce order (bidirectional). + * + * @param int $booking_id Booking post ID. + * @param \WC_Order $order WooCommerce order. + * @return void + */ + public static function link_booking_to_order( int $booking_id, \WC_Order $order ): void { + // Store order ID in booking meta. + update_post_meta( $booking_id, self::BOOKING_ORDER_META, $order->get_id() ); + + // Store booking ID in order meta (HPOS compatible). + $order->update_meta_data( self::ORDER_BOOKING_META, $booking_id ); + $order->save(); + } +} diff --git a/src/Integration/WooCommerce/OrderHandler.php b/src/Integration/WooCommerce/OrderHandler.php new file mode 100644 index 0000000..39ab9b3 --- /dev/null +++ b/src/Integration/WooCommerce/OrderHandler.php @@ -0,0 +1,584 @@ +get_items() as $item ) { + $room_id = $item->get_meta( '_bnb_room_id', true ); + if ( $room_id ) { + $has_bookings = true; + break; + } + } + + if ( ! $has_bookings ) { + return; + } + + // Create booking. + self::create_booking_from_order( $order ); + } + + /** + * Create booking(s) from WooCommerce order. + * + * @param \WC_Order $order WooCommerce order. + * @return int|null Booking ID or null on failure. + */ + public static function create_booking_from_order( \WC_Order $order ): ?int { + /** + * Fires before creating a booking from an order. + * + * @param \WC_Order $order WooCommerce order. + */ + do_action( 'wp_bnb_wc_before_booking_from_order', $order ); + + // Find or create guest. + $guest_id = self::find_or_create_guest( $order ); + + // Get booking data from order items. + $booking_id = null; + + foreach ( $order->get_items() as $item ) { + $room_id = $item->get_meta( '_bnb_room_id', true ); + + if ( ! $room_id ) { + continue; + } + + $check_in = $item->get_meta( '_bnb_check_in', true ); + $check_out = $item->get_meta( '_bnb_check_out', true ); + $guests = $item->get_meta( '_bnb_guests', true ); + $nights = $item->get_meta( '_bnb_nights', true ); + $services = $item->get_meta( '_bnb_services', true ); + $breakdown = $item->get_meta( '_bnb_price_breakdown', true ); + + // Decode JSON if necessary. + if ( is_string( $services ) ) { + $services = json_decode( $services, true ) ?: array(); + } + if ( is_string( $breakdown ) ) { + $breakdown = json_decode( $breakdown, true ) ?: array(); + } + + // Determine initial status. + $status = Manager::is_auto_confirm_enabled() ? 'confirmed' : 'pending'; + + // Get guest notes. + $guest_notes = $order->get_meta( '_bnb_guest_notes', true ); + + // Create booking post. + $booking_data = array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => self::generate_booking_title( $guest_id, $check_in, $check_out ), + ); + + /** + * Filter the booking data before creation. + * + * @param array $booking_data Booking post data. + * @param \WC_Order $order WooCommerce order. + */ + $booking_data = apply_filters( 'wp_bnb_wc_booking_from_order_data', $booking_data, $order ); + + $booking_id = wp_insert_post( $booking_data ); + + if ( ! $booking_id || is_wp_error( $booking_id ) ) { + continue; + } + + // Store booking meta. + update_post_meta( $booking_id, '_bnb_booking_room_id', $room_id ); + update_post_meta( $booking_id, '_bnb_booking_check_in', $check_in ); + update_post_meta( $booking_id, '_bnb_booking_check_out', $check_out ); + update_post_meta( $booking_id, '_bnb_booking_status', $status ); + update_post_meta( $booking_id, '_bnb_booking_adults', max( 1, (int) $guests ) ); + update_post_meta( $booking_id, '_bnb_booking_children', 0 ); + update_post_meta( $booking_id, '_bnb_booking_guest_notes', $guest_notes ); + update_post_meta( $booking_id, '_bnb_booking_source', 'woocommerce_order_' . $order->get_id() ); + + // Store guest info. + if ( $guest_id ) { + update_post_meta( $booking_id, '_bnb_booking_guest_id', $guest_id ); + update_post_meta( $booking_id, '_bnb_booking_guest_name', Guest::get_full_name( $guest_id ) ); + update_post_meta( $booking_id, '_bnb_booking_guest_email', get_post_meta( $guest_id, '_bnb_guest_email', true ) ); + update_post_meta( $booking_id, '_bnb_booking_guest_phone', get_post_meta( $guest_id, '_bnb_guest_phone', true ) ); + } else { + // Use order billing info. + $guest_name = $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(); + update_post_meta( $booking_id, '_bnb_booking_guest_name', $guest_name ); + update_post_meta( $booking_id, '_bnb_booking_guest_email', $order->get_billing_email() ); + update_post_meta( $booking_id, '_bnb_booking_guest_phone', $order->get_billing_phone() ); + } + + // Store pricing. + $total = $item->get_total(); + update_post_meta( $booking_id, '_bnb_booking_calculated_price', $total ); + + if ( ! empty( $breakdown ) ) { + update_post_meta( $booking_id, '_bnb_booking_price_breakdown', $breakdown ); + } + + // Store services. + if ( ! empty( $services ) ) { + update_post_meta( $booking_id, Booking::SERVICES_META_KEY, $services ); + } + + // Generate booking reference. + $reference = self::generate_booking_reference( $booking_id ); + update_post_meta( $booking_id, '_bnb_booking_reference', $reference ); + + // Store confirmed timestamp if auto-confirmed. + if ( 'confirmed' === $status ) { + update_post_meta( $booking_id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) ); + } + + // Link booking to order. + Manager::link_booking_to_order( $booking_id, $order ); + + // Also store room ID in order meta for quick access. + $order->update_meta_data( Manager::ORDER_ROOM_META, $room_id ); + + // Store check-in/out in order meta. + $order->update_meta_data( '_bnb_check_in', $check_in ); + $order->update_meta_data( '_bnb_check_out', $check_out ); + $order->save(); + + // Trigger status change action for email notifications. + if ( 'confirmed' === $status ) { + /** + * Fires when booking status changes (for email notifications). + * + * @param int $booking_id Booking post ID. + * @param string $old_status Old status. + * @param string $new_status New status. + */ + do_action( 'wp_bnb_booking_status_changed', $booking_id, 'pending', 'confirmed' ); + } + } + + /** + * Fires after creating a booking from an order. + * + * @param int|null $booking_id Last booking ID created. + * @param \WC_Order $order WooCommerce order. + */ + do_action( 'wp_bnb_wc_after_booking_from_order', $booking_id, $order ); + + return $booking_id; + } + + /** + * Sync order status changes to booking status. + * + * @param int $order_id Order ID. + * @param string $old_status Old status. + * @param string $new_status New status. + * @param \WC_Order $order Order object. + * @return void + */ + public static function sync_order_status_to_booking( int $order_id, string $old_status, string $new_status, \WC_Order $order ): void { + // Prevent recursive updates. + if ( self::$updating_status ) { + return; + } + + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + return; + } + + // Map WC status to booking status. + $booking_status = Manager::map_wc_status_to_booking( $new_status ); + + // Get current booking status. + $current_booking_status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + + // Don't update if status is the same. + if ( $current_booking_status === $booking_status ) { + return; + } + + // Don't downgrade from checked_in/checked_out. + if ( in_array( $current_booking_status, array( 'checked_in', 'checked_out' ), true ) ) { + return; + } + + self::$updating_status = true; + + // Update booking status. + update_post_meta( $booking_id, '_bnb_booking_status', $booking_status ); + + // Update confirmed timestamp if confirming. + if ( 'confirmed' === $booking_status && 'pending' === $current_booking_status ) { + update_post_meta( $booking_id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) ); + } + + // Trigger booking status changed action. + do_action( 'wp_bnb_booking_status_changed', $booking_id, $current_booking_status, $booking_status ); + + self::$updating_status = false; + } + + /** + * Find or create guest from order data. + * + * @param \WC_Order $order WooCommerce order. + * @return int|null Guest ID or null. + */ + private static function find_or_create_guest( \WC_Order $order ): ?int { + $email = $order->get_billing_email(); + + if ( ! $email ) { + return null; + } + + // Try to find existing guest by email. + $existing_guests = get_posts( + array( + 'post_type' => Guest::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'meta_query' => array( + array( + 'key' => '_bnb_guest_email', + 'value' => $email, + ), + ), + ) + ); + + if ( ! empty( $existing_guests ) ) { + return $existing_guests[0]->ID; + } + + // Create new guest. + $first_name = $order->get_billing_first_name(); + $last_name = $order->get_billing_last_name(); + $full_name = trim( $first_name . ' ' . $last_name ); + + $guest_data = array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $full_name ?: $email, + ); + + $guest_id = wp_insert_post( $guest_data ); + + if ( ! $guest_id || is_wp_error( $guest_id ) ) { + return null; + } + + // Store guest meta. + update_post_meta( $guest_id, '_bnb_guest_first_name', $first_name ); + update_post_meta( $guest_id, '_bnb_guest_last_name', $last_name ); + update_post_meta( $guest_id, '_bnb_guest_email', $email ); + update_post_meta( $guest_id, '_bnb_guest_phone', $order->get_billing_phone() ); + update_post_meta( $guest_id, '_bnb_guest_address', $order->get_billing_address_1() ); + update_post_meta( $guest_id, '_bnb_guest_city', $order->get_billing_city() ); + update_post_meta( $guest_id, '_bnb_guest_postal_code', $order->get_billing_postcode() ); + update_post_meta( $guest_id, '_bnb_guest_country', $order->get_billing_country() ); + update_post_meta( $guest_id, '_bnb_guest_status', 'active' ); + update_post_meta( $guest_id, '_bnb_guest_source', 'woocommerce' ); + + return $guest_id; + } + + /** + * Generate booking title. + * + * @param int|null $guest_id Guest ID. + * @param string $check_in Check-in date. + * @param string $check_out Check-out date. + * @return string Booking title. + */ + private static function generate_booking_title( ?int $guest_id, string $check_in, string $check_out ): string { + $guest_name = $guest_id ? Guest::get_full_name( $guest_id ) : __( 'Guest', 'wp-bnb' ); + + $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in ); + $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out ); + + if ( ! $check_in_date || ! $check_out_date ) { + return $guest_name; + } + + // Format: "Guest Name (DD.MM - DD.MM.YYYY)" or span years. + if ( $check_in_date->format( 'Y' ) === $check_out_date->format( 'Y' ) ) { + return sprintf( + '%s (%s - %s)', + $guest_name, + $check_in_date->format( 'd.m' ), + $check_out_date->format( 'd.m.Y' ) + ); + } + + return sprintf( + '%s (%s - %s)', + $guest_name, + $check_in_date->format( 'd.m.Y' ), + $check_out_date->format( 'd.m.Y' ) + ); + } + + /** + * Generate booking reference. + * + * @param int $booking_id Booking ID. + * @return string Booking reference. + */ + private static function generate_booking_reference( int $booking_id ): string { + return sprintf( + 'BNB-%s-%05d', + gmdate( 'Y' ), + $booking_id + ); + } + + /** + * Display booking info in admin order page. + * + * @param \WC_Order $order WooCommerce order. + * @return void + */ + public static function display_booking_info_admin( \WC_Order $order ): void { + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + return; + } + + $booking = get_post( $booking_id ); + $status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + $room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true ); + $room = $room_id ? get_post( $room_id ) : null; + ?> + + +
+

+ + + + + + + + + + + + + + + + + + + + + +
+ post_title ); ?> + +
post_title ); ?> + +
format( $date_format ) : $check_in ); ?>
format( $date_format ) : $check_out ); ?>
+

+ +

+
+ +

+ + + + + +

+ post_status ) { + return; + } + + // Check if auto-sync is enabled. + if ( ! Manager::is_auto_sync_enabled() ) { + return; + } + + // Sync the product. + self::sync_room_to_product( $post_id ); + } + + /** + * Handle room deletion - delete linked product. + * + * @param int $post_id Post ID. + * @return void + */ + public static function on_room_delete( int $post_id ): void { + $post = get_post( $post_id ); + + if ( ! $post || Room::POST_TYPE !== $post->post_type ) { + return; + } + + self::delete_product_for_room( $post_id ); + } + + /** + * Sync a room to a WooCommerce product. + * + * Creates a new product if one doesn't exist, or updates existing one. + * + * @param int $room_id Room post ID. + * @return int|null Product ID or null on failure. + */ + public static function sync_room_to_product( int $room_id ): ?int { + $room = get_post( $room_id ); + + if ( ! $room || Room::POST_TYPE !== $room->post_type ) { + return null; + } + + // Check for existing product. + $product_id = self::get_product_for_room( $room_id ); + + if ( $product_id ) { + // Update existing product. + return self::update_product( $product_id, $room ); + } + + // Create new product. + return self::create_product_for_room( $room ); + } + + /** + * Create a WooCommerce product for a room. + * + * @param \WP_Post $room Room post object. + * @return int|null Product ID or null on failure. + */ + public static function create_product_for_room( \WP_Post $room ): ?int { + /** + * Fires before creating a WC product for a room. + * + * @param int $room_id Room post ID. + */ + do_action( 'wp_bnb_wc_before_product_sync', $room->ID ); + + // Create a simple virtual product. + $product = new \WC_Product_Simple(); + + // Configure the product. + self::configure_product( $product, $room ); + + // Save the product. + $product_id = $product->save(); + + if ( ! $product_id ) { + return null; + } + + // Store bidirectional links. + update_post_meta( $room->ID, Manager::ROOM_PRODUCT_META, $product_id ); + update_post_meta( $product_id, Manager::PRODUCT_ROOM_META, $room->ID ); + + /** + * Fires after creating a WC product for a room. + * + * @param int $room_id Room post ID. + * @param int $product_id WC product ID. + */ + do_action( 'wp_bnb_wc_after_product_sync', $room->ID, $product_id ); + + return $product_id; + } + + /** + * Update an existing WooCommerce product. + * + * @param int $product_id Product ID. + * @param \WP_Post $room Room post object. + * @return int|null Product ID or null on failure. + */ + private static function update_product( int $product_id, \WP_Post $room ): ?int { + $product = wc_get_product( $product_id ); + + if ( ! $product ) { + // Product was deleted, create a new one. + return self::create_product_for_room( $room ); + } + + /** + * Fires before updating a WC product for a room. + * + * @param int $room_id Room post ID. + */ + do_action( 'wp_bnb_wc_before_product_sync', $room->ID ); + + // Configure the product. + self::configure_product( $product, $room ); + + // Save the product. + $product->save(); + + /** + * Fires after updating a WC product for a room. + * + * @param int $room_id Room post ID. + * @param int $product_id WC product ID. + */ + do_action( 'wp_bnb_wc_after_product_sync', $room->ID, $product_id ); + + return $product_id; + } + + /** + * Configure a WooCommerce product from room data. + * + * @param \WC_Product $product Product object. + * @param \WP_Post $room Room post object. + * @return void + */ + private static function configure_product( \WC_Product $product, \WP_Post $room ): void { + // Get room data. + $building = Room::get_building( $room->ID ); + $building_name = $building ? $building->post_title : ''; + $pricing = Calculator::getRoomPricing( $room->ID ); + + // Basic info. + $product->set_name( $room->post_title ); + $product->set_slug( 'bnb-room-' . $room->ID ); + $product->set_status( 'publish' ); + + // SKU. + $product->set_sku( 'bnb-room-' . $room->ID ); + + // Virtual product (no shipping). + $product->set_virtual( true ); + + // Description. + $description = $room->post_content; + if ( $building_name ) { + $description = sprintf( + /* translators: %s: Building name */ + __( 'Room at %s', 'wp-bnb' ), + $building_name + ) . "\n\n" . $description; + } + $product->set_description( $description ); + $product->set_short_description( $room->post_excerpt ?: wp_trim_words( $room->post_content, 30 ) ); + + // Price (use short-term/nightly rate as base). + $base_price = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? 0; + if ( $base_price > 0 ) { + $product->set_regular_price( (string) $base_price ); + } + + // Featured image. + $thumbnail_id = get_post_thumbnail_id( $room->ID ); + if ( $thumbnail_id ) { + $product->set_image_id( $thumbnail_id ); + } + + // Gallery images. + $gallery_ids = get_post_meta( $room->ID, '_bnb_room_gallery', true ); + if ( $gallery_ids ) { + $ids = array_filter( explode( ',', $gallery_ids ), 'is_numeric' ); + $product->set_gallery_image_ids( array_map( 'absint', $ids ) ); + } + + // Stock management (disabled - availability handled by booking system). + $product->set_manage_stock( false ); + $product->set_stock_status( 'instock' ); + + // Catalog visibility - visible. + $product->set_catalog_visibility( 'visible' ); + + // Product category. + $category_id = Manager::get_product_category(); + if ( $category_id ) { + $product->set_category_ids( array( $category_id ) ); + } + + // Store room metadata. + $capacity = get_post_meta( $room->ID, '_bnb_room_capacity', true ); + $beds = get_post_meta( $room->ID, '_bnb_room_beds', true ); + + $product->update_meta_data( '_bnb_room_capacity', $capacity ); + $product->update_meta_data( '_bnb_room_beds', $beds ); + $product->update_meta_data( '_bnb_building_id', $building ? $building->ID : 0 ); + $product->update_meta_data( '_bnb_building_name', $building_name ); + + /** + * Filter the product data before save. + * + * @param array $data Product data array. + * @param int $room_id Room post ID. + * @param \WP_Post $room Room post object. + */ + $data = apply_filters( + 'wp_bnb_wc_product_data', + array( + 'name' => $product->get_name(), + 'price' => $product->get_regular_price(), + 'description' => $product->get_description(), + ), + $room->ID, + $room + ); + + // Apply filtered data. + if ( isset( $data['name'] ) ) { + $product->set_name( $data['name'] ); + } + if ( isset( $data['price'] ) ) { + $product->set_regular_price( (string) $data['price'] ); + } + if ( isset( $data['description'] ) ) { + $product->set_description( $data['description'] ); + } + } + + /** + * Delete the WooCommerce product for a room. + * + * @param int $room_id Room post ID. + * @return bool True if deleted, false otherwise. + */ + public static function delete_product_for_room( int $room_id ): bool { + $product_id = self::get_product_for_room( $room_id ); + + if ( ! $product_id ) { + return false; + } + + $product = wc_get_product( $product_id ); + + if ( ! $product ) { + return false; + } + + // Delete the product (force delete, not trash). + $product->delete( true ); + + // Clean up room meta. + delete_post_meta( $room_id, Manager::ROOM_PRODUCT_META ); + + return true; + } + + /** + * Get the WooCommerce product ID for a room. + * + * @param int $room_id Room post ID. + * @return int|null Product ID or null. + */ + public static function get_product_for_room( int $room_id ): ?int { + $product_id = get_post_meta( $room_id, Manager::ROOM_PRODUCT_META, true ); + + if ( ! $product_id ) { + return null; + } + + // Verify product still exists. + $product = wc_get_product( $product_id ); + + if ( ! $product ) { + // Clean up stale meta. + delete_post_meta( $room_id, Manager::ROOM_PRODUCT_META ); + return null; + } + + return absint( $product_id ); + } + + /** + * Get the room ID for a WooCommerce product. + * + * @param int $product_id Product ID. + * @return int|null Room ID or null. + */ + public static function get_room_for_product( int $product_id ): ?int { + $room_id = get_post_meta( $product_id, Manager::PRODUCT_ROOM_META, true ); + + if ( ! $room_id ) { + return null; + } + + // Verify room still exists. + $room = get_post( $room_id ); + + if ( ! $room || Room::POST_TYPE !== $room->post_type ) { + // Clean up stale meta. + delete_post_meta( $product_id, Manager::PRODUCT_ROOM_META ); + return null; + } + + return absint( $room_id ); + } + + /** + * Sync all published rooms to WooCommerce products. + * + * @return array{created: int, updated: int, errors: array} + */ + public static function sync_all_rooms(): array { + $result = array( + 'created' => 0, + 'updated' => 0, + 'errors' => array(), + ); + + $rooms = get_posts( + array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + + foreach ( $rooms as $room_id ) { + $existing_product = self::get_product_for_room( $room_id ); + + $product_id = self::sync_room_to_product( $room_id ); + + if ( $product_id ) { + if ( $existing_product ) { + ++$result['updated']; + } else { + ++$result['created']; + } + } else { + $room = get_post( $room_id ); + $result['errors'][] = sprintf( + /* translators: %s: Room title */ + __( 'Failed to sync room: %s', 'wp-bnb' ), + $room ? $room->post_title : "#{$room_id}" + ); + } + } + + return $result; + } + + /** + * AJAX handler for syncing all rooms. + * + * @return void + */ + public static function ajax_sync_all_rooms(): void { + check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-bnb' ) ) ); + } + + $result = self::sync_all_rooms(); + + wp_send_json_success( + array( + 'message' => sprintf( + /* translators: 1: Created count, 2: Updated count */ + __( 'Sync complete. Created: %1$d, Updated: %2$d', 'wp-bnb' ), + $result['created'], + $result['updated'] + ), + 'created' => $result['created'], + 'updated' => $result['updated'], + 'errors' => $result['errors'], + ) + ); + } + + /** + * Add linked room info to WooCommerce product edit screen. + * + * @return void + */ + public static function add_product_room_info(): void { + global $post; + + if ( ! $post ) { + return; + } + + $room_id = self::get_room_for_product( $post->ID ); + + if ( ! $room_id ) { + return; + } + + $room = get_post( $room_id ); + + if ( ! $room ) { + return; + } + + ?> +
+

+ + + + post_title ); ?> + +
+ +
+

+
+ get_meta( Manager::PRODUCT_ROOM_META, true ); + + return ! empty( $room_id ); + } +} diff --git a/src/Integration/WooCommerce/RefundHandler.php b/src/Integration/WooCommerce/RefundHandler.php new file mode 100644 index 0000000..9541f66 --- /dev/null +++ b/src/Integration/WooCommerce/RefundHandler.php @@ -0,0 +1,394 @@ +get_total_refunded(); + self::cancel_booking_on_refund( $booking_id, $total_refunded, __( 'Order fully refunded', 'wp-bnb' ) ); + } + + /** + * Check if the order is fully refunded. + * + * @param \WC_Order $order WooCommerce order. + * @return bool + */ + public static function is_full_refund( \WC_Order $order ): bool { + $order_total = floatval( $order->get_total() ); + $total_refunded = floatval( $order->get_total_refunded() ); + + // Consider it full refund if refunded amount >= order total. + return $total_refunded >= $order_total; + } + + /** + * Cancel booking on full refund. + * + * @param int $booking_id Booking ID. + * @param float $refund_amount Refund amount. + * @param string $reason Refund reason. + * @return void + */ + public static function cancel_booking_on_refund( int $booking_id, float $refund_amount, string $reason ): void { + // Get current status. + $old_status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + + // Don't cancel if already cancelled. + if ( 'cancelled' === $old_status ) { + // Just update refund info. + self::record_refund_meta( $booking_id, $refund_amount, $reason ); + return; + } + + /** + * Filter whether to cancel booking on refund. + * + * @param bool $cancel Whether to cancel. + * @param \WC_Order $order WooCommerce order. + * @param float $refund_amount Refund amount. + */ + $should_cancel = apply_filters( 'wp_bnb_wc_should_cancel_on_refund', true, $booking_id, $refund_amount ); + + if ( ! $should_cancel ) { + self::record_partial_refund( $booking_id, $refund_amount, $reason ); + return; + } + + // Update booking status to cancelled. + update_post_meta( $booking_id, '_bnb_booking_status', 'cancelled' ); + + // Store refund information. + self::record_refund_meta( $booking_id, $refund_amount, $reason ); + + // Add cancellation note. + $note = sprintf( + /* translators: %s: Refund amount */ + __( 'Booking cancelled due to WooCommerce refund (%s)', 'wp-bnb' ), + wc_price( $refund_amount ) + ); + + $existing_notes = get_post_meta( $booking_id, '_bnb_booking_notes', true ); + $new_notes = $existing_notes ? $existing_notes . "\n\n" . $note : $note; + update_post_meta( $booking_id, '_bnb_booking_notes', $new_notes ); + + /** + * Fires when booking status changes (triggers email notifications). + * + * @param int $booking_id Booking ID. + * @param string $old_status Old status. + * @param string $new_status New status. + */ + do_action( 'wp_bnb_booking_status_changed', $booking_id, $old_status, 'cancelled' ); + } + + /** + * Record partial refund without cancelling. + * + * @param int $booking_id Booking ID. + * @param float $refund_amount Refund amount. + * @param string $reason Refund reason. + * @return void + */ + private static function record_partial_refund( int $booking_id, float $refund_amount, string $reason ): void { + // Get existing refund amount and add to it. + $existing_refund = floatval( get_post_meta( $booking_id, self::REFUND_AMOUNT_META, true ) ); + $total_refund = $existing_refund + $refund_amount; + + // Update refund meta. + self::record_refund_meta( $booking_id, $total_refund, $reason ); + + // Add note about partial refund. + $note = sprintf( + /* translators: %s: Refund amount */ + __( 'Partial refund processed: %s', 'wp-bnb' ), + wc_price( $refund_amount ) + ); + + $existing_notes = get_post_meta( $booking_id, '_bnb_booking_notes', true ); + $new_notes = $existing_notes ? $existing_notes . "\n\n" . $note : $note; + update_post_meta( $booking_id, '_bnb_booking_notes', $new_notes ); + } + + /** + * Record refund metadata. + * + * @param int $booking_id Booking ID. + * @param float $refund_amount Total refund amount. + * @param string $reason Refund reason. + * @return void + */ + private static function record_refund_meta( int $booking_id, float $refund_amount, string $reason ): void { + update_post_meta( $booking_id, self::REFUND_AMOUNT_META, $refund_amount ); + update_post_meta( $booking_id, self::REFUND_DATE_META, current_time( 'mysql' ) ); + + if ( $reason ) { + update_post_meta( $booking_id, self::REFUND_REASON_META, $reason ); + } + } + + /** + * Calculate refund amount for a booking. + * + * @param int $booking_id Booking ID. + * @param string $type Refund type: 'full' or 'nights_remaining'. + * @return float Refund amount. + */ + public static function calculate_refund_amount( int $booking_id, string $type = 'full' ): float { + $calculated_price = floatval( get_post_meta( $booking_id, '_bnb_booking_calculated_price', true ) ); + + if ( 'full' === $type ) { + return $calculated_price; + } + + // Calculate pro-rata based on nights remaining. + $check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true ); + + if ( ! $check_in || ! $check_out ) { + return $calculated_price; + } + + $today = new \DateTime( 'today' ); + $check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in ); + $check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out ); + + if ( ! $check_in_date || ! $check_out_date ) { + return $calculated_price; + } + + // If check-in hasn't happened, full refund. + if ( $today < $check_in_date ) { + return $calculated_price; + } + + // If check-out has passed, no refund. + if ( $today >= $check_out_date ) { + return 0.0; + } + + // Calculate remaining nights. + $total_nights = $check_in_date->diff( $check_out_date )->days; + $nights_used = $check_in_date->diff( $today )->days; + $nights_remaining = $total_nights - $nights_used; + + if ( $total_nights <= 0 ) { + return 0.0; + } + + // Pro-rata refund. + $nightly_rate = $calculated_price / $total_nights; + + return $nightly_rate * $nights_remaining; + } + + /** + * Get refund info for a booking. + * + * @param int $booking_id Booking ID. + * @return array|null Refund info or null. + */ + public static function get_booking_refund_info( int $booking_id ): ?array { + $amount = get_post_meta( $booking_id, self::REFUND_AMOUNT_META, true ); + + if ( ! $amount ) { + return null; + } + + return array( + 'amount' => floatval( $amount ), + 'reason' => get_post_meta( $booking_id, self::REFUND_REASON_META, true ), + 'date' => get_post_meta( $booking_id, self::REFUND_DATE_META, true ), + ); + } + + /** + * Add booking refund notice in admin order page. + * + * @param int $order_id Order ID. + * @return void + */ + public static function add_booking_refund_notice( int $order_id ): void { + $order = wc_get_order( $order_id ); + + if ( ! $order instanceof \WC_Order ) { + return; + } + + $booking_id = Manager::get_booking_for_order( $order ); + + if ( ! $booking_id ) { + return; + } + + $refund_info = self::get_booking_refund_info( $booking_id ); + + if ( ! $refund_info ) { + return; + } + + $booking_status = get_post_meta( $booking_id, '_bnb_booking_status', true ); + ?> + + : + + + + + + + + + + + + ' . esc_html__( 'View booking', 'wp-bnb' ) . '' + ); + ?> + + + + + init_rest_api(); @@ -636,6 +642,10 @@ final class Plugin { class="nav-tab "> + + +
@@ -656,6 +666,9 @@ final class Plugin { case 'api': $this->render_api_settings(); break; + case 'woocommerce': + $this->render_woocommerce_settings(); + break; default: $this->render_general_settings(); break; @@ -1854,6 +1867,9 @@ final class Plugin { case 'api': $this->save_api_settings(); break; + case 'woocommerce': + $this->save_woocommerce_settings(); + break; default: $this->save_general_settings(); break; @@ -2037,6 +2053,379 @@ final class Plugin { settings_errors( 'wp_bnb_settings' ); } + /** + * Render WooCommerce settings tab. + * + * @return void + */ + private function render_woocommerce_settings(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Subtab switching only. + $active_subtab = isset( $_GET['subtab'] ) ? sanitize_key( $_GET['subtab'] ) : 'general'; + $base_url = admin_url( 'admin.php?page=wp-bnb-settings&tab=woocommerce' ); + + $wc_active = class_exists( 'WooCommerce' ); + ?> + + + +
+

+ + +

+
+ + + render_wc_products_subtab( $wc_active ); + break; + case 'orders': + $this->render_wc_orders_subtab( $wc_active ); + break; + case 'invoices': + $this->render_wc_invoices_subtab( $wc_active ); + break; + default: + $this->render_wc_general_subtab( $wc_active ); + break; + } + } + + /** + * Render WooCommerce General subtab. + * + * @param bool $wc_active Whether WooCommerce is active. + * @return void + */ + private function render_wc_general_subtab( bool $wc_active ): void { + ?> +
+ + + +

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

+ +

+ +
+ 'product_cat', + 'hide_empty' => false, + ) + ); + } + ?> +
+ + + +

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

+

+ + +

+

+ +

+ +

+ +

+ +
+ +

+ +

+ +

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

+
    +
  • +
  • +
  • +
+ +
+ + + +

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

+ +

+ +
+