20 Commits
v0.10.0 ... dev

Author SHA1 Message Date
d39abc0dd1 Bump version to 0.12.1
All checks were successful
Create Release Package / build-release (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:27:58 +01:00
a1155af6a0 Fix AJAX handler timing: register directly instead of on woocommerce_loaded
The woocommerce_loaded hook may have already fired by the time our plugin
initializes. Register the AJAX handler directly since we've already verified
WooCommerce is active.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:24:31 +01:00
23f073339a Fix WooCommerce sync button: AJAX handler registration and icon alignment
- Register ProductSync AJAX handler independently from full integration init
- AJAX now available on settings page even when integration is not yet enabled
- Improved icon vertical alignment with explicit margin-top adjustment
- Added better error handling and console logging in JS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:22:14 +01:00
c92be303e8 Fix WooCommerce product sync button functionality
- Move sync handler from wc-integration.js to admin.js (uses wpBnbAdmin)
- Add CSS for button icon vertical alignment with inline-flex
- Add spin animation for sync button icon during operation
- Add sync status styling (success/error colors)
- Add i18n strings for syncing messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:17:17 +01:00
ada838a1e4 Add Dashboard link to plugin action links on plugins page
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:09:15 +01:00
dbd0f3f788 Security audit and bug fixes (v0.12.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m37s
- Complete security audit for WordPress best practices, OWASP Top 10
- Fix Calculator static method calls in API controllers
- Fix EmailNotifier method names in BookingsController
- Fix guest_id type casting in EmailNotifier

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 09:44:23 +01:00
a8e0df99d1 Update CLAUDE.md with v0.11.2/v0.11.3 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:41:43 +01:00
70d588808e Display calendar filters side by side (v0.11.3)
All checks were successful
Create Release Package / build-release (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:40:05 +01:00
0bf7f19ac5 Improve calendar room column with building name display (v0.11.2)
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
- Widen room column to 200px with proper left alignment
- Display building name as second row under room name
- Change table-layout from fixed to auto for flexible columns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:31:45 +01:00
7c0016c244 Bump version to 0.11.1
All checks were successful
Create Release Package / build-release (push) Successful in 1m7s
- Add i18n translation files (German Switzerland)
- Update README with WooCommerce documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:21:17 +01:00
997541ab48 Add i18n translation files for German (Switzerland)
- Extract 1140 translatable strings to wp-bnb.pot
- Create wp-bnb-de_CH.po with 875 (77%) translated strings
- Compile wp-bnb-de_CH.mo for WordPress to use
- Coverage includes: admin UI, post types, taxonomies, settings,
  dashboard, reports, REST API messages, WooCommerce integration,
  CF7 integration, frontend widgets, blocks, and shortcodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:19:34 +01:00
36a69b0de4 Update README with WooCommerce integration documentation
- Add WooCommerce Integration and REST API to key features
- Add WooCommerce 8.0+ to optional requirements
- Add comprehensive WooCommerce Integration section with:
  - Enabling instructions
  - Product sync, cart/checkout, booking creation features
  - Order-booking synchronization details
  - PDF invoice documentation
  - Settings subtabs description
  - HPOS compatibility note
  - Admin columns info
- Update FAQ to reflect WooCommerce is now implemented

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:01:45 +01:00
5d24cfa6f9 Update CLAUDE.md with v0.11.0 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:53:54 +01:00
2865956c56 Add WooCommerce integration for payments, invoices, and order management (v0.11.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m11s
- 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 <noreply@anthropic.com>
2026-02-03 22:40:36 +01:00
965060cc03 Update directory structure in CLAUDE.md and PLAN.md
- Added Api/ directory with all REST API controllers
- Added Admin/Dashboard.php and Admin/Reports.php
- Added Integration/Prometheus.php
- Added License/Updater.php
- Added PostTypes/Guest.php and PostTypes/Service.php
- Added Privacy/Manager.php
- Added Taxonomies/ServiceCategory.php
- Added assets/grafana/ directory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:04:42 +01:00
0e55fae7f2 Update CLAUDE.md with v0.10.0/v0.10.1 session history
- Added comprehensive session entry for REST API Endpoints phase
- Documented all created API files and controllers
- Listed key learnings about WordPress REST API patterns
- Updated with release tags and commit references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:01:32 +01:00
1b6a5a4897 Reorganize roadmap: add Phase 10 API Endpoints, renumber Security Audit
- Phase 10 (API Endpoints v0.10.0) marked complete
- WooCommerce Integration moved from Future to Phase 11 (v0.11.0)
- Security Audit renumbered to Phase 12 (v0.12.0)
- Updated version milestones table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:59:18 +01:00
3f5adfb04e Bump version to 0.10.1 for configurable rate limiting release
All checks were successful
Create Release Package / build-release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:52:00 +01:00
b701d127f8 Add configurable API rate limits with subtabs in settings (v0.10.0)
- Make rate limiting configurable via WordPress options
- Add subtabs to API settings: General, Rate Limits, Endpoints
- Add HTTP method badges for endpoint documentation
- Update CHANGELOG with rate limiting configuration details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:50:41 +01:00
481495805b added MARKETING.md 2026-02-03 21:36:59 +01:00
28 changed files with 16613 additions and 248 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ wp-plugins
wp-core
vendor/
releases/*
MARKETING.md

View File

@@ -5,6 +5,159 @@ 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.12.1] - 2026-02-04
### Fixed
- Fixed WooCommerce product sync button not working on settings page
- Fixed AJAX handler registration timing (now registered directly instead of on `woocommerce_loaded` hook)
- Fixed sync button icon vertical alignment
- Added Dashboard link to WordPress plugins list page
### Added
- Better error handling and console logging for WooCommerce sync AJAX requests
## [0.12.0] - 2026-02-04
### Security
- Completed comprehensive security audit (Phase 12)
- Verified WordPress best practices compliance across entire codebase
- Confirmed protection against SQL Injection: all database queries use `$wpdb->prepare()` or WP_Query
- Confirmed protection against XSS: all output properly escaped with `esc_html()`, `esc_attr()`, `esc_url()`
- Confirmed protection against CSRF: nonce verification on all forms and admin AJAX handlers
- Verified REST API endpoint security: proper permission callbacks, rate limiting, input sanitization
- Sensitive data (ID/passport numbers) properly encrypted and not exposed via API
### Fixed
- Fixed Calculator being called statically in API controllers (`BookingsController`, `RoomsController`, `PricingController`)
- Fixed EmailNotifier method names in BookingsController (`send_admin_new_booking`, `send_cancellation`, `send_guest_confirmation`)
- Fixed guest_id type casting in EmailNotifier (string to int from post meta)
### Notes
- Public AJAX endpoints (search, availability, calendar, price calculation) intentionally do not require nonce verification as they are read-only public APIs with proper input sanitization
- All admin AJAX endpoints properly protected with nonce verification and capability checks
## [0.11.3] - 2026-02-03
### Changed
- Calendar filters now display side by side instead of stacked rows
## [0.11.2] - 2026-02-03
### Changed
- Calendar page room column now wider (200px) with proper left alignment
- Room column displays building name on second row for better identification
- Changed calendar table layout from fixed to auto for flexible column widths
## [0.11.1] - 2026-02-03
### Added
- Internationalization (i18n) support:
- Translation template file `languages/wp-bnb.pot` with 1,140 translatable strings
- German (Switzerland) translation `languages/wp-bnb-de_CH.po` with 77% coverage (875 strings)
- Compiled binary `languages/wp-bnb-de_CH.mo` for WordPress use
- Coverage includes: admin UI, post types, taxonomies, settings, dashboard, reports, REST API, WooCommerce, CF7, widgets, blocks, shortcodes
### Changed
- README.md updated with comprehensive WooCommerce integration documentation
- Added REST API to key features list in README
## [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
- API Settings subtabs for better organization:
- General subtab: Enable/disable REST API, rate limiting toggle, API information
- Rate Limits subtab: Configurable time window and endpoint-specific limits
- Endpoints subtab: Full endpoint documentation with HTTP method badges
- Configurable rate limiting:
- Time window setting (10-300 seconds, default 60)
- Per-endpoint-type limits (public, availability, booking, admin)
- Settings stored in WordPress options with fallback defaults
### Changed
- RateLimiter class now loads limits from WordPress options
- README updated with configurable rate limiting documentation
## [0.10.0] - 2026-02-03
### Added
@@ -616,6 +769,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

373
CLAUDE.md
View File

@@ -237,7 +237,21 @@ wp-bnb/
│ ├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin pages
│ │ ├── Calendar.php # Availability calendar page
│ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ├── Reports.php # Reports page with exports
│ │ └── Seasons.php # Seasons management page
│ ├── Api/ # REST API (v0.10.0+)
│ │ ├── RestApi.php # Main API registration
│ │ ├── RateLimiter.php # Transient-based rate limiting
│ │ ├── ResponseFormatter.php # Standardized responses
│ │ └── Controllers/ # API endpoint controllers
│ │ ├── AbstractController.php
│ │ ├── BookingsController.php
│ │ ├── BuildingsController.php
│ │ ├── GuestsController.php
│ │ ├── PricingController.php
│ │ ├── RoomsController.php
│ │ └── ServicesController.php
│ ├── Blocks/ # Gutenberg blocks
│ │ └── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking system
@@ -250,38 +264,47 @@ wp-bnb/
│ │ ├── AvailabilityCalendar.php
│ │ ├── BuildingRooms.php
│ │ └── SimilarRooms.php
│ ├── License/
│ │ ── Manager.php # License management
│ ├── Integration/ # Third-party integrations
│ │ ── CF7.php # Contact Form 7 integration
│ │ └── Prometheus.php # Prometheus metrics
│ ├── License/ # License management
│ │ ├── Manager.php # License validation and activation
│ │ └── Updater.php # Auto-update system
│ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php # Booking post type
│ │ ├── Building.php # Building post type
│ │ ── Room.php # Room post type
│ │ ── Guest.php # Guest post type
│ │ ├── Room.php # Room post type
│ │ └── Service.php # Service post type
│ ├── Pricing/ # Pricing system
│ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing
│ ├── Integration/ # Third-party integrations
│ │ └── CF7.php # Contact Form 7 integration
│ ├── Privacy/ # Privacy & GDPR
│ │ └── Manager.php # Data export/deletion
│ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenity taxonomy (tags)
── RoomType.php # Room type taxonomy (categories)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/ # License client library
├── vendor/ # Composer dependencies (auto-generated)
├── assets/
── RoomType.php # Room type taxonomy (categories)
│ └── ServiceCategory.php # Service categories
├── assets/ # CSS, JS, images
│ ├── css/
│ │ ├── admin.css # Admin styles
│ │ ├── blocks-editor.css # Gutenberg editor styles
│ │ ├── cf7-integration.css # CF7 form styles
│ │ └── frontend.css # Frontend styles (~1250 lines)
│ │ └── frontend.css # Frontend styles
│ ├── grafana/
│ │ └── wp-bnb-dashboard.json # Pre-configured Grafana dashboard
│ └── js/
│ ├── admin.js # Admin scripts
│ ├── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts (~825 lines)
├── templates/ # Twig templates (future)
├── languages/ # Translation files (future)
└── releases/ # Release packages (git-ignored)
│ └── frontend.js # Frontend scripts
├── languages/ # Translation files (.pot/.po/.mo)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/ # License client library
├── releases/ # Release packages (git-ignored)
├── templates/ # Twig templates (reserved for future)
└── vendor/ # Composer dependencies (auto-generated)
```
### Implementation Details
@@ -992,3 +1015,323 @@ Admin features always work; frontend requires valid license.
- Metrics should be cached or computed efficiently as they're scraped frequently
- Dashboard registration requires file path, title, description, icon, and plugin name
- Settings tab detection uses `$prometheus_active` to show WP Prometheus status
### 2026-02-03 - Version 0.10.0/0.10.1 (REST API Endpoints)
**Completed:**
- Created `src/Api/RestApi.php` main registration class
- Namespace constant: `wp-bnb/v1`
- Controller initialization and route registration
- Integration with Plugin class via `rest_api_init` hook
- Created `src/Api/RateLimiter.php`
- Transient-based rate limiting per client (user ID or IP)
- Tiered limits: public (60/min), availability (30/min), booking (10/min), admin (120/min)
- Configurable via WordPress options with fallback defaults
- `check()`, `get_retry_after()`, `get_rate_limit_info()` methods
- Created `src/Api/ResponseFormatter.php`
- Standardized success/error responses
- `success()`, `collection()`, `created()` methods
- Error helpers: `validation_error()`, `not_found()`, `forbidden()`, `conflict()`, `rate_limit_error()`
- Created `src/Api/Controllers/AbstractController.php`
- Base class extending `WP_REST_Controller`
- Rate limit checking and header injection
- Client IP detection (supports Cloudflare, proxies)
- Common permission callbacks: `public_permission()`, `admin_permission()`, `manage_bookings_permission()`
- Helper methods: `validate_date()`, `validate_future_date()`, `get_pagination_params()`, `get_sorting_params()`
- Image formatting: `format_featured_image()`, `format_image()`
- HATEOAS links via `add_links()`
- Created `src/Api/Controllers/BuildingsController.php`
- GET /buildings - List with pagination, search, orderby
- GET /buildings/{id} - Single building with address, contact, rooms count
- GET /buildings/{id}/rooms - Rooms in building
- Created `src/Api/Controllers/RoomsController.php`
- GET /rooms - List with filters (building, room_type, amenities, capacity, status)
- GET /rooms/{id} - Full room data with gallery, pricing, amenities
- GET /rooms/{id}/availability - Check availability using `Availability::check_availability_with_price()`
- GET /rooms/{id}/calendar - Monthly calendar using `Availability::get_calendar_data()`
- Created `src/Api/Controllers/AvailabilityController.php`
- POST /availability/search - Search available rooms with date range, capacity, filters
- Created `src/Api/Controllers/BookingsController.php`
- POST /bookings - Create booking with guest auto-creation, conflict check
- GET /bookings - Admin list with filters (status, room, date range)
- GET /bookings/{id} - Full booking with room, guest, services
- PATCH /bookings/{id} - Update booking details
- DELETE /bookings/{id} - Cancel booking (sets status to cancelled)
- POST /bookings/{id}/confirm - Status transition
- POST /bookings/{id}/check-in - Status transition
- POST /bookings/{id}/check-out - Status transition
- Created `src/Api/Controllers/GuestsController.php`
- GET /guests - Admin list with search, status filter
- GET /guests/{id} - Guest data (excludes encrypted ID numbers)
- GET /guests/search - Quick search by name/email
- GET /guests/{id}/bookings - Guest's booking history
- Created `src/Api/Controllers/ServicesController.php`
- GET /services - List active services with categories
- GET /services/{id} - Service details with pricing info
- Created `src/Api/Controllers/PricingController.php`
- POST /pricing/calculate - Full price breakdown with room, dates, services
- Updated `src/Plugin.php`
- Added API tab to settings page with subtabs (General, Rate Limits, Endpoints)
- Enable/disable API toggle
- Configurable rate limiting with per-endpoint-type limits
- Time window configuration (10-300 seconds)
- Full endpoint documentation with HTTP method badges
- Updated `README.md` with comprehensive REST API documentation
- Endpoint reference tables (public and admin)
- Authentication examples (Application Passwords)
- Rate limiting configuration and response headers
- Code examples for common operations
**Files Created:**
- `src/Api/RestApi.php` - Main API registration
- `src/Api/RateLimiter.php` - Rate limiting
- `src/Api/ResponseFormatter.php` - Response formatting
- `src/Api/Controllers/AbstractController.php` - Base controller
- `src/Api/Controllers/BuildingsController.php` - Buildings endpoints
- `src/Api/Controllers/RoomsController.php` - Rooms endpoints
- `src/Api/Controllers/AvailabilityController.php` - Availability search
- `src/Api/Controllers/BookingsController.php` - Bookings CRUD
- `src/Api/Controllers/GuestsController.php` - Guests endpoints
- `src/Api/Controllers/ServicesController.php` - Services endpoints
- `src/Api/Controllers/PricingController.php` - Pricing calculation
- `MARKETING.md` - Marketing texts for shops (gitignored)
**Files Changed:**
- `src/Plugin.php` - API settings tab with subtabs, RestApi initialization
- `wp-bnb.php` - Version bump to 0.10.0, then 0.10.1
- `CHANGELOG.md` - Added v0.10.0 and v0.10.1 release notes
- `PLAN.md` - Marked Phase 10 as complete, reorganized roadmap
- `README.md` - Added REST API documentation section
- `.gitignore` - Added MARKETING.md to exclusions
**Learnings:**
- WordPress REST API uses `WP_REST_Controller` as base class with `register_routes()` method
- Route registration via `register_rest_route()` with namespace, route pattern, and args
- Permission callbacks return bool; use `current_user_can('edit_posts')` for admin endpoints
- Rate limiting with transients: store count and start time, check against limits
- Transient key should include client identifier and endpoint type hash
- X-RateLimit headers (Limit, Remaining, Reset) provide rate limit info to clients
- Application Passwords (WordPress 5.6+) recommended for external API access
- HATEOAS links added via `_links` key in response
- Conflict detection reuses existing `Availability::check_availability()` method
- Settings subtabs use query parameters (`subtab=general`) with conditional rendering
- Configurable options should have sensible defaults via `get_option($key, $default)`
- Marketing content (MARKETING.md) should be gitignored to keep repo focused on code
**Released:**
- v0.10.0: Committed `81c97c3` - Base REST API implementation
- v0.10.1: Committed `3f5adfb` - Configurable rate limiting with settings subtabs
- Tags: `v0.10.0`, `v0.10.1`
- Pushed to origin: dev, main, both tags
### 2026-02-03 - Version 0.11.0 (WooCommerce Integration)
**Completed:**
- Created `src/Integration/WooCommerce/Manager.php` (~435 lines)
- Core integration manager with option constants
- HPOS (High-Performance Order Storage) compatibility declaration
- `is_wc_active()`, `is_enabled()` checks
- `map_wc_status_to_booking()` for status synchronization
- `get_order_for_booking()`, `get_booking_for_order()` bidirectional lookups
- `link_booking_to_order()` for creating relationships
- Created `src/Integration/WooCommerce/ProductSync.php` (~515 lines)
- Virtual WooCommerce products for rooms (SKU: `bnb-room-{id}`)
- Auto-sync on `save_post_bnb_room` hook
- Product deletion on room deletion
- `sync_all_rooms()` for bulk synchronization
- Bidirectional meta linking (room ↔ product)
- Created `src/Integration/WooCommerce/CartHandler.php` (~545 lines)
- Cart item data structure with booking details
- Availability validation on add-to-cart
- Dynamic price calculation using `Calculator` class
- Cart item display with dates, guests, nights
- Services support in cart items
- Created `src/Integration/WooCommerce/CheckoutHandler.php` (~347 lines)
- Special requests textarea field
- Arrival time dropdown field
- Guest data pre-fill from user profile
- Final availability validation before payment
- Booking summary display
- Created `src/Integration/WooCommerce/OrderHandler.php` (~584 lines)
- Booking creation on `woocommerce_payment_complete`
- Guest record creation from billing info
- Booking reference generation (BNB-YYYY-NNNNN)
- Status synchronization on order status changes
- Order-booking bidirectional linking
- Created `src/Integration/WooCommerce/InvoiceGenerator.php` (~633 lines)
- PDF generation using existing mPDF dependency
- Configurable invoice numbering (prefix + start number)
- Auto-attach to WooCommerce order emails
- Secure storage in `wp-content/uploads/wp-bnb-invoices/`
- .htaccess protection for invoice directory
- Created `src/Integration/WooCommerce/RefundHandler.php` (~394 lines)
- Full refund triggers booking cancellation
- Partial refund records amount without cancellation
- Refund meta storage on booking
- `wp_bnb_wc_should_cancel_on_refund` filter for customization
- Created `src/Integration/WooCommerce/AdminColumns.php` (~282 lines)
- "WC Order" column in bookings list
- "Booking" column in WC orders list (supports HPOS)
- Row actions for cross-navigation
- Created `assets/css/wc-integration.css` (~443 lines)
- Cart booking display styles
- Checkout summary styles
- Booking form styles
- Status badge colors
- Created `assets/js/wc-integration.js` (~358 lines)
- `BookingForm` class for frontend forms
- AJAX sync rooms handler
- AJAX generate invoice handler
- Availability checking with debounce
- Updated `src/Plugin.php`
- WooCommerce initialization when WC is active
- WooCommerce settings tab with 4 subtabs (General, Products, Orders, Invoices)
- Asset enqueuing for WooCommerce integration
**Files Created:**
- `src/Integration/WooCommerce/Manager.php`
- `src/Integration/WooCommerce/ProductSync.php`
- `src/Integration/WooCommerce/CartHandler.php`
- `src/Integration/WooCommerce/CheckoutHandler.php`
- `src/Integration/WooCommerce/OrderHandler.php`
- `src/Integration/WooCommerce/InvoiceGenerator.php`
- `src/Integration/WooCommerce/RefundHandler.php`
- `src/Integration/WooCommerce/AdminColumns.php`
- `assets/css/wc-integration.css`
- `assets/js/wc-integration.js`
**Files Changed:**
- `src/Plugin.php` - WooCommerce initialization and settings tab
- `assets/css/admin.css` - Status badge colors for booking statuses
- `wp-bnb.php` - Version bump to 0.11.0
- `CHANGELOG.md` - Added v0.11.0 release notes
- `PLAN.md` - Marked Phase 11 as complete
**Learnings:**
- WooCommerce HPOS compatibility requires `FeaturesUtil::declare_compatibility()` in `before_woocommerce_init` hook
- Use `$order->get_meta()` / `$order->update_meta_data()` instead of `get_post_meta()` for HPOS compatibility
- Virtual products (`virtual => true`, `downloadable => false`) don't require shipping
- Cart item data stored via `woocommerce_add_cart_item_data` filter persists through session
- Dynamic pricing via `woocommerce_before_calculate_totals` hook with priority 20
- `woocommerce_payment_complete` fires after successful payment, ideal for booking creation
- Invoice attachment via `woocommerce_email_attachments` filter with order and email type detection
- Refund detection: `woocommerce_refund_created` for partial, `woocommerce_order_fully_refunded` for full
- Status badge CSS must be in admin.css for settings page (wc-integration.css is frontend only)
- HPOS orders list uses `manage_woocommerce_page_wc-orders_columns` filter (different from legacy)
**Released:**
- Committed: `2865956` on dev branch
- Merged to main (fast-forward)
- Tagged: `v0.11.0`
- Pushed to origin: dev, main, v0.11.0
### 2026-02-03 - Version 0.11.2/0.11.3 (Calendar UI Improvements)
**Completed:**
- Improved Calendar admin page room column
- Increased room column width from narrow to 200px minimum
- Changed `table-layout` from `fixed` to `auto` for flexible column sizing
- Added building name as second row under room name
- Left-aligned room column content for better readability
- Improved Calendar filter layout
- Changed filter form to flexbox layout (side by side instead of stacked)
- Added gap between filter dropdowns
- Updated responsive styles for smaller screens
**Files Changed:**
- `src/Admin/Calendar.php` - Added building name display in room cell
- `assets/css/admin.css` - Calendar table and filter layout improvements
- `wp-bnb.php` - Version bumps to 0.11.2 and 0.11.3
- `CHANGELOG.md` - Added v0.11.2 and v0.11.3 release notes
**Learnings:**
- CSS `table-layout: fixed` forces equal column widths; use `auto` for content-based sizing
- When a parent container has flexbox but content is in a child element, the flex must be applied to the correct container (form element in this case)
- Higher CSS specificity (`.bnb-calendar-table .bnb-calendar-room`) needed to override inherited styles
**Released:**
- v0.11.2: Calendar room column width and building name display
- v0.11.3: Calendar filters side-by-side layout
- Both versions tagged and pushed to origin
### 2026-02-04 - Version 0.12.0 (Security Audit)
**Completed:**
- Comprehensive security audit (Phase 12)
- WordPress best practices compliance verification
- OWASP Top 10 vulnerability review
- Live API endpoint testing against localhost:9080
**Security Audit Findings:**
1. **SQL Injection:** ✓ PROTECTED
- All `$wpdb` queries use `$wpdb->prepare()` with parameterized queries
- WP_Query used throughout for post queries (inherently safe)
- Format specifiers (`%s`, `%d`) properly used in `$wpdb->update()` calls
2. **XSS (Cross-Site Scripting):** ✓ PROTECTED
- PHP output consistently uses `esc_html()`, `esc_attr()`, `esc_url()`
- JavaScript uses `escapeHtml()` function (textContent/innerHTML pattern)
- Form values properly escaped before output
3. **CSRF (Cross-Site Request Forgery):** ✓ PROTECTED
- All forms use `wp_nonce_field()`
- Admin AJAX handlers use `check_ajax_referer()` or `wp_verify_nonce()`
- Capability checks with `current_user_can()` on privileged operations
- Public AJAX endpoints are read-only and don't modify data
4. **REST API Security:** ✓ PROTECTED
- Permission callbacks on all admin endpoints (`admin_permission`)
- Rate limiting via transient-based `RateLimiter` class
- Input sanitization via `sanitize_callback` on all parameters
- Sensitive data (ID/passport) not exposed via API
5. **Data Encryption:** ✓ IMPLEMENTED
- Guest ID/passport numbers encrypted with AES-256-CBC
- IV stored with encrypted data for secure decryption
**Bugs Fixed During Audit:**
- Fixed `Calculator::calculate()` being called statically (non-static method)
- `src/Api/Controllers/BookingsController.php`
- `src/Api/Controllers/RoomsController.php`
- `src/Api/Controllers/PricingController.php`
- Fixed incorrect EmailNotifier method names in BookingsController
- `send_admin_notification``send_admin_new_booking`
- `send_cancellation_email``send_cancellation`
- `send_confirmation_email``send_guest_confirmation`
- Fixed guest_id type casting in EmailNotifier (string from post meta → int)
**Files Changed:**
- `src/Api/Controllers/BookingsController.php` - Calculator instantiation, EmailNotifier method names
- `src/Api/Controllers/RoomsController.php` - Calculator instantiation
- `src/Api/Controllers/PricingController.php` - Calculator instantiation
- `src/Booking/EmailNotifier.php` - guest_id type casting
- `wp-bnb.php` - Version bump to 0.12.0
- `CHANGELOG.md` - Added v0.12.0 security audit notes
- `PLAN.md` - Marked Phase 12 complete
**Learnings:**
- `get_post_meta()` always returns strings; cast to `(int)` when needed for type-hinted methods
- Static vs instance method calls must match the method declaration
- Public frontend AJAX endpoints can safely skip nonce verification if they're read-only
- Rate limiting headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) provide client feedback
- WordPress REST API permission callbacks should use capability checks, not user login status alone

149
PLAN.md
View File

@@ -204,21 +204,22 @@ 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: Security Audit (v0.11.0)
### Phase 11: WooCommerce Integration (v0.11.0) - Complete
- [ ] Check for Wordpress best-practices
- [ ] Review the code for OWASP Top 10, including XSS, XSRF, SQLi and other critical threads
- [ ] Test the API-Endpoints against a local live system under <http://localhost:9080/> for common vulnerabilities
- [x] Payment processing
- [x] Invoice generation
- [x] Order management
- [x] Refund handling
## Phase 12: Security Audit (v0.12.0) - Complete
- [x] Check for WordPress best-practices
- [x] Review the code for OWASP Top 10, including XSS, CSRF, SQLi and other critical threats
- [x] Test the API-Endpoints against a local live system under <http://localhost:9080/> for common vulnerabilities
- [x] Fix bugs discovered during security audit
## Future Considerations (v1.0.0+)
### WooCommerce Integration (Optional)
- [ ] Payment processing
- [ ] Invoice generation
- [ ] Order management
- [ ] Refund handling
### Multi-language Support
- [ ] Full translation support
@@ -238,53 +239,90 @@ This document outlines the implementation plan for the WP BnB Management plugin.
```text
wp-bnb/
├── wp-bnb.php # Main plugin file
├── composer.json # Dependencies
├── src/ # PHP source (PSR-4)
│ ├── Plugin.php # Main plugin class
│ ├── License/ # License management
└── Manager.php
│ ├── PostTypes/ # Custom post types
├── Building.php
├── Room.php
├── Booking.php
│ │ ├── Guest.php
│ └── Service.php
├── Taxonomies/ # Custom taxonomies
├── RoomType.php
│ └── Amenity.php
│ ├── Admin/ # Admin functionality
│ │ ├── Dashboard.php
│ │ ├── Settings.php
│ │ ── MetaBoxes.php
├── Frontend/ # Frontend functionality
│ ├── Shortcodes.php
│ │ ├── Widgets.php
│ │ ── Search.php
├── wp-bnb.php # Main plugin file (entry point)
├── composer.json # Composer configuration
├── composer.lock # Dependency lock file
├── CHANGELOG.md # Version history
├── CLAUDE.md # AI assistant documentation
├── PLAN.md # Implementation roadmap
├── README.md # User documentation
├── .editorconfig # Editor configuration
├── .gitignore # Git ignore patterns
├── .gitmodules # Git submodule configuration
├── .gitea/
│ └── workflows/
└── release.yml # CI/CD release pipeline
├── src/ # PHP source (PSR-4: Magdev\WpBnb)
├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin pages
│ │ ├── Calendar.php # Availability calendar page
│ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ── Reports.php # Reports page with exports
│ └── Seasons.php # Seasons management page
│ ├── Api/ # REST API (v0.10.0+)
│ │ ├── RestApi.php # Main API registration
│ │ ── RateLimiter.php # Transient-based rate limiting
│ │ ├── ResponseFormatter.php # Standardized responses
│ │ └── Controllers/ # API endpoint controllers
│ │ ├── AbstractController.php
│ │ ├── BookingsController.php
│ │ ├── BuildingsController.php
│ │ ├── GuestsController.php
│ │ ├── PricingController.php
│ │ ├── RoomsController.php
│ │ └── ServicesController.php
│ ├── Blocks/ # Gutenberg blocks
│ │ ── Building.php
│ │ ├── Room.php
│ │ └── Search.php
│ ├── Pricing/ # Pricing logic
│ │ ├── Calculator.php
│ │ └── PricingTier.php
│ │ ── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking logic
│ │ ├── Manager.php
│ │ ── Calendar.php
│ └── Workflow.php
└── Integration/ # Third-party integrations
── CF7.php
├── templates/ # Twig templates
├── admin/
├── frontend/
└── email/
│ │ ├── Availability.php # Availability checking
│ │ ── EmailNotifier.php # Email notifications
├── Frontend/ # Frontend components
│ ├── Search.php # Room search and AJAX handlers
── Shortcodes.php # All shortcode handlers
│ │ └── Widgets/ # WordPress widgets
│ ├── AvailabilityCalendar.php
│ ├── BuildingRooms.php
│ └── SimilarRooms.php
│ ├── Integration/ # Third-party integrations
│ │ ├── CF7.php # Contact Form 7 integration
│ │ └── Prometheus.php # Prometheus metrics
│ ├── License/ # License management
│ │ ├── Manager.php # License validation and activation
│ │ └── Updater.php # Auto-update system
│ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php
│ │ ├── Building.php
│ │ ├── Guest.php
│ │ ├── Room.php
│ │ └── Service.php
│ ├── Pricing/ # Pricing logic
│ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing
│ ├── Privacy/ # Privacy & GDPR
│ │ └── Manager.php # Data export/deletion
│ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenities (tags)
│ ├── RoomType.php # Room types (categories)
│ └── ServiceCategory.php # Service categories
├── assets/ # CSS, JS, images
│ ├── css/
│ ├── js/
└── images/
├── languages/ # Translation files
│ ├── admin.css # Admin styles
│ ├── blocks-editor.css # Gutenberg editor styles
├── cf7-integration.css # CF7 form styles
│ │ └── frontend.css # Frontend styles
│ ├── grafana/
│ │ └── wp-bnb-dashboard.json # Pre-configured Grafana dashboard
│ └── js/
│ ├── admin.js # Admin scripts
│ ├── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts
├── languages/ # Translation files (.pot/.po/.mo)
├── lib/ # Git submodules
│ └── wc-licensed-product-client/
├── releases/ # Release packages (git-ignored)
├── templates/ # Twig templates (reserved for future)
└── vendor/ # Composer dependencies
```
@@ -310,7 +348,7 @@ The plugin will provide extensive hooks for customization:
## Version Milestones
| Version | Focus | Target |
| ------- | ------------------ | -------- |
| ------- | ----------------------- | -------- |
| 0.0.1 | Initial setup | Complete |
| 0.1.0 | Data structures | Complete |
| 0.2.0 | Pricing | Complete |
@@ -321,6 +359,7 @@ The plugin will provide extensive hooks for customization:
| 0.7.0 | CF7 Integration | Complete |
| 0.8.0 | Dashboard | Complete |
| 0.9.0 | Prometheus Metrics | Complete |
| 0.10.0 | API Endpoints | TBD |
| 0.11.0 | Security Audit | TBD |
| 0.10.0 | API Endpoints | Complete |
| 0.11.0 | WooCommerce Integration | Complete |
| 0.12.0 | Security Audit | Complete |
| 1.0.0 | Stable Release | TBD |

112
README.md
View File

@@ -19,9 +19,11 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- **Auto-Updates**: Automatic update checks and installation from license server
- **Development Mode**: License bypass for local development environments
- **Contact Form 7 Integration**: Accept booking requests and inquiries through CF7 forms
- **WooCommerce Integration**: Accept payments, auto-sync rooms as products, generate invoices
- **Dashboard**: Comprehensive admin dashboard with statistics and charts
- **Reports**: Detailed reports with CSV and PDF export
- **Prometheus Metrics**: Expose operational metrics for monitoring with Grafana
- **REST API**: Comprehensive API for external integrations
### Requirements
@@ -29,6 +31,7 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- PHP 8.3 or higher
- Valid license key
- Contact Form 7 (optional, for booking forms)
- WooCommerce 8.0+ (optional, for payments and invoicing)
## Installation
@@ -444,6 +447,95 @@ The dashboard includes:
- Today's check-ins/check-outs
- Trend indicators
## WooCommerce Integration
The plugin integrates with WooCommerce to enable payment processing, automatic invoicing, and seamless order management.
### Enabling WooCommerce Integration
1. Install and activate WooCommerce 8.0 or higher
2. Navigate to **WP BnB → Settings → WooCommerce**
3. Enable "Enable WooCommerce Integration"
4. Configure product sync and invoice settings
### Features
**Product Synchronization:**
- Rooms are automatically synced as virtual WooCommerce products
- Products use SKU format `bnb-room-{id}` for tracking
- Price, description, and images are kept in sync
- Products created/updated on room save, deleted on room deletion
- Manual "Sync All Rooms" button in settings
**Cart & Checkout:**
- Add room bookings to WooCommerce cart with dates and guest count
- Real-time availability validation prevents double-booking
- Dynamic pricing calculated from room rates and services
- Special checkout fields for arrival time and special requests
- Guest information pre-filled from user profile
**Booking Creation:**
- Bookings automatically created on successful payment
- Guest records created from billing information
- Booking linked to WooCommerce order for reference
- Booking reference displayed on order confirmation
**Order-Booking Synchronization:**
- Order status changes sync to booking status:
- Order completed → Booking confirmed
- Order cancelled → Booking cancelled
- Order refunded → Booking cancelled (full refund)
- Partial refunds recorded without cancellation
- Bidirectional linking between orders and bookings
**PDF Invoices:**
- Automatic PDF invoice generation
- Configurable invoice number prefix (default: `INV-`)
- Sequential invoice numbering with configurable start number
- Auto-attach to WooCommerce order emails
- Secure storage in `wp-content/uploads/wp-bnb-invoices/`
- Manual generation from order admin
### WooCommerce Settings
**General Subtab:**
- Enable/disable WooCommerce integration
- Enable automatic product sync
- Enable auto-attach invoices to emails
**Products Subtab:**
- View sync status and product count
- Manual "Sync All Rooms Now" button
- Product category assignment
**Orders Subtab:**
- Order-booking status mapping
- View linked orders and bookings
**Invoices Subtab:**
- Invoice number prefix
- Starting invoice number
- Company details for invoice header
- PDF styling options
### HPOS Compatibility
The integration is fully compatible with WooCommerce High-Performance Order Storage (HPOS). Order meta is accessed using the modern `$order->get_meta()` and `$order->update_meta_data()` methods.
### Admin Columns
- **Bookings list**: "WC Order" column with link to order
- **WooCommerce Orders list**: "Booking" column with link to booking
## REST API
The plugin provides a comprehensive REST API for integration with external applications, mobile apps, and third-party services.
@@ -451,8 +543,10 @@ The plugin provides a comprehensive REST API for integration with external appli
### Enabling the API
1. Navigate to **WP BnB → Settings → API**
2. Enable "Enable REST API"
2. In the **General** subtab, enable "Enable REST API"
3. Optionally enable rate limiting for protection against abuse
4. Configure rate limits in the **Rate Limits** subtab
5. View all available endpoints in the **Endpoints** subtab
### Base URL
@@ -517,15 +611,23 @@ curl -u "username:app-password" https://site.com/wp-json/wp-bnb/v1/bookings
### Rate Limiting
When enabled, rate limits are applied per client (by user ID or IP address):
When enabled, rate limits are applied per client (by user ID or IP address). Configure limits in **Settings → API → Rate Limits**.
| Type | Limit | Applies To |
| ---- | ----- | ---------- |
**Default Limits:**
| Type | Default | Applies To |
| ---- | ------- | ---------- |
| Public | 60/min | Room/building listings |
| Availability | 30/min | Availability and calendar endpoints |
| Booking | 10/min | Booking creation |
| Admin | 120/min | All admin endpoints |
**Configuration Options:**
- **Time Window**: 10-300 seconds (default: 60 seconds)
- **Per-endpoint limits**: Customize for each endpoint type
- **Rate limiting toggle**: Enable/disable without losing settings
Rate limit headers are included in responses:
- `X-RateLimit-Limit`: Maximum requests allowed
@@ -658,7 +760,7 @@ Yes, guest data can be exported and deleted on request, and consent is tracked a
### Does it integrate with WooCommerce?
WooCommerce integration for payments is planned for a future release.
Yes! WooCommerce integration is available for payment processing and invoicing. Rooms are synced as virtual products, bookings are created on successful payment, and PDF invoices are auto-generated and attached to order emails. Navigate to **WP BnB → Settings → WooCommerce** to enable and configure the integration.
### How is guest data secured?

View File

@@ -491,6 +491,37 @@
height: 16px;
}
/* API Method Badges */
.wp-bnb-method {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
.wp-bnb-method.get {
background: #e7f5e7;
color: #1e7e1e;
}
.wp-bnb-method.post {
background: #e7f0f5;
color: #1e5f7e;
}
.wp-bnb-method.patch {
background: #f5f0e7;
color: #7e5f1e;
}
.wp-bnb-method.delete {
background: #f5e7e7;
color: #7e1e1e;
}
/* Form Tables */
.form-table th {
width: 200px;
@@ -567,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;
@@ -895,13 +950,17 @@
/* Calendar Filters */
.bnb-calendar-filters {
display: flex;
gap: 15px;
padding: 15px 20px;
border-bottom: 1px solid #c3c4c7;
background: #f9f9f9;
}
.bnb-calendar-filters form {
display: flex;
align-items: center;
gap: 20px;
}
.bnb-calendar-filters label {
display: flex;
align-items: center;
@@ -921,7 +980,7 @@
.bnb-calendar-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
table-layout: auto;
}
.bnb-calendar-table th,
@@ -942,7 +1001,8 @@
.bnb-calendar-table th.room-header {
text-align: left;
padding-left: 10px;
min-width: 150px;
width: 200px;
min-width: 200px;
}
/* Calendar Day Cell */
@@ -1013,16 +1073,31 @@
}
/* Room Row in Multi-Room Calendar */
.bnb-calendar-room {
.bnb-calendar-table .bnb-calendar-room {
font-weight: 600;
text-align: left;
padding: 8px 10px;
background: #f6f7f7;
min-width: 200px;
white-space: nowrap;
}
.bnb-calendar-room small {
.bnb-calendar-room a {
display: block;
}
.bnb-calendar-room .room-number {
font-weight: normal;
color: #646970;
margin-left: 5px;
}
.bnb-calendar-room .building-name {
display: block;
font-weight: normal;
font-size: 12px;
color: #646970;
margin-top: 2px;
}
/* Calendar Legend */
@@ -1102,8 +1177,9 @@
/* Responsive */
@media screen and (max-width: 782px) {
.bnb-calendar-filters {
.bnb-calendar-filters form {
flex-direction: column;
align-items: stretch;
}
.bnb-calendar-filters select {
@@ -2022,3 +2098,49 @@
width: 16px;
height: 16px;
}
/* ============================================
WooCommerce Sync Button
============================================ */
.bnb-sync-rooms-btn {
display: inline-flex !important;
align-items: center !important;
gap: 6px;
vertical-align: middle;
}
.bnb-sync-rooms-btn .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
display: inline-block;
vertical-align: middle;
margin-top: -2px;
}
.bnb-sync-rooms-btn .dashicons.bnb-spin {
animation: bnb-spin 1s linear infinite;
}
@keyframes bnb-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.sync-status {
margin-left: 10px;
font-style: italic;
}
.sync-status .bnb-sync-success {
color: #00a32a;
}
.sync-status .bnb-sync-error {
color: #d63638;
}

View File

@@ -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;
}
}

View File

@@ -1199,6 +1199,59 @@
});
}
/**
* Initialize WooCommerce sync button.
*/
function initWooCommerceSync() {
var $syncBtn = $('.bnb-sync-rooms-btn');
if (!$syncBtn.length) {
return;
}
$syncBtn.on('click', function(e) {
e.preventDefault();
var $btn = $(this);
var $status = $btn.parent().find('.sync-status');
$btn.prop('disabled', true);
$btn.find('.dashicons').addClass('bnb-spin');
$status.text(wpBnbAdmin.i18n.syncing || 'Syncing...');
$.ajax({
url: wpBnbAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wp_bnb_sync_all_rooms',
nonce: wpBnbAdmin.nonce
},
success: function(response) {
if (response.success) {
$status.html('<span class="bnb-sync-success">' + response.data.message + '</span>');
} else {
var errorMsg = response.data && response.data.message ? response.data.message : 'Unknown error';
$status.html('<span class="bnb-sync-error">' + errorMsg + '</span>');
}
},
error: function(xhr, status, error) {
var errorMsg = wpBnbAdmin.i18n.error || 'Error occurred';
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
errorMsg = xhr.responseJSON.data.message;
} else if (error) {
errorMsg = error;
}
$status.html('<span class="bnb-sync-error">' + errorMsg + '</span>');
console.error('WP-BnB Sync Error:', status, error, xhr.responseText);
},
complete: function() {
$btn.prop('disabled', false);
$btn.find('.dashicons').removeClass('bnb-spin');
}
});
});
}
// Initialize on document ready.
$(document).ready(function() {
initLicenseManagement();
@@ -1214,6 +1267,7 @@
initBookingServices();
initDashboardCharts();
initReportsPage();
initWooCommerceSync();
});
})(jQuery);

358
assets/js/wc-integration.js Normal file
View File

@@ -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('<span class="success">' + response.data.message + '</span>');
} else {
$status.html('<span class="error">' + (response.data?.message || 'Error') + '</span>');
}
},
error: function () {
$status.html('<span class="error">' + (WpBnbWC.settings.i18n?.error || 'Error occurred') + '</span>');
},
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 = '<span class="dashicons dashicons-update"></span>';
break;
case 'available':
icon = '<span class="dashicons dashicons-yes-alt"></span>';
break;
case 'unavailable':
icon = '<span class="dashicons dashicons-no-alt"></span>';
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 = '<div class="price-row"><span>' + (WpBnbWC.settings.i18n?.roomTotal || 'Room') + '</span><span>' + WpBnbWC.formatPrice(roomPrice) + '</span></div>';
if (servicesTotal > 0) {
html += '<div class="price-row"><span>' + (WpBnbWC.settings.i18n?.services || 'Services') + '</span><span>' + WpBnbWC.formatPrice(servicesTotal) + '</span></div>';
}
html += '<div class="price-row total"><span>' + (WpBnbWC.settings.i18n?.total || 'Total') + '</span><span>' + WpBnbWC.formatPrice(grandTotal) + '</span></div>';
$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);

BIN
languages/wp-bnb-de_CH.mo Normal file

Binary file not shown.

5162
languages/wp-bnb-de_CH.po Normal file

File diff suppressed because it is too large Load Diff

5163
languages/wp-bnb.pot Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -257,15 +257,19 @@ final class Calendar {
<?php foreach ( $rooms as $room ) : ?>
<?php
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true );
$room_building = Room::get_building( $room->ID );
$booked_dates = Availability::get_booked_dates( $room->ID, $year, $month );
?>
<tr>
<td class="bnb-calendar-room">
<a href="<?php echo esc_url( get_edit_post_link( $room->ID ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
<?php if ( $room_number ) : ?>
<br><small>#<?php echo esc_html( $room_number ); ?></small>
<span class="room-number">#<?php echo esc_html( $room_number ); ?></span>
<?php endif; ?>
</a>
<?php if ( $room_building ) : ?>
<span class="building-name"><?php echo esc_html( $room_building->post_title ); ?></span>
<?php endif; ?>
</td>
<?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?>

View File

@@ -338,9 +338,9 @@ final class BookingsController extends AbstractController {
$guest_id = $this->find_or_create_guest( $guest );
// Calculate price.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$calculator = new Calculator( $room_id, $check_in, $check_out );
$room_price = $calculator->calculate();
$services = $request->get_param( 'services' ) ?? array();
$room_price = $price['price'] ?? 0;
// Calculate services total.
$services_total = 0;
@@ -410,7 +410,7 @@ final class BookingsController extends AbstractController {
// Send notification email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_admin_notification( $post_id );
EmailNotifier::send_admin_new_booking( $post_id );
}
// Prepare response.
@@ -532,7 +532,7 @@ final class BookingsController extends AbstractController {
// Send cancellation email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_cancellation_email( $id );
EmailNotifier::send_cancellation( $id );
}
return $this->formatter->no_content();
@@ -607,7 +607,7 @@ final class BookingsController extends AbstractController {
if ( 'confirmed' === $new_status ) {
update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_confirmation_email( $id );
EmailNotifier::send_guest_confirmation( $id );
}
}

View File

@@ -136,9 +136,9 @@ final class PricingController extends AbstractController {
$nights = (int) $check_in_date->diff( $check_out_date )->days;
// Calculate room price.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$room_total = $price['price'] ?? 0;
$breakdown = $price['breakdown'] ?? array();
$calculator = new Calculator( $room_id, $check_in, $check_out );
$room_total = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Build night-by-night breakdown.

View File

@@ -400,14 +400,16 @@ final class RoomsController extends AbstractController {
if ( $is_available ) {
// Calculate pricing.
$price = Calculator::calculate( $room_id, $check_in, $check_out );
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
$data['pricing'] = array(
'tier' => $price['breakdown']['tier']->value ?? 'short_term',
'base_rate' => $price['breakdown']['base_price_per_night'] ?? 0,
'total' => $price['price'] ?? 0,
'formatted' => $price['price_formatted'] ?? '',
'tier' => $breakdown['tier']->value ?? 'short_term',
'base_rate' => $breakdown['base_price_per_night'] ?? 0,
'total' => $price,
'formatted' => Calculator::formatPrice( $price ),
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
'breakdown' => $price['breakdown'] ?? array(),
'breakdown' => $breakdown,
);
} else {
// Get conflicts.

View File

@@ -22,23 +22,61 @@ final class RateLimiter {
private const TRANSIENT_PREFIX = 'wp_bnb_rate_';
/**
* Rate limits per minute by endpoint type.
* Default rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private array $limits = array(
private const DEFAULT_LIMITS = array(
'public' => 60, // Public read endpoints.
'availability' => 30, // Availability checks.
'booking' => 10, // Booking creation.
'admin' => 120, // Admin endpoints.
);
/**
* Rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private array $limits;
/**
* Time window in seconds.
*
* @var int
*/
private int $window = 60;
private int $window;
/**
* Constructor.
*/
public function __construct() {
$this->load_limits_from_options();
}
/**
* Load rate limits from WordPress options.
*
* @return void
*/
private function load_limits_from_options(): void {
$this->limits = array(
'public' => (int) get_option( 'wp_bnb_rate_limit_public', self::DEFAULT_LIMITS['public'] ),
'availability' => (int) get_option( 'wp_bnb_rate_limit_availability', self::DEFAULT_LIMITS['availability'] ),
'booking' => (int) get_option( 'wp_bnb_rate_limit_booking', self::DEFAULT_LIMITS['booking'] ),
'admin' => (int) get_option( 'wp_bnb_rate_limit_admin', self::DEFAULT_LIMITS['admin'] ),
);
$this->window = (int) get_option( 'wp_bnb_rate_limit_window', 60 );
}
/**
* Get default rate limits.
*
* @return array<string, int>
*/
public static function get_default_limits(): array {
return self::DEFAULT_LIMITS;
}
/**
* Check if request is within rate limit.

View File

@@ -263,10 +263,10 @@ final class EmailNotifier {
* @return array Guest data with keys: name, first_name, last_name, email, phone, notes, full_address.
*/
private static function get_guest_data( int $booking_id ): array {
$guest_id = get_post_meta( $booking_id, '_bnb_booking_guest_id', true );
$guest_id = (int) get_post_meta( $booking_id, '_bnb_booking_guest_id', true );
// Try to get data from Guest CPT.
if ( $guest_id ) {
if ( $guest_id > 0 ) {
$guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->post_type ) {
$first_name = get_post_meta( $guest_id, '_bnb_guest_first_name', true );

View File

@@ -0,0 +1,282 @@
<?php
/**
* WooCommerce Admin Columns.
*
* Adds cross-reference columns and links between bookings and orders.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Booking;
/**
* Admin Columns class.
*
* Enhances admin list tables with booking-order cross-references.
*/
final class AdminColumns {
/**
* Initialize admin columns.
*
* @return void
*/
public static function init(): void {
// Add WC Order column to bookings list.
add_filter( 'manage_' . Booking::POST_TYPE . '_posts_columns', array( self::class, 'add_booking_columns' ) );
add_action( 'manage_' . Booking::POST_TYPE . '_posts_custom_column', array( self::class, 'render_booking_column' ), 10, 2 );
// Add Booking column to WC orders list.
add_filter( 'manage_edit-shop_order_columns', array( self::class, 'add_order_columns' ) );
add_action( 'manage_shop_order_posts_custom_column', array( self::class, 'render_order_column' ), 10, 2 );
// Also support HPOS orders list.
add_filter( 'manage_woocommerce_page_wc-orders_columns', array( self::class, 'add_order_columns' ) );
add_action( 'manage_woocommerce_page_wc-orders_custom_column', array( self::class, 'render_order_column_hpos' ), 10, 2 );
// Add row actions.
add_filter( 'post_row_actions', array( self::class, 'add_booking_row_actions' ), 10, 2 );
// Add order row actions.
add_filter( 'woocommerce_admin_order_actions', array( self::class, 'add_order_actions' ), 10, 2 );
}
/**
* Add columns to booking admin list.
*
* @param array $columns Existing columns.
* @return array Modified columns.
*/
public static function add_booking_columns( array $columns ): array {
// Insert WC Order column after status.
$new_columns = array();
foreach ( $columns as $key => $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 '<span class="na">&ndash;</span>';
return;
}
$order_number = $order->get_order_number();
$order_status = $order->get_status();
$edit_url = $order->get_edit_order_url();
printf(
'<a href="%s" class="order-view" title="%s">#%s</a>',
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(
'<br><mark class="order-status status-%s"><span>%s</span></mark>',
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 '<span class="na">&ndash;</span>';
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 '<span class="na">&ndash;</span>';
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 '<span class="na">&ndash;</span>';
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(
'<a href="%s" title="%s">%s</a>',
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(
'<br><small>%s - %s</small>',
esc_html( $check_in_date->format( 'd.m' ) ),
esc_html( $check_out_date->format( 'd.m.y' ) )
);
}
}
// Status badge.
if ( $status ) {
printf(
'<br><span class="bnb-status-badge bnb-status-%s" style="font-size: 10px;">%s</span>',
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(
'<a href="%s" aria-label="%s">%s</a>',
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;
}
}

View File

@@ -0,0 +1,545 @@
<?php
/**
* WooCommerce Cart Handler.
*
* Handles cart item data, availability validation, and dynamic pricing.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Cart Handler class.
*
* Manages booking data in the WooCommerce cart, validates availability,
* and calculates dynamic pricing based on dates and services.
*/
final class CartHandler {
/**
* Initialize cart handler.
*
* @return void
*/
public static function init(): void {
// Add booking data to cart item.
add_filter( 'woocommerce_add_cart_item_data', array( self::class, 'add_cart_item_data' ), 10, 3 );
// Restore booking data from session.
add_filter( 'woocommerce_get_cart_item_from_session', array( self::class, 'get_cart_item_from_session' ), 10, 2 );
// Validate before adding to cart.
add_filter( 'woocommerce_add_to_cart_validation', array( self::class, 'validate_add_to_cart' ), 10, 5 );
// Re-validate availability on cart load.
add_action( 'woocommerce_cart_loaded_from_session', array( self::class, 'validate_cart_items' ) );
// Display booking info in cart.
add_filter( 'woocommerce_get_item_data', array( self::class, 'display_cart_item_data' ), 10, 2 );
// Calculate dynamic price.
add_action( 'woocommerce_before_calculate_totals', array( self::class, 'calculate_cart_item_prices' ) );
// Prevent quantity changes for room products.
add_filter( 'woocommerce_quantity_input_args', array( self::class, 'lock_quantity' ), 10, 2 );
// Add booking data to order item meta.
add_action( 'woocommerce_checkout_create_order_line_item', array( self::class, 'add_order_item_meta' ), 10, 4 );
}
/**
* Add a room to cart with booking data.
*
* @param int $room_id Room post ID.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @param int $guests Number of guests.
* @param array $services Array of service selections.
* @return string|bool Cart item key or false on failure.
*/
public static function add_room_to_cart(
int $room_id,
string $check_in,
string $check_out,
int $guests = 1,
array $services = array()
) {
// Get product ID for room.
$product_id = ProductSync::get_product_for_room( $room_id );
if ( ! $product_id ) {
// Try to sync the room first.
$product_id = ProductSync::sync_room_to_product( $room_id );
if ( ! $product_id ) {
wc_add_notice( __( 'Unable to add room to cart. Product not found.', 'wp-bnb' ), 'error' );
return false;
}
}
// Store booking data in session temporarily for the filter.
WC()->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'] . ' &times; ' . $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;
}
}

View File

@@ -0,0 +1,347 @@
<?php
/**
* WooCommerce Checkout Handler.
*
* Handles checkout field customization and validation.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
/**
* Checkout Handler class.
*
* Manages checkout field customization, pre-filling, and validation.
*/
final class CheckoutHandler {
/**
* Initialize checkout handler.
*
* @return void
*/
public static function init(): void {
// Add custom checkout fields.
add_filter( 'woocommerce_checkout_fields', array( self::class, 'add_checkout_fields' ) );
// Pre-fill checkout fields.
add_filter( 'woocommerce_checkout_get_value', array( self::class, 'prefill_checkout_fields' ), 10, 2 );
// Validate checkout.
add_action( 'woocommerce_after_checkout_validation', array( self::class, 'validate_checkout' ), 10, 2 );
// Display booking summary in order review.
add_action( 'woocommerce_review_order_before_submit', array( self::class, 'display_booking_summary' ) );
// Save guest notes to order meta.
add_action( 'woocommerce_checkout_create_order', array( self::class, 'save_guest_notes' ), 10, 2 );
}
/**
* Add custom checkout fields.
*
* @param array $fields Checkout fields.
* @return array Modified checkout fields.
*/
public static function add_checkout_fields( array $fields ): array {
// Only add fields if cart contains BnB bookings.
if ( ! self::cart_has_bookings() ) {
return $fields;
}
// Add special requests field.
$fields['order']['bnb_guest_notes'] = array(
'type' => '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;
}
?>
<div class="bnb-checkout-booking-summary">
<h3><?php esc_html_e( 'Booking Summary', 'wp-bnb' ); ?></h3>
<?php foreach ( $bookings as $booking ) : ?>
<div class="bnb-booking-item">
<strong><?php echo esc_html( $booking['room_name'] ); ?></strong>
<?php if ( $booking['building_name'] ) : ?>
<span class="bnb-building-name"><?php echo esc_html( $booking['building_name'] ); ?></span>
<?php endif; ?>
<div class="bnb-booking-details">
<span>
<?php
printf(
/* translators: 1: Check-in date, 2: Check-out date */
esc_html__( '%1$s to %2$s', 'wp-bnb' ),
esc_html( $booking['check_in'] ),
esc_html( $booking['check_out'] )
);
?>
</span>
<span>
<?php
printf(
/* translators: %d: Number of nights */
esc_html( _n( '%d night', '%d nights', $booking['nights'], 'wp-bnb' ) ),
$booking['nights']
);
?>
</span>
<span>
<?php
printf(
/* translators: %d: Number of guests */
esc_html( _n( '%d guest', '%d guests', $booking['guests'], 'wp-bnb' ) ),
$booking['guests']
);
?>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
/**
* Save guest notes to order meta.
*
* @param \WC_Order $order Order object.
* @param array $data Checkout data.
* @return void
*/
public static function save_guest_notes( \WC_Order $order, array $data ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( isset( $_POST['bnb_guest_notes'] ) && ! empty( $_POST['bnb_guest_notes'] ) ) {
$order->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;
}
}

View File

@@ -0,0 +1,633 @@
<?php
/**
* WooCommerce Invoice Generator.
*
* Generates PDF invoices for WooCommerce orders.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Mpdf\Mpdf;
/**
* Invoice Generator class.
*
* Creates PDF invoices for bookings using mPDF.
*/
final class InvoiceGenerator {
/**
* Invoice storage directory relative to uploads.
*/
private const INVOICE_DIR = 'wp-bnb-invoices';
/**
* Initialize invoice generator.
*
* @return void
*/
public static function init(): void {
// Attach invoice to order emails.
add_filter( 'woocommerce_email_attachments', array( self::class, 'attach_invoice_to_email' ), 10, 4 );
// Add admin order action button.
add_action( 'woocommerce_admin_order_actions_end', array( self::class, 'add_order_action_button' ) );
// AJAX handler for invoice generation.
add_action( 'wp_ajax_wp_bnb_generate_invoice', array( self::class, 'ajax_generate_invoice' ) );
// Handle invoice download.
add_action( 'init', array( self::class, 'handle_invoice_download' ) );
}
/**
* Generate invoice for an order.
*
* @param \WC_Order $order WooCommerce order.
* @return string|null Invoice file path or null on failure.
*/
public static function generate_invoice( \WC_Order $order ): ?string {
/**
* Fires before generating an invoice.
*
* @param \WC_Order $order WooCommerce order.
*/
do_action( 'wp_bnb_wc_before_invoice_generate', $order );
// Get or generate invoice number.
$invoice_number = self::get_invoice_number( $order );
// Get booking data.
$booking_id = Manager::get_booking_for_order( $order );
// Generate HTML content.
$html = self::get_invoice_html( $order, $invoice_number, $booking_id );
// Generate PDF.
try {
$temp_dir = get_temp_dir() . 'mpdf';
if ( ! file_exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
}
$mpdf = new Mpdf(
array(
'mode' => '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, '<?php // Silence is golden.' );
}
}
// Sanitize invoice number for filename.
$safe_number = sanitize_file_name( $invoice_number );
return $invoice_dir . '/invoice-' . $safe_number . '-' . $order->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' )
);
?>
<a class="button tips" href="<?php echo esc_url( $download_url ); ?>"
data-tip="<?php esc_attr_e( 'Download Invoice', 'wp-bnb' ); ?>">
<span class="dashicons dashicons-pdf" style="vertical-align: middle;"></span>
</a>
<?php
}
/**
* AJAX handler for generating invoice.
*
* @return void
*/
public static function ajax_generate_invoice(): void {
check_ajax_referer( 'wp_bnb_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( '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 = '<img src="' . esc_url( $logo_url ) . '" style="max-height: 60px; max-width: 200px;">';
}
}
// Get footer.
$footer_text = Manager::get_invoice_footer();
// Build HTML.
$html = '<html><head><style>' . self::get_invoice_css() . '</style></head><body>';
// Header.
$html .= '<div class="invoice-header">';
$html .= '<div class="logo">' . $logo_html . '</div>';
$html .= '<div class="invoice-title">';
$html .= '<h1>' . esc_html__( 'INVOICE', 'wp-bnb' ) . '</h1>';
$html .= '<p class="invoice-number">' . esc_html( $invoice_number ) . '</p>';
$html .= '<p class="invoice-date">' . esc_html( $order_date ) . '</p>';
$html .= '</div>';
$html .= '</div>';
// Addresses.
$html .= '<div class="addresses">';
// From.
$html .= '<div class="address from">';
$html .= '<h3>' . esc_html__( 'From', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $business['name'] ) . '</strong></p>';
if ( $business['street'] ) {
$html .= '<p>' . esc_html( $business['street'] ) . '</p>';
}
if ( $business['city'] || $business['postal'] ) {
$html .= '<p>' . esc_html( $business['postal'] . ' ' . $business['city'] ) . '</p>';
}
if ( $business['country'] ) {
$html .= '<p>' . esc_html( $business['country'] ) . '</p>';
}
if ( $business['email'] ) {
$html .= '<p>' . esc_html( $business['email'] ) . '</p>';
}
if ( $business['phone'] ) {
$html .= '<p>' . esc_html( $business['phone'] ) . '</p>';
}
$html .= '</div>';
// To.
$html .= '<div class="address to">';
$html .= '<h3>' . esc_html__( 'Bill To', 'wp-bnb' ) . '</h3>';
$html .= '<p><strong>' . esc_html( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ) . '</strong></p>';
if ( $order->get_billing_address_1() ) {
$html .= '<p>' . esc_html( $order->get_billing_address_1() ) . '</p>';
}
if ( $order->get_billing_city() || $order->get_billing_postcode() ) {
$html .= '<p>' . esc_html( $order->get_billing_postcode() . ' ' . $order->get_billing_city() ) . '</p>';
}
if ( $order->get_billing_country() ) {
$html .= '<p>' . esc_html( WC()->countries->countries[ $order->get_billing_country() ] ?? $order->get_billing_country() ) . '</p>';
}
if ( $order->get_billing_email() ) {
$html .= '<p>' . esc_html( $order->get_billing_email() ) . '</p>';
}
$html .= '</div>';
$html .= '</div>';
// Booking details.
if ( $booking_id && $room_name ) {
$html .= '<div class="booking-details">';
$html .= '<h3>' . esc_html__( 'Booking Details', 'wp-bnb' ) . '</h3>';
$html .= '<table class="details-table">';
$html .= '<tr><td><strong>' . esc_html__( 'Room', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $room_name );
if ( $building_name ) {
$html .= ' <small>(' . esc_html( $building_name ) . ')</small>';
}
$html .= '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-in', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_in_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Check-out', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $check_out_display ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Nights', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $nights ) . '</td></tr>';
$html .= '<tr><td><strong>' . esc_html__( 'Guests', 'wp-bnb' ) . '</strong></td><td>' . esc_html( $guests ) . '</td></tr>';
$html .= '</table>';
$html .= '</div>';
}
// Line items.
$html .= '<div class="line-items">';
$html .= '<table class="items-table">';
$html .= '<thead><tr>';
$html .= '<th class="description">' . esc_html__( 'Description', 'wp-bnb' ) . '</th>';
$html .= '<th class="qty">' . esc_html__( 'Qty', 'wp-bnb' ) . '</th>';
$html .= '<th class="price">' . esc_html__( 'Price', 'wp-bnb' ) . '</th>';
$html .= '<th class="total">' . esc_html__( 'Total', 'wp-bnb' ) . '</th>';
$html .= '</tr></thead>';
$html .= '<tbody>';
foreach ( $order->get_items() as $item ) {
$qty = $item->get_quantity();
$total = $item->get_total();
$price = $qty > 0 ? $total / $qty : $total;
$html .= '<tr>';
$html .= '<td class="description">' . esc_html( $item->get_name() ) . '</td>';
$html .= '<td class="qty">' . esc_html( $qty ) . '</td>';
$html .= '<td class="price">' . wc_price( $price ) . '</td>';
$html .= '<td class="total">' . wc_price( $total ) . '</td>';
$html .= '</tr>';
}
$html .= '</tbody>';
$html .= '</table>';
$html .= '</div>';
// Totals.
$html .= '<div class="totals">';
$html .= '<table class="totals-table">';
$html .= '<tr><td>' . esc_html__( 'Subtotal', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_subtotal() ) . '</td></tr>';
if ( $order->get_total_tax() > 0 ) {
$html .= '<tr><td>' . esc_html__( 'Tax', 'wp-bnb' ) . '</td><td>' . wc_price( $order->get_total_tax() ) . '</td></tr>';
}
$html .= '<tr class="grand-total"><td><strong>' . esc_html__( 'Total', 'wp-bnb' ) . '</strong></td><td><strong>' . wc_price( $order->get_total() ) . '</strong></td></tr>';
$html .= '</table>';
$html .= '</div>';
// Payment info.
$html .= '<div class="payment-info">';
$html .= '<p><strong>' . esc_html__( 'Payment Status:', 'wp-bnb' ) . '</strong> ';
if ( $order->is_paid() ) {
$html .= '<span class="paid">' . esc_html__( 'PAID', 'wp-bnb' ) . '</span>';
} else {
$html .= '<span class="unpaid">' . esc_html__( 'PENDING', 'wp-bnb' ) . '</span>';
}
$html .= '</p>';
$html .= '<p><strong>' . esc_html__( 'Payment Method:', 'wp-bnb' ) . '</strong> ' . esc_html( $order->get_payment_method_title() ) . '</p>';
$html .= '</div>';
// Footer.
$html .= '<div class="footer">';
$html .= '<p>' . esc_html__( 'Thank you for your stay!', 'wp-bnb' ) . '</p>';
if ( $footer_text ) {
$html .= '<p class="custom-footer">' . esc_html( $footer_text ) . '</p>';
}
$html .= '</div>';
$html .= '</body></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', '' ),
);
}
}

View File

@@ -0,0 +1,439 @@
<?php
/**
* WooCommerce Integration Manager.
*
* Main integration class that initializes WooCommerce features.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
/**
* WooCommerce Integration Manager class.
*
* Manages the integration with WooCommerce for payment processing,
* invoice generation, order management, and refund handling.
*/
final class Manager {
/**
* Option key for enabling WooCommerce integration.
*/
public const OPTION_ENABLED = 'wp_bnb_wc_enabled';
/**
* Option key for auto-syncing products.
*/
public const OPTION_AUTO_SYNC = 'wp_bnb_wc_auto_sync_products';
/**
* Option key for auto-confirming bookings on payment.
*/
public const OPTION_AUTO_CONFIRM = 'wp_bnb_wc_auto_confirm_booking';
/**
* Option key for attaching invoices to emails.
*/
public const OPTION_INVOICE_ATTACH = 'wp_bnb_wc_invoice_attach';
/**
* Option key for product category.
*/
public const OPTION_PRODUCT_CATEGORY = 'wp_bnb_wc_product_category';
/**
* Option key for services as products.
*/
public const OPTION_SERVICES_AS_PRODUCTS = 'wp_bnb_wc_services_as_products';
/**
* Option key for invoice number prefix.
*/
public const OPTION_INVOICE_PREFIX = 'wp_bnb_invoice_prefix';
/**
* Option key for invoice starting number.
*/
public const OPTION_INVOICE_START_NUMBER = 'wp_bnb_invoice_start_number';
/**
* Option key for last invoice number.
*/
public const OPTION_INVOICE_LAST_NUMBER = 'wp_bnb_invoice_last_number';
/**
* Option key for invoice logo.
*/
public const OPTION_INVOICE_LOGO = 'wp_bnb_invoice_logo';
/**
* Option key for invoice footer text.
*/
public const OPTION_INVOICE_FOOTER = 'wp_bnb_invoice_footer';
/**
* Order meta key for linked booking ID.
*/
public const ORDER_BOOKING_META = '_bnb_booking_id';
/**
* Order meta key for linked room ID.
*/
public const ORDER_ROOM_META = '_bnb_room_id';
/**
* Booking meta key for linked WC order ID.
*/
public const BOOKING_ORDER_META = '_bnb_booking_wc_order_id';
/**
* Room meta key for linked WC product ID.
*/
public const ROOM_PRODUCT_META = '_bnb_wc_product_id';
/**
* Product meta key for linked room ID.
*/
public const PRODUCT_ROOM_META = '_bnb_room_id';
/**
* Cart item data key for booking data.
*/
public const CART_ITEM_KEY = 'bnb_booking_data';
/**
* Whether components have been initialized.
*
* @var bool
*/
private static bool $initialized = false;
/**
* Initialize the WooCommerce integration.
*
* @return void
*/
public static function init(): void {
// Prevent double initialization.
if ( self::$initialized ) {
return;
}
// Only proceed if WooCommerce is active.
if ( ! self::is_wc_active() ) {
return;
}
// Declare HPOS compatibility.
add_action( 'before_woocommerce_init', array( self::class, 'declare_hpos_compatibility' ) );
// Always register admin AJAX handlers immediately (for settings page).
// Must be called directly, not via hook, since woocommerce_loaded may have already fired.
ProductSync::init_admin_ajax();
// Only initialize full components if integration is enabled.
if ( ! self::is_enabled() ) {
return;
}
// Initialize sub-components after WooCommerce is fully loaded.
add_action( 'woocommerce_loaded', array( self::class, 'init_components' ) );
// Add admin notice if WooCommerce deactivated while integration enabled.
add_action( 'admin_notices', array( self::class, 'admin_notices' ) );
self::$initialized = true;
}
/**
* Declare High-Performance Order Storage (HPOS) compatibility.
*
* @return void
*/
public static function declare_hpos_compatibility(): void {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
WP_BNB_PATH . 'wp-bnb.php',
true
);
}
}
/**
* Initialize all WooCommerce integration components.
*
* @return void
*/
public static function init_components(): void {
// Product synchronization (room <-> 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() ) {
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong><?php esc_html_e( 'WP BnB:', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'WooCommerce integration is enabled but WooCommerce is not active. Please install and activate WooCommerce or disable the integration in WP BnB settings.', 'wp-bnb' ); ?>
</p>
</div>
<?php
}
}
/**
* Map WooCommerce order status to booking status.
*
* @param string $wc_status WooCommerce order status (without 'wc-' prefix).
* @return string Booking status.
*/
public static function map_wc_status_to_booking( string $wc_status ): string {
$map = array(
'pending' => '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();
}
}

View File

@@ -0,0 +1,584 @@
<?php
/**
* WooCommerce Order Handler.
*
* Handles order-to-booking creation and status synchronization.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Order Handler class.
*
* Creates bookings from WooCommerce orders on payment completion
* and synchronizes order/booking statuses.
*/
final class OrderHandler {
/**
* Flag to prevent recursive status updates.
*
* @var bool
*/
private static bool $updating_status = false;
/**
* Initialize order handler.
*
* @return void
*/
public static function init(): void {
// Create booking on payment completion.
add_action( 'woocommerce_payment_complete', array( self::class, 'on_payment_complete' ) );
// Also handle manual order status changes to processing/completed.
add_action( 'woocommerce_order_status_processing', array( self::class, 'on_order_processing' ) );
add_action( 'woocommerce_order_status_completed', array( self::class, 'on_order_completed' ) );
// Sync order status changes to booking.
add_action( 'woocommerce_order_status_changed', array( self::class, 'sync_order_status_to_booking' ), 10, 4 );
// Display booking info in admin order page.
add_action( 'woocommerce_admin_order_data_after_billing_address', array( self::class, 'display_booking_info_admin' ) );
// Display booking info on thank you page.
add_action( 'woocommerce_thankyou', array( self::class, 'display_booking_info_thankyou' ) );
// Display booking info in order details.
add_action( 'woocommerce_order_details_after_order_table', array( self::class, 'display_booking_info_order_details' ) );
}
/**
* Handle payment completion.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_payment_complete( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Handle order status change to processing.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_order_processing( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Handle order status change to completed.
*
* @param int $order_id Order ID.
* @return void
*/
public static function on_order_completed( int $order_id ): void {
self::maybe_create_booking( $order_id );
}
/**
* Create booking from order if not already created.
*
* @param int $order_id Order ID.
* @return void
*/
private static function maybe_create_booking( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
// Check if booking already created.
$existing_booking = Manager::get_booking_for_order( $order );
if ( $existing_booking ) {
return;
}
// Check if order has room bookings.
$has_bookings = false;
foreach ( $order->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;
?>
<div class="order_data_column bnb-order-booking-info">
<h3><?php esc_html_e( 'Booking Information', 'wp-bnb' ); ?></h3>
<p>
<strong><?php esc_html_e( 'Booking:', 'wp-bnb' ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $booking_id ) ); ?>">
<?php echo esc_html( $booking ? $booking->post_title : "#{$booking_id}" ); ?>
</a>
</p>
<?php if ( $room ) : ?>
<p>
<strong><?php esc_html_e( 'Room:', 'wp-bnb' ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $room_id ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
</p>
<?php endif; ?>
<p>
<strong><?php esc_html_e( 'Status:', 'wp-bnb' ); ?></strong>
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $status ) ) ); ?>
</span>
</p>
</div>
<?php
}
/**
* Display booking info on thank you page.
*
* @param int $order_id Order ID.
* @return void
*/
public static function display_booking_info_thankyou( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$reference = get_post_meta( $booking_id, '_bnb_booking_reference', true );
$check_in = get_post_meta( $booking_id, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking_id, '_bnb_booking_check_out', true );
$room_id = get_post_meta( $booking_id, '_bnb_booking_room_id', true );
$room = $room_id ? get_post( $room_id ) : null;
$building = $room_id ? Room::get_building( $room_id ) : null;
$date_format = get_option( 'date_format' );
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
?>
<section class="woocommerce-booking-confirmation">
<h2><?php esc_html_e( 'Booking Confirmed', 'wp-bnb' ); ?></h2>
<table class="woocommerce-table woocommerce-table--booking-details">
<tbody>
<tr>
<th><?php esc_html_e( 'Booking Reference:', 'wp-bnb' ); ?></th>
<td><strong><?php echo esc_html( $reference ); ?></strong></td>
</tr>
<?php if ( $room ) : ?>
<tr>
<th><?php esc_html_e( 'Room:', 'wp-bnb' ); ?></th>
<td>
<?php echo esc_html( $room->post_title ); ?>
<?php if ( $building ) : ?>
<br><small><?php echo esc_html( $building->post_title ); ?></small>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<tr>
<th><?php esc_html_e( 'Check-in:', 'wp-bnb' ); ?></th>
<td><?php echo esc_html( $check_in_date ? $check_in_date->format( $date_format ) : $check_in ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'Check-out:', 'wp-bnb' ); ?></th>
<td><?php echo esc_html( $check_out_date ? $check_out_date->format( $date_format ) : $check_out ); ?></td>
</tr>
</tbody>
</table>
<p class="woocommerce-notice woocommerce-notice--success">
<?php esc_html_e( 'Your booking has been confirmed. A confirmation email has been sent to your email address.', 'wp-bnb' ); ?>
</p>
</section>
<?php
}
/**
* Display booking info in order details (customer account).
*
* @param \WC_Order $order WooCommerce order.
* @return void
*/
public static function display_booking_info_order_details( \WC_Order $order ): void {
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$reference = get_post_meta( $booking_id, '_bnb_booking_reference', true );
$status = get_post_meta( $booking_id, '_bnb_booking_status', true );
if ( $reference ) {
?>
<p class="woocommerce-booking-reference">
<strong><?php esc_html_e( 'Booking Reference:', 'wp-bnb' ); ?></strong>
<?php echo esc_html( $reference ); ?>
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $status ) ) ); ?>
</span>
</p>
<?php
}
}
}

View File

@@ -0,0 +1,541 @@
<?php
/**
* WooCommerce Product Synchronization.
*
* Synchronizes rooms with WooCommerce products.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Product Synchronization class.
*
* Creates and maintains WooCommerce products that correspond to BnB rooms.
*/
final class ProductSync {
/**
* Track if admin AJAX has been initialized.
*
* @var bool
*/
private static bool $admin_ajax_initialized = false;
/**
* Initialize product synchronization.
*
* @return void
*/
public static function init(): void {
// Sync product when room is saved.
add_action( 'save_post_' . Room::POST_TYPE, array( self::class, 'on_room_save' ), 20, 2 );
// Delete product when room is deleted.
add_action( 'before_delete_post', array( self::class, 'on_room_delete' ) );
// Add linked room info to product edit screen.
add_action( 'woocommerce_product_options_general_product_data', array( self::class, 'add_product_room_info' ) );
// Register admin AJAX if not already done.
self::init_admin_ajax();
}
/**
* Initialize admin AJAX handlers only.
*
* Called separately from init() to ensure AJAX is available on settings page
* even when full integration is not yet enabled.
*
* @return void
*/
public static function init_admin_ajax(): void {
if ( self::$admin_ajax_initialized ) {
return;
}
// AJAX handler for syncing all rooms.
add_action( 'wp_ajax_wp_bnb_sync_all_rooms', array( self::class, 'ajax_sync_all_rooms' ) );
self::$admin_ajax_initialized = true;
}
/**
* Handle room save - create or update product.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function on_room_save( int $post_id, \WP_Post $post ): void {
// Skip autosaves, revisions, and other non-published posts.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( 'publish' !== $post->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<string>}
*/
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;
}
?>
<div class="options_group show_if_simple">
<p class="form-field">
<label><?php esc_html_e( 'Linked BnB Room', 'wp-bnb' ); ?></label>
<span class="description">
<a href="<?php echo esc_url( get_edit_post_link( $room_id ) ); ?>">
<?php echo esc_html( $room->post_title ); ?>
</a>
<br>
<small><?php esc_html_e( 'This product is automatically synced from the linked room. Changes should be made in the room settings.', 'wp-bnb' ); ?></small>
</span>
</p>
</div>
<?php
}
/**
* Check if a product is a BnB room product.
*
* @param int|\WC_Product $product Product ID or object.
* @return bool
*/
public static function is_room_product( $product ): bool {
if ( is_int( $product ) ) {
$product = wc_get_product( $product );
}
if ( ! $product instanceof \WC_Product ) {
return false;
}
$room_id = $product->get_meta( Manager::PRODUCT_ROOM_META, true );
return ! empty( $room_id );
}
}

View File

@@ -0,0 +1,394 @@
<?php
/**
* WooCommerce Refund Handler.
*
* Handles refund processing and booking cancellation.
*
* @package Magdev\WpBnb\Integration\WooCommerce
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration\WooCommerce;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\PostTypes\Booking;
/**
* Refund Handler class.
*
* Processes refunds and updates booking status accordingly.
*/
final class RefundHandler {
/**
* Booking meta key for refund amount.
*/
public const REFUND_AMOUNT_META = '_bnb_booking_refund_amount';
/**
* Booking meta key for refund reason.
*/
public const REFUND_REASON_META = '_bnb_booking_refund_reason';
/**
* Booking meta key for refund date.
*/
public const REFUND_DATE_META = '_bnb_booking_refund_date';
/**
* Initialize refund handler.
*
* @return void
*/
public static function init(): void {
// Handle refund creation.
add_action( 'woocommerce_refund_created', array( self::class, 'on_refund_created' ), 10, 2 );
// Handle order fully refunded.
add_action( 'woocommerce_order_fully_refunded', array( self::class, 'on_order_fully_refunded' ), 10, 2 );
// Add refund notice in admin order page.
add_action( 'woocommerce_admin_order_totals_after_refunded', array( self::class, 'add_booking_refund_notice' ) );
}
/**
* Handle refund creation.
*
* @param int $refund_id Refund ID.
* @param array $args Refund arguments.
* @return void
*/
public static function on_refund_created( int $refund_id, array $args ): void {
$order_id = $args['order_id'] ?? 0;
if ( ! $order_id ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order instanceof \WC_Order ) {
return;
}
// Check if order has a linked booking.
$booking_id = Manager::get_booking_for_order( $order );
if ( ! $booking_id ) {
return;
}
$refund_amount = abs( floatval( $args['amount'] ?? 0 ) );
$refund_reason = $args['reason'] ?? '';
/**
* Fires before processing a refund for a booking.
*
* @param \WC_Order $order WooCommerce order.
* @param float $refund_amount Refund amount.
*/
do_action( 'wp_bnb_wc_before_refund_process', $order, $refund_amount );
// Check if this is a full or partial refund.
$is_full_refund = self::is_full_refund( $order );
if ( $is_full_refund ) {
// Full refund - cancel the booking.
self::cancel_booking_on_refund( $booking_id, $refund_amount, $refund_reason );
} else {
// Partial refund - store refund info but don't cancel.
self::record_partial_refund( $booking_id, $refund_amount, $refund_reason );
}
/**
* Fires after processing a refund for a booking.
*
* @param \WC_Order $order WooCommerce order.
* @param int $booking_id Booking ID.
* @param bool $cancelled Whether booking was cancelled.
*/
do_action( 'wp_bnb_wc_after_refund_process', $order, $booking_id, $is_full_refund );
}
/**
* Handle order fully refunded.
*
* @param int $order_id Order ID.
* @param int $refund_id Refund ID.
* @return void
*/
public static function on_order_fully_refunded( int $order_id, int $refund_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;
}
// Get current booking status.
$current_status = get_post_meta( $booking_id, '_bnb_booking_status', true );
// Don't cancel if already cancelled.
if ( 'cancelled' === $current_status ) {
return;
}
// Cancel the booking.
$total_refunded = $order->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 );
?>
<tr>
<td class="label"><?php esc_html_e( 'Booking Status', 'wp-bnb' ); ?>:</td>
<td width="1%"></td>
<td class="total">
<span class="bnb-status-badge bnb-status-<?php echo esc_attr( $booking_status ); ?>">
<?php echo esc_html( ucfirst( str_replace( '_', ' ', $booking_status ) ) ); ?>
</span>
</td>
</tr>
<?php if ( 'cancelled' === $booking_status ) : ?>
<tr>
<td class="label" colspan="2">
<small class="description">
<?php
printf(
/* translators: %s: Booking edit link */
esc_html__( 'Booking was cancelled due to refund. %s', 'wp-bnb' ),
'<a href="' . esc_url( get_edit_post_link( $booking_id ) ) . '">' . esc_html__( 'View booking', 'wp-bnb' ) . '</a>'
);
?>
</small>
</td>
</tr>
<?php endif; ?>
<?php
}
}

View File

@@ -21,6 +21,7 @@ use Magdev\WpBnb\Frontend\Shortcodes;
use Magdev\WpBnb\Api\RestApi;
use Magdev\WpBnb\Integration\CF7;
use Magdev\WpBnb\Integration\Prometheus;
use Magdev\WpBnb\Integration\WooCommerce\Manager as WooCommerceManager;
use Magdev\WpBnb\Frontend\Widgets\AvailabilityCalendar;
use Magdev\WpBnb\Frontend\Widgets\BuildingRooms;
use Magdev\WpBnb\Frontend\Widgets\SimilarRooms;
@@ -147,6 +148,11 @@ final class Plugin {
// Initialize Prometheus metrics integration.
Prometheus::init();
// Initialize WooCommerce integration if WooCommerce is active.
if ( class_exists( 'WooCommerce' ) ) {
WooCommerceManager::init();
}
// Initialize REST API.
$this->init_rest_api();
@@ -344,6 +350,8 @@ final class Plugin {
'checkingUpdates' => __( 'Checking for updates...', 'wp-bnb' ),
'occupancy' => __( 'Occupancy %', 'wp-bnb' ),
'revenue' => __( 'Revenue', 'wp-bnb' ),
'syncing' => __( 'Syncing...', 'wp-bnb' ),
'syncComplete' => __( 'Sync complete', 'wp-bnb' ),
),
);
@@ -636,6 +644,10 @@ final class Plugin {
class="nav-tab <?php echo 'api' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'API', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-settings&tab=woocommerce' ) ); ?>"
class="nav-tab <?php echo 'woocommerce' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'WooCommerce', 'wp-bnb' ); ?>
</a>
</nav>
<div class="tab-content">
@@ -656,6 +668,9 @@ final class Plugin {
case 'api':
$this->render_api_settings();
break;
case 'woocommerce':
$this->render_woocommerce_settings();
break;
default:
$this->render_general_settings();
break;
@@ -1460,13 +1475,48 @@ final class Plugin {
* @return void
*/
private function render_api_settings(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Subtab switching only.
$active_subtab = isset( $_GET['subtab'] ) ? sanitize_key( $_GET['subtab'] ) : 'general';
$api_enabled = get_option( 'wp_bnb_api_enabled', 'yes' );
$rate_limiting = get_option( 'wp_bnb_api_rate_limiting', 'yes' );
$api_base_url = rest_url( RestApi::NAMESPACE );
// Rate limit values.
$defaults = \Magdev\WpBnb\Api\RateLimiter::get_default_limits();
$limit_public = get_option( 'wp_bnb_rate_limit_public', $defaults['public'] );
$limit_avail = get_option( 'wp_bnb_rate_limit_availability', $defaults['availability'] );
$limit_booking = get_option( 'wp_bnb_rate_limit_booking', $defaults['booking'] );
$limit_admin = get_option( 'wp_bnb_rate_limit_admin', $defaults['admin'] );
$limit_window = get_option( 'wp_bnb_rate_limit_window', 60 );
$base_url = admin_url( 'admin.php?page=wp-bnb-settings&tab=api' );
?>
<!-- API Subtabs -->
<div class="wp-bnb-subtabs">
<a href="<?php echo esc_url( $base_url . '&subtab=general' ); ?>"
class="wp-bnb-subtab <?php echo 'general' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-admin-generic"></span>
<?php esc_html_e( 'General', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=rate-limits' ); ?>"
class="wp-bnb-subtab <?php echo 'rate-limits' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-dashboard"></span>
<?php esc_html_e( 'Rate Limits', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=endpoints' ); ?>"
class="wp-bnb-subtab <?php echo 'endpoints' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-rest-api"></span>
<?php esc_html_e( 'Endpoints', 'wp-bnb' ); ?>
</a>
</div>
<form method="post" action="">
<?php wp_nonce_field( 'wp_bnb_save_settings', 'wp_bnb_settings_nonce' ); ?>
<?php if ( 'general' === $active_subtab ) : ?>
<!-- General Subtab -->
<h2><?php esc_html_e( 'REST API Settings', 'wp-bnb' ); ?></h2>
<table class="form-table">
@@ -1491,6 +1541,9 @@ final class Plugin {
</label>
<p class="description">
<?php esc_html_e( 'Limits API requests to prevent abuse. Recommended for production sites.', 'wp-bnb' ); ?>
<?php if ( 'yes' === $rate_limiting ) : ?>
<a href="<?php echo esc_url( $base_url . '&subtab=rate-limits' ); ?>"><?php esc_html_e( 'Configure limits', 'wp-bnb' ); ?> &rarr;</a>
<?php endif; ?>
</p>
</td>
</tr>
@@ -1525,64 +1578,102 @@ final class Plugin {
</tr>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Available Endpoints', 'wp-bnb' ); ?></h2>
<?php submit_button( __( 'Save Settings', 'wp-bnb' ) ); ?>
<h3><?php esc_html_e( 'Public Endpoints', 'wp-bnb' ); ?></h3>
<table class="widefat" style="margin-bottom: 20px;">
<thead>
<?php elseif ( 'rate-limits' === $active_subtab ) : ?>
<!-- Rate Limits Subtab -->
<h2><?php esc_html_e( 'Rate Limit Configuration', 'wp-bnb' ); ?></h2>
<?php if ( 'yes' !== $rate_limiting ) : ?>
<div class="notice notice-warning inline" style="margin: 15px 0;">
<p>
<?php esc_html_e( 'Rate limiting is currently disabled.', 'wp-bnb' ); ?>
<a href="<?php echo esc_url( $base_url . '&subtab=general' ); ?>"><?php esc_html_e( 'Enable it in General settings', 'wp-bnb' ); ?></a>
</p>
</div>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Configure the number of requests allowed per time window for each endpoint type.', 'wp-bnb' ); ?></p>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
<th scope="row"><?php esc_html_e( 'Time Window', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_window" value="<?php echo esc_attr( $limit_window ); ?>" min="10" max="300" step="10" class="small-text">
<?php esc_html_e( 'seconds', 'wp-bnb' ); ?>
<p class="description">
<?php esc_html_e( 'The time window for rate limit counting. Default: 60 seconds.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Public Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_public" value="<?php echo esc_attr( $limit_public ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for public read endpoints (rooms, buildings, services). Default: %d', 'wp-bnb' ),
$defaults['public']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Availability Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_availability" value="<?php echo esc_attr( $limit_avail ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for availability checks and calendar requests. Default: %d', 'wp-bnb' ),
$defaults['availability']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Booking Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_booking" value="<?php echo esc_attr( $limit_booking ); ?>" min="1" max="100" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for booking creation. Keep low to prevent abuse. Default: %d', 'wp-bnb' ),
$defaults['booking']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Admin Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_admin" value="<?php echo esc_attr( $limit_admin ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for authenticated admin endpoints. Default: %d', 'wp-bnb' ),
$defaults['admin']
);
?>
</p>
</td>
</tr>
</thead>
<tbody>
<tr><td>GET</td><td><code>/buildings</code></td><td><?php esc_html_e( 'List all buildings', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/buildings/{id}</code></td><td><?php esc_html_e( 'Get building details', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/buildings/{id}/rooms</code></td><td><?php esc_html_e( 'List rooms in building', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/rooms</code></td><td><?php esc_html_e( 'List/search rooms', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/rooms/{id}</code></td><td><?php esc_html_e( 'Get room details', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/rooms/{id}/availability</code></td><td><?php esc_html_e( 'Check room availability', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/rooms/{id}/calendar</code></td><td><?php esc_html_e( 'Get room calendar', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/availability/search</code></td><td><?php esc_html_e( 'Search available rooms', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/services</code></td><td><?php esc_html_e( 'List services', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/pricing/calculate</code></td><td><?php esc_html_e( 'Calculate booking price', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/bookings</code></td><td><?php esc_html_e( 'Create booking (pending status)', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h3><?php esc_html_e( 'Admin Endpoints (Requires Authentication)', 'wp-bnb' ); ?></h3>
<table class="widefat" style="margin-bottom: 20px;">
<thead>
<tr>
<th><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td>GET</td><td><code>/bookings</code></td><td><?php esc_html_e( 'List all bookings', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Get booking details', 'wp-bnb' ); ?></td></tr>
<tr><td>PATCH</td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Update booking', 'wp-bnb' ); ?></td></tr>
<tr><td>DELETE</td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Cancel booking', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/bookings/{id}/confirm</code></td><td><?php esc_html_e( 'Confirm booking', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/bookings/{id}/check-in</code></td><td><?php esc_html_e( 'Check in guest', 'wp-bnb' ); ?></td></tr>
<tr><td>POST</td><td><code>/bookings/{id}/check-out</code></td><td><?php esc_html_e( 'Check out guest', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/guests</code></td><td><?php esc_html_e( 'List guests', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/guests/{id}</code></td><td><?php esc_html_e( 'Get guest details', 'wp-bnb' ); ?></td></tr>
<tr><td>GET</td><td><code>/guests/search</code></td><td><?php esc_html_e( 'Search guests', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Authentication', 'wp-bnb' ); ?></h2>
<p><?php esc_html_e( 'Admin endpoints require authentication. Use one of the following methods:', 'wp-bnb' ); ?></p>
<ul style="list-style: disc; margin-left: 20px;">
<li><strong><?php esc_html_e( 'Application Passwords:', 'wp-bnb' ); ?></strong> <?php esc_html_e( 'Create one in Users > Your Profile. Use Basic Auth with username and app password.', 'wp-bnb' ); ?></li>
<li><strong><?php esc_html_e( 'Cookie + Nonce:', 'wp-bnb' ); ?></strong> <?php esc_html_e( 'For same-domain requests. Pass nonce in X-WP-Nonce header.', 'wp-bnb' ); ?></li>
</ul>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Rate Limits', 'wp-bnb' ); ?></h2>
<table class="widefat" style="margin-bottom: 20px;">
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Current Rate Limits Summary', 'wp-bnb' ); ?></h2>
<table class="widefat striped" style="max-width: 600px;">
<thead>
<tr>
<th><?php esc_html_e( 'Type', 'wp-bnb' ); ?></th>
@@ -1591,16 +1682,112 @@ final class Plugin {
</tr>
</thead>
<tbody>
<tr><td><?php esc_html_e( 'Public', 'wp-bnb' ); ?></td><td>60/min</td><td><?php esc_html_e( 'GET rooms, buildings, services', 'wp-bnb' ); ?></td></tr>
<tr><td><?php esc_html_e( 'Availability', 'wp-bnb' ); ?></td><td>30/min</td><td><?php esc_html_e( 'Availability checks, calendar', 'wp-bnb' ); ?></td></tr>
<tr><td><?php esc_html_e( 'Booking', 'wp-bnb' ); ?></td><td>10/min</td><td><?php esc_html_e( 'Booking creation', 'wp-bnb' ); ?></td></tr>
<tr><td><?php esc_html_e( 'Admin', 'wp-bnb' ); ?></td><td>120/min</td><td><?php esc_html_e( 'All admin endpoints', 'wp-bnb' ); ?></td></tr>
<tr>
<td><?php esc_html_e( 'Public', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_public . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'GET rooms, buildings, services', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Availability', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_avail . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'Availability checks, calendar', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Booking', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_booking . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'Booking creation', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Admin', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_admin . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'All admin endpoints', 'wp-bnb' ); ?></td>
</tr>
</tbody>
</table>
<p class="submit">
<?php submit_button( __( 'Save API Settings', 'wp-bnb' ), 'primary', 'submit', false ); ?>
</p>
<?php submit_button( __( 'Save Rate Limits', 'wp-bnb' ) ); ?>
<?php else : ?>
<!-- Endpoints Subtab -->
<h2><?php esc_html_e( 'Available Endpoints', 'wp-bnb' ); ?></h2>
<h3><?php esc_html_e( 'Public Endpoints', 'wp-bnb' ); ?></h3>
<p class="description"><?php esc_html_e( 'These endpoints are accessible without authentication.', 'wp-bnb' ); ?></p>
<table class="widefat striped" style="margin-bottom: 20px;">
<thead>
<tr>
<th style="width: 80px;"><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th style="width: 250px;"><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings</code></td><td><?php esc_html_e( 'List all buildings', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings/{id}</code></td><td><?php esc_html_e( 'Get building details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings/{id}/rooms</code></td><td><?php esc_html_e( 'List rooms in building', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms</code></td><td><?php esc_html_e( 'List/search rooms', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}</code></td><td><?php esc_html_e( 'Get room details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}/availability</code></td><td><?php esc_html_e( 'Check room availability', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}/calendar</code></td><td><?php esc_html_e( 'Get room calendar', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/availability/search</code></td><td><?php esc_html_e( 'Search available rooms', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/services</code></td><td><?php esc_html_e( 'List services', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/pricing/calculate</code></td><td><?php esc_html_e( 'Calculate booking price', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings</code></td><td><?php esc_html_e( 'Create booking (pending status)', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h3><?php esc_html_e( 'Admin Endpoints', 'wp-bnb' ); ?></h3>
<p class="description"><?php esc_html_e( 'These endpoints require authentication (Application Password or Cookie + Nonce).', 'wp-bnb' ); ?></p>
<table class="widefat striped" style="margin-bottom: 20px;">
<thead>
<tr>
<th style="width: 80px;"><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th style="width: 250px;"><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/bookings</code></td><td><?php esc_html_e( 'List all bookings', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Get booking details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method patch">PATCH</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Update booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method delete">DELETE</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Cancel booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/confirm</code></td><td><?php esc_html_e( 'Confirm booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/check-in</code></td><td><?php esc_html_e( 'Check in guest', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/check-out</code></td><td><?php esc_html_e( 'Check out guest', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests</code></td><td><?php esc_html_e( 'List guests', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests/{id}</code></td><td><?php esc_html_e( 'Get guest details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests/search</code></td><td><?php esc_html_e( 'Search guests', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Authentication', 'wp-bnb' ); ?></h2>
<p><?php esc_html_e( 'Admin endpoints require authentication. Use one of the following methods:', 'wp-bnb' ); ?></p>
<table class="form-table">
<tr>
<th scope="row">
<span class="dashicons dashicons-admin-network" style="color: #2271b1;"></span>
<?php esc_html_e( 'Application Passwords', 'wp-bnb' ); ?>
</th>
<td>
<p><?php esc_html_e( 'Create an Application Password in Users > Your Profile.', 'wp-bnb' ); ?></p>
<p><code>Authorization: Basic base64(username:app-password)</code></p>
<p class="description"><?php esc_html_e( 'Recommended for external applications and integrations.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<span class="dashicons dashicons-lock" style="color: #2271b1;"></span>
<?php esc_html_e( 'Cookie + Nonce', 'wp-bnb' ); ?>
</th>
<td>
<p><?php esc_html_e( 'For same-domain JavaScript requests when user is logged in.', 'wp-bnb' ); ?></p>
<p><code>X-WP-Nonce: <?php echo esc_html( wp_create_nonce( 'wp_rest' ) ); ?></code></p>
<p class="description"><?php esc_html_e( 'Best for frontend JavaScript that interacts with the API.', 'wp-bnb' ); ?></p>
</td>
</tr>
</table>
<?php endif; ?>
</form>
<?php
}
@@ -1682,6 +1869,9 @@ final class Plugin {
case 'api':
$this->save_api_settings();
break;
case 'woocommerce':
$this->save_woocommerce_settings();
break;
default:
$this->save_general_settings();
break;
@@ -1836,10 +2026,408 @@ final class Plugin {
update_option( 'wp_bnb_api_enabled', $api_enabled );
update_option( 'wp_bnb_api_rate_limiting', $rate_limiting );
// Save rate limit configuration.
$defaults = \Magdev\WpBnb\Api\RateLimiter::get_default_limits();
$limit_window = isset( $_POST['wp_bnb_rate_limit_window'] )
? max( 10, min( 300, absint( $_POST['wp_bnb_rate_limit_window'] ) ) )
: 60;
$limit_public = isset( $_POST['wp_bnb_rate_limit_public'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_public'] ) ) )
: $defaults['public'];
$limit_avail = isset( $_POST['wp_bnb_rate_limit_availability'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_availability'] ) ) )
: $defaults['availability'];
$limit_booking = isset( $_POST['wp_bnb_rate_limit_booking'] )
? max( 1, min( 100, absint( $_POST['wp_bnb_rate_limit_booking'] ) ) )
: $defaults['booking'];
$limit_admin = isset( $_POST['wp_bnb_rate_limit_admin'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_admin'] ) ) )
: $defaults['admin'];
update_option( 'wp_bnb_rate_limit_window', $limit_window );
update_option( 'wp_bnb_rate_limit_public', $limit_public );
update_option( 'wp_bnb_rate_limit_availability', $limit_avail );
update_option( 'wp_bnb_rate_limit_booking', $limit_booking );
update_option( 'wp_bnb_rate_limit_admin', $limit_admin );
add_settings_error( 'wp_bnb_settings', 'settings_saved', __( 'API settings saved.', 'wp-bnb' ), 'success' );
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' );
?>
<div class="wp-bnb-subtabs">
<a href="<?php echo esc_url( $base_url . '&subtab=general' ); ?>"
class="wp-bnb-subtab <?php echo 'general' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-admin-generic"></span>
<?php esc_html_e( 'General', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=products' ); ?>"
class="wp-bnb-subtab <?php echo 'products' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-products"></span>
<?php esc_html_e( 'Products', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=orders' ); ?>"
class="wp-bnb-subtab <?php echo 'orders' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-cart"></span>
<?php esc_html_e( 'Orders', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=invoices' ); ?>"
class="wp-bnb-subtab <?php echo 'invoices' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-media-document"></span>
<?php esc_html_e( 'Invoices', 'wp-bnb' ); ?>
</a>
</div>
<?php if ( ! $wc_active ) : ?>
<div class="notice notice-warning inline">
<p>
<strong><?php esc_html_e( 'WooCommerce is not active.', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'Please install and activate WooCommerce to use these features.', 'wp-bnb' ); ?>
</p>
</div>
<?php endif; ?>
<?php
switch ( $active_subtab ) {
case 'products':
$this->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 {
?>
<form method="post" action="">
<?php wp_nonce_field( 'wp_bnb_save_settings', 'wp_bnb_settings_nonce' ); ?>
<input type="hidden" name="wc_subtab" value="general">
<h2><?php esc_html_e( 'WooCommerce Integration', 'wp-bnb' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Enable Integration', 'wp-bnb' ); ?></th>
<td>
<label for="wp_bnb_wc_enabled">
<input type="checkbox" name="wp_bnb_wc_enabled" id="wp_bnb_wc_enabled" value="yes"
<?php checked( 'yes', get_option( WooCommerceManager::OPTION_ENABLED, 'no' ) ); ?>
<?php disabled( ! $wc_active ); ?>>
<?php esc_html_e( 'Enable WooCommerce integration for bookings', 'wp-bnb' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Allow guests to book and pay through WooCommerce checkout.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Auto-Confirm Bookings', 'wp-bnb' ); ?></th>
<td>
<label for="wp_bnb_wc_auto_confirm_booking">
<input type="checkbox" name="wp_bnb_wc_auto_confirm_booking" id="wp_bnb_wc_auto_confirm_booking" value="yes"
<?php checked( 'yes', get_option( WooCommerceManager::OPTION_AUTO_CONFIRM, 'yes' ) ); ?>
<?php disabled( ! $wc_active ); ?>>
<?php esc_html_e( 'Automatically confirm booking when payment is completed', 'wp-bnb' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'If disabled, bookings will remain pending until manually confirmed.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
</table>
<?php if ( $wc_active ) : ?>
<p class="submit">
<input type="submit" name="submit" class="button button-primary"
value="<?php esc_attr_e( 'Save Settings', 'wp-bnb' ); ?>">
</p>
<?php endif; ?>
</form>
<?php
}
/**
* Render WooCommerce Products subtab.
*
* @param bool $wc_active Whether WooCommerce is active.
* @return void
*/
private function render_wc_products_subtab( bool $wc_active ): void {
$categories = array();
if ( $wc_active ) {
$categories = get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => false,
)
);
}
?>
<form method="post" action="">
<?php wp_nonce_field( 'wp_bnb_save_settings', 'wp_bnb_settings_nonce' ); ?>
<input type="hidden" name="wc_subtab" value="products">
<h2><?php esc_html_e( 'Product Synchronization', 'wp-bnb' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Auto-Sync Products', 'wp-bnb' ); ?></th>
<td>
<label for="wp_bnb_wc_auto_sync_products">
<input type="checkbox" name="wp_bnb_wc_auto_sync_products" id="wp_bnb_wc_auto_sync_products" value="yes"
<?php checked( 'yes', get_option( WooCommerceManager::OPTION_AUTO_SYNC, 'yes' ) ); ?>
<?php disabled( ! $wc_active ); ?>>
<?php esc_html_e( 'Automatically create/update WooCommerce products when rooms are saved', 'wp-bnb' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="wp_bnb_wc_product_category"><?php esc_html_e( 'Product Category', 'wp-bnb' ); ?></label>
</th>
<td>
<select name="wp_bnb_wc_product_category" id="wp_bnb_wc_product_category" <?php disabled( ! $wc_active ); ?>>
<option value=""><?php esc_html_e( '— No category —', 'wp-bnb' ); ?></option>
<?php foreach ( $categories as $cat ) : ?>
<option value="<?php echo esc_attr( $cat->term_id ); ?>"
<?php selected( get_option( WooCommerceManager::OPTION_PRODUCT_CATEGORY, 0 ), $cat->term_id ); ?>>
<?php echo esc_html( $cat->name ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e( 'Assign synced room products to this WooCommerce category.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
</table>
<?php if ( $wc_active ) : ?>
<h3><?php esc_html_e( 'Manual Sync', 'wp-bnb' ); ?></h3>
<p>
<button type="button" class="button bnb-sync-rooms-btn">
<span class="dashicons dashicons-update"></span>
<?php esc_html_e( 'Sync All Rooms Now', 'wp-bnb' ); ?>
</button>
<span class="sync-status"></span>
</p>
<p class="description">
<?php esc_html_e( 'Creates or updates WooCommerce products for all published rooms.', 'wp-bnb' ); ?>
</p>
<p class="submit">
<input type="submit" name="submit" class="button button-primary"
value="<?php esc_attr_e( 'Save Settings', 'wp-bnb' ); ?>">
</p>
<?php endif; ?>
</form>
<?php
}
/**
* Render WooCommerce Orders subtab.
*
* @param bool $wc_active Whether WooCommerce is active.
* @return void
*/
private function render_wc_orders_subtab( bool $wc_active ): void {
?>
<h2><?php esc_html_e( 'Order-Booking Status Mapping', 'wp-bnb' ); ?></h2>
<p class="description">
<?php esc_html_e( 'The following table shows how WooCommerce order statuses map to booking statuses.', 'wp-bnb' ); ?>
</p>
<table class="widefat fixed striped" style="max-width: 600px; margin-top: 15px;">
<thead>
<tr>
<th><?php esc_html_e( 'WooCommerce Status', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Booking Status', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><?php esc_html_e( 'Pending Payment', 'wp-bnb' ); ?></td>
<td><span class="bnb-status-badge bnb-status-pending"><?php esc_html_e( 'Pending', 'wp-bnb' ); ?></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'On Hold', 'wp-bnb' ); ?></td>
<td><span class="bnb-status-badge bnb-status-pending"><?php esc_html_e( 'Pending', 'wp-bnb' ); ?></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'Processing', 'wp-bnb' ); ?></td>
<td>
<?php if ( get_option( WooCommerceManager::OPTION_AUTO_CONFIRM, 'yes' ) === 'yes' ) : ?>
<span class="bnb-status-badge bnb-status-confirmed"><?php esc_html_e( 'Confirmed', 'wp-bnb' ); ?></span>
<?php else : ?>
<span class="bnb-status-badge bnb-status-pending"><?php esc_html_e( 'Pending', 'wp-bnb' ); ?></span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><?php esc_html_e( 'Completed', 'wp-bnb' ); ?></td>
<td><span class="bnb-status-badge bnb-status-confirmed"><?php esc_html_e( 'Confirmed', 'wp-bnb' ); ?></span></td>
</tr>
<tr>
<td><?php esc_html_e( 'Cancelled / Refunded / Failed', 'wp-bnb' ); ?></td>
<td><span class="bnb-status-badge bnb-status-cancelled"><?php esc_html_e( 'Cancelled', 'wp-bnb' ); ?></span></td>
</tr>
</tbody>
</table>
<h3><?php esc_html_e( 'Refund Handling', 'wp-bnb' ); ?></h3>
<ul style="list-style: disc; margin-left: 20px;">
<li><?php esc_html_e( 'Full refunds automatically cancel the linked booking.', 'wp-bnb' ); ?></li>
<li><?php esc_html_e( 'Partial refunds are recorded but do not cancel the booking.', 'wp-bnb' ); ?></li>
<li><?php esc_html_e( 'Cancellation emails are sent when a booking is cancelled due to refund.', 'wp-bnb' ); ?></li>
</ul>
<?php
}
/**
* Render WooCommerce Invoices subtab.
*
* @param bool $wc_active Whether WooCommerce is active.
* @return void
*/
private function render_wc_invoices_subtab( bool $wc_active ): void {
?>
<form method="post" action="">
<?php wp_nonce_field( 'wp_bnb_save_settings', 'wp_bnb_settings_nonce' ); ?>
<input type="hidden" name="wc_subtab" value="invoices">
<h2><?php esc_html_e( 'Invoice Settings', 'wp-bnb' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Attach to Emails', 'wp-bnb' ); ?></th>
<td>
<label for="wp_bnb_wc_invoice_attach">
<input type="checkbox" name="wp_bnb_wc_invoice_attach" id="wp_bnb_wc_invoice_attach" value="yes"
<?php checked( 'yes', get_option( WooCommerceManager::OPTION_INVOICE_ATTACH, 'yes' ) ); ?>
<?php disabled( ! $wc_active ); ?>>
<?php esc_html_e( 'Attach PDF invoice to order confirmation emails', 'wp-bnb' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="wp_bnb_invoice_prefix"><?php esc_html_e( 'Invoice Number Prefix', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" name="wp_bnb_invoice_prefix" id="wp_bnb_invoice_prefix"
value="<?php echo esc_attr( get_option( WooCommerceManager::OPTION_INVOICE_PREFIX, 'INV-' ) ); ?>"
class="regular-text" <?php disabled( ! $wc_active ); ?>>
<p class="description">
<?php esc_html_e( 'Prefix for invoice numbers (e.g., INV- for INV-00001).', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="wp_bnb_invoice_start_number"><?php esc_html_e( 'Starting Number', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" name="wp_bnb_invoice_start_number" id="wp_bnb_invoice_start_number"
value="<?php echo esc_attr( get_option( WooCommerceManager::OPTION_INVOICE_START_NUMBER, 1000 ) ); ?>"
class="small-text" min="1" <?php disabled( ! $wc_active ); ?>>
<p class="description">
<?php esc_html_e( 'Starting number for new invoices (only applies if no invoices generated yet).', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="wp_bnb_invoice_footer"><?php esc_html_e( 'Footer Text', 'wp-bnb' ); ?></label>
</th>
<td>
<textarea name="wp_bnb_invoice_footer" id="wp_bnb_invoice_footer" rows="3" class="large-text"
<?php disabled( ! $wc_active ); ?>><?php echo esc_textarea( get_option( WooCommerceManager::OPTION_INVOICE_FOOTER, '' ) ); ?></textarea>
<p class="description">
<?php esc_html_e( 'Custom footer text for invoices (e.g., terms and conditions).', 'wp-bnb' ); ?>
</p>
</td>
</tr>
</table>
<?php if ( $wc_active ) : ?>
<p class="submit">
<input type="submit" name="submit" class="button button-primary"
value="<?php esc_attr_e( 'Save Settings', 'wp-bnb' ); ?>">
</p>
<?php endif; ?>
</form>
<?php
}
/**
* Save WooCommerce settings.
*
* @return void
*/
private function save_woocommerce_settings(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in render_settings_page.
$subtab = isset( $_POST['wc_subtab'] ) ? sanitize_key( $_POST['wc_subtab'] ) : 'general';
switch ( $subtab ) {
case 'products':
$auto_sync = isset( $_POST['wp_bnb_wc_auto_sync_products'] ) ? 'yes' : 'no';
$category = isset( $_POST['wp_bnb_wc_product_category'] ) ? absint( $_POST['wp_bnb_wc_product_category'] ) : 0;
update_option( WooCommerceManager::OPTION_AUTO_SYNC, $auto_sync );
update_option( WooCommerceManager::OPTION_PRODUCT_CATEGORY, $category );
break;
case 'invoices':
$attach = isset( $_POST['wp_bnb_wc_invoice_attach'] ) ? 'yes' : 'no';
$prefix = isset( $_POST['wp_bnb_invoice_prefix'] ) ? sanitize_text_field( wp_unslash( $_POST['wp_bnb_invoice_prefix'] ) ) : 'INV-';
$start_number = isset( $_POST['wp_bnb_invoice_start_number'] ) ? absint( $_POST['wp_bnb_invoice_start_number'] ) : 1000;
$footer = isset( $_POST['wp_bnb_invoice_footer'] ) ? sanitize_textarea_field( wp_unslash( $_POST['wp_bnb_invoice_footer'] ) ) : '';
update_option( WooCommerceManager::OPTION_INVOICE_ATTACH, $attach );
update_option( WooCommerceManager::OPTION_INVOICE_PREFIX, $prefix );
update_option( WooCommerceManager::OPTION_INVOICE_START_NUMBER, $start_number );
update_option( WooCommerceManager::OPTION_INVOICE_FOOTER, $footer );
break;
default: // general
$enabled = isset( $_POST['wp_bnb_wc_enabled'] ) ? 'yes' : 'no';
$auto_confirm = isset( $_POST['wp_bnb_wc_auto_confirm_booking'] ) ? 'yes' : 'no';
update_option( WooCommerceManager::OPTION_ENABLED, $enabled );
update_option( WooCommerceManager::OPTION_AUTO_CONFIRM, $auto_confirm );
break;
}
add_settings_error( 'wp_bnb_settings', 'settings_saved', __( 'WooCommerce settings saved.', 'wp-bnb' ), 'success' );
settings_errors( 'wp_bnb_settings' );
}
/**
* AJAX handler for checking room availability.
*

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP BnB Management
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb
* Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests.
* Version: 0.10.0
* Version: 0.12.1
* Requires at least: 6.0
* Requires PHP: 8.3
* Author: Marco Graetsch
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// Plugin version constant - MUST match Version in header above.
define( 'WP_BNB_VERSION', '0.10.0' );
define( 'WP_BNB_VERSION', '0.12.1' );
// Plugin path constants.
define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );
@@ -189,3 +189,23 @@ function wp_bnb_deactivate(): void {
}
register_deactivation_hook( __FILE__, 'wp_bnb_deactivate' );
/**
* Add action links to the plugins page.
*
* @param array $links Existing plugin action links.
* @return array Modified plugin action links.
*/
function wp_bnb_plugin_action_links( array $links ): array {
$dashboard_link = sprintf(
'<a href="%s">%s</a>',
esc_url( admin_url( 'admin.php?page=wp-bnb' ) ),
esc_html__( 'Dashboard', 'wp-bnb' )
);
array_unshift( $links, $dashboard_link );
return $links;
}
add_filter( 'plugin_action_links_' . WP_BNB_BASENAME, 'wp_bnb_plugin_action_links' );