18 Commits

Author SHA1 Message Date
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
81c97c31d7 Implement Phase 10: REST API Endpoints (v0.10.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m10s
- Add complete REST API infrastructure under src/Api/
- ResponseFormatter for standardized responses
- RateLimiter with tiered limits (public 60/min, availability 30/min, booking 10/min, admin 120/min)
- AbstractController base class with common functionality
- BuildingsController: list, get, rooms endpoints
- RoomsController: list, get, availability, calendar, search endpoints
- BookingsController: CRUD + confirm/check-in/check-out status transitions
- GuestsController: list, get, search, booking history (admin only)
- ServicesController: list, get, calculate endpoints
- PricingController: calculate, seasons endpoints
- API settings tab with enable/disable toggles
- Comprehensive API documentation in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:24:40 +01:00
87aa89b1a6 Reorganize roadmap: add Phase 10 API Endpoints, renumber Security Audit
- API Endpoints promoted from Future Considerations to Phase 10 (v0.10.0)
- Security Audit moved from Phase 10 to Phase 11 (v0.11.0)
- Updated version milestones table accordingly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:56:39 +01:00
13fd25f84c Implement Phase 9: Prometheus Metrics (v0.9.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m8s
- Add Prometheus metrics integration via wp-prometheus hooks
- Create Grafana dashboard JSON with 24 panels
- Add metrics settings tab with enable/disable toggle
- Expose inventory, booking, occupancy, revenue, and guest metrics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:40:54 +01:00
35 changed files with 23546 additions and 108 deletions

1
.gitignore vendored
View File

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

View File

@@ -5,6 +5,237 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
- REST API Infrastructure:
- New `src/Api/` directory with complete REST API implementation
- `ResponseFormatter.php` - Standardized response formatting (success, collection, error responses)
- `RateLimiter.php` - Transient-based rate limiting with tiered limits
- `Controllers/AbstractController.php` - Base controller with common functionality
- `RestApi.php` - Main registration class with namespace `wp-bnb/v1`
- Buildings API:
- `GET /wp-bnb/v1/buildings` - List buildings with pagination and search
- `GET /wp-bnb/v1/buildings/{id}` - Get single building with address, contact, details
- `GET /wp-bnb/v1/buildings/{id}/rooms` - Get rooms in a building with status filter
- Rooms API:
- `GET /wp-bnb/v1/rooms` - List rooms with filters (building, room_type, amenities, capacity, status)
- `GET /wp-bnb/v1/rooms/{id}` - Get room details with gallery, pricing, amenities
- `GET /wp-bnb/v1/rooms/{id}/availability` - Check availability with price calculation
- `GET /wp-bnb/v1/rooms/{id}/calendar` - Get monthly calendar data
- `POST /wp-bnb/v1/availability/search` - Search available rooms by date range and criteria
- Bookings API:
- `POST /wp-bnb/v1/bookings` - Create booking (public, creates pending status)
- `GET /wp-bnb/v1/bookings` - List bookings with filters (admin)
- `GET /wp-bnb/v1/bookings/{id}` - Get booking details (admin)
- `PATCH /wp-bnb/v1/bookings/{id}` - Update booking (admin)
- `DELETE /wp-bnb/v1/bookings/{id}` - Cancel booking (admin)
- `POST /wp-bnb/v1/bookings/{id}/confirm` - Confirm pending booking (admin)
- `POST /wp-bnb/v1/bookings/{id}/check-in` - Check in guest (admin)
- `POST /wp-bnb/v1/bookings/{id}/check-out` - Check out guest (admin)
- Guests API (admin only):
- `GET /wp-bnb/v1/guests` - List guests with pagination
- `GET /wp-bnb/v1/guests/{id}` - Get guest details (excludes encrypted ID numbers)
- `GET /wp-bnb/v1/guests/search` - Search guests by name/email
- `GET /wp-bnb/v1/guests/{id}/bookings` - Get guest's booking history
- Services API:
- `GET /wp-bnb/v1/services` - List active services with categories
- `GET /wp-bnb/v1/services/{id}` - Get service details with pricing info
- `POST /wp-bnb/v1/services/{id}/calculate` - Calculate service price for booking
- Pricing API:
- `POST /wp-bnb/v1/pricing/calculate` - Full price calculation with services
- `GET /wp-bnb/v1/pricing/seasons` - Get configured seasons and pricing modifiers
- API Settings tab in plugin settings:
- Enable/disable REST API toggle
- Enable/disable rate limiting toggle
- Endpoint documentation table
- Authentication instructions
### Changed
- Plugin.php updated to initialize REST API on `rest_api_init` hook
- Settings page now has seven tabs: General, Pricing, License, Updates, Metrics, API
- README.md updated with comprehensive REST API documentation
### Security
- Rate limiting: public (60/min), availability (30/min), booking (10/min), admin (120/min)
- Admin endpoints require `edit_posts` capability
- Supports WordPress Application Passwords for external API access
- Client identification by user ID (authenticated) or IP address (anonymous)
- Proxy/Cloudflare IP detection via X-Forwarded-For and CF-Connecting-IP headers
## [0.9.0] - 2026-02-03
### Added
- Prometheus Metrics Integration:
- New `src/Integration/Prometheus.php` class for metrics collection
- Integration with wp-prometheus plugin via `wp_prometheus_collect_metrics` hook
- Inventory metrics: buildings total, rooms by status, services by status
- Booking metrics: bookings by status, check-ins/check-outs today, upcoming 7 days, avg duration
- Guest metrics: total guests, guests by status, repeat guests, new guests this month
- Occupancy metrics: current rate, monthly rate, occupied rooms, total bed capacity
- Revenue metrics: this month, YTD, average booking value, services revenue
- Grafana Dashboard:
- Pre-configured dashboard at `assets/grafana/wp-bnb-dashboard.json`
- Automatic registration with wp-prometheus dashboard provider
- Occupancy gauges with color-coded thresholds
- Pie charts for bookings, rooms, and guests by status
- Revenue and guest statistics panels
- Responsive grid layout with 24 panels
- Settings page Metrics tab:
- Enable/disable metrics collection toggle
- WP Prometheus detection with status indicator
- Complete metrics reference table
- Dashboard file location and export info
### Changed
- Plugin.php updated to initialize Prometheus integration
- Settings page now has six tabs: General, Pricing, License, Updates, Metrics
## [0.8.0] - 2026-02-03 ## [0.8.0] - 2026-02-03
### Added ### Added
@@ -525,6 +756,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Input sanitization and output escaping - Input sanitization and output escaping
- Server secret masking in license settings - 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.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.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 [0.5.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.5.0

430
CLAUDE.md
View File

@@ -237,7 +237,21 @@ wp-bnb/
│ ├── Plugin.php # Main plugin singleton │ ├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin pages │ ├── Admin/ # Admin pages
│ │ ├── Calendar.php # Availability calendar page │ │ ├── Calendar.php # Availability calendar page
│ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ├── Reports.php # Reports page with exports
│ │ └── Seasons.php # Seasons management page │ │ └── 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 │ ├── Blocks/ # Gutenberg blocks
│ │ └── BlockRegistrar.php # Block registration and rendering │ │ └── BlockRegistrar.php # Block registration and rendering
│ ├── Booking/ # Booking system │ ├── Booking/ # Booking system
@@ -250,38 +264,47 @@ wp-bnb/
│ │ ├── AvailabilityCalendar.php │ │ ├── AvailabilityCalendar.php
│ │ ├── BuildingRooms.php │ │ ├── BuildingRooms.php
│ │ └── SimilarRooms.php │ │ └── SimilarRooms.php
│ ├── License/ │ ├── Integration/ # Third-party integrations
│ │ ── Manager.php # License management │ │ ── 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 │ ├── PostTypes/ # Custom post types
│ │ ├── Booking.php # Booking post type │ │ ├── Booking.php # Booking post type
│ │ ├── Building.php # Building 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 │ ├── Pricing/ # Pricing system
│ │ ├── Calculator.php # Price calculation │ │ ├── Calculator.php # Price calculation
│ │ ├── PricingTier.php # Pricing tier enum │ │ ├── PricingTier.php # Pricing tier enum
│ │ └── Season.php # Seasonal pricing │ │ └── Season.php # Seasonal pricing
│ ├── Integration/ # Third-party integrations │ ├── Privacy/ # Privacy & GDPR
│ │ └── CF7.php # Contact Form 7 integration │ │ └── Manager.php # Data export/deletion
│ └── Taxonomies/ # Custom taxonomies │ └── Taxonomies/ # Custom taxonomies
│ ├── Amenity.php # Amenity taxonomy (tags) │ ├── Amenity.php # Amenity taxonomy (tags)
── RoomType.php # Room type taxonomy (categories) ── RoomType.php # Room type taxonomy (categories)
├── lib/ # Git submodules │ └── ServiceCategory.php # Service categories
│ └── wc-licensed-product-client/ # License client library ├── assets/ # CSS, JS, images
├── vendor/ # Composer dependencies (auto-generated)
├── assets/
│ ├── css/ │ ├── css/
│ │ ├── admin.css # Admin styles │ │ ├── admin.css # Admin styles
│ │ ├── blocks-editor.css # Gutenberg editor styles │ │ ├── blocks-editor.css # Gutenberg editor styles
│ │ ├── cf7-integration.css # CF7 form 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/ │ └── js/
│ ├── admin.js # Admin scripts │ ├── admin.js # Admin scripts
│ ├── blocks-editor.js # Gutenberg editor scripts │ ├── blocks-editor.js # Gutenberg editor scripts
│ ├── cf7-integration.js # CF7 form scripts │ ├── cf7-integration.js # CF7 form scripts
│ └── frontend.js # Frontend scripts (~825 lines) │ └── frontend.js # Frontend scripts
├── templates/ # Twig templates (future) ├── languages/ # Translation files (.pot/.po/.mo)
├── languages/ # Translation files (future) ├── lib/ # Git submodules
└── releases/ # Release packages (git-ignored) │ └── 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 ### Implementation Details
@@ -935,3 +958,380 @@ Admin features always work; frontend requires valid license.
- CSV export with BOM (`\xEF\xBB\xBF`) ensures Excel compatibility - CSV export with BOM (`\xEF\xBB\xBF`) ensures Excel compatibility
- Guest data aggregation from bookings uses unique key pattern for anonymous guests - Guest data aggregation from bookings uses unique key pattern for anonymous guests
- Occupancy calculation: (booked nights / total room nights) * 100 - Occupancy calculation: (booked nights / total room nights) * 100
### 2026-02-03 - Version 0.9.0 (Prometheus Metrics)
**Completed:**
- Created `src/Integration/Prometheus.php` class (~700 lines)
- Integration with wp-prometheus via `wp_prometheus_collect_metrics` hook
- Dashboard registration via `wp_prometheus_register_dashboards` hook
- Option to enable/disable metrics collection
- Inventory metrics: buildings total, rooms by status, services by status
- Booking metrics: by status, check-ins/outs today, upcoming 7 days, avg duration
- Guest metrics: total, by status, repeat guests, new this month
- Occupancy metrics: current rate, monthly rate, occupied rooms, bed capacity
- Revenue metrics: this month, YTD, avg booking value, services revenue
- Optimized SQL queries using `$wpdb->prepare()` throughout
- Created `assets/grafana/wp-bnb-dashboard.json` Grafana dashboard
- 24 panels with responsive grid layout
- Occupancy gauges with color-coded thresholds (red < 30%, orange < 50%, yellow < 70%, green ≥ 70%)
- Pie charts for bookings, rooms, and guests by status
- Revenue stat panels (this month, YTD, avg value, services)
- Guest stat panels (total, new, repeat, active services)
- Today's activity panels (check-ins, check-outs, upcoming)
- Prometheus datasource variable for flexibility
- Auto-refresh every 5 minutes
- Updated `src/Plugin.php`
- Added Prometheus class import
- Initialized Prometheus integration in `init_components()`
- Added "Metrics" tab to settings page (6 tabs total)
- Added `render_metrics_settings()` method with WP Prometheus detection
- Added `save_metrics_settings()` method
- Metrics reference table showing all available metrics
- Updated version to 0.9.0
**Files Created:**
- `src/Integration/Prometheus.php` - Prometheus metrics integration class
- `assets/grafana/wp-bnb-dashboard.json` - Pre-configured Grafana dashboard
**Files Changed:**
- `src/Plugin.php` - Prometheus initialization, metrics settings tab
- `wp-bnb.php` - Version bump to 0.9.0 (header and constant)
- `CHANGELOG.md` - Added v0.9.0 release notes
- `PLAN.md` - Marked Phase 9 as complete
- `README.md` - Added Prometheus metrics documentation
**Learnings:**
- wp-prometheus uses `wp_prometheus_collect_metrics` action with collector object
- Collector provides `register_gauge()` for fluctuating values
- Labels are passed as array to `register_gauge()`, values to `set()`
- Grafana dashboard JSON requires proper panel IDs and grid positions
- Occupancy queries need careful date range handling for month boundaries
- Revenue queries use `DECIMAL(10,2)` casting for accurate sums
- 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

182
PLAN.md
View File

@@ -180,33 +180,46 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [x] Guest statistics - [x] Guest statistics
- [x] Export functionality (CSV, PDF) - [x] Export functionality (CSV, PDF)
## Phase 9: Prometheus Metrics (v0.9.0) ## Phase 9: Prometheus Metrics (v0.9.0) - Complete
- [ ] Meanigful Metrics for this Plugin, see <https://src.bundespruefstelle.ch/magdev/wp-prometheus/raw/branch/main/README.md> for implementation details - [x] Meaningful Metrics for this Plugin:
- [ ] Example Grafana-Dashboard, see <https://src.bundespruefstelle.ch/magdev/wp-prometheus/raw/branch/main/README.md> for implementation details - Inventory: buildings, rooms by status, services by status
- [ ] Update settings page to enable/disable metrics - Bookings: by status, check-ins/check-outs today, upcoming, avg duration
- Guests: total, by status, repeat guests, new this month
- Occupancy: current rate, monthly rate, occupied rooms, bed capacity
- Revenue: this month, YTD, average booking value, services revenue
- [x] Example Grafana Dashboard:
- Pre-configured dashboard JSON at `assets/grafana/wp-bnb-dashboard.json`
- Automatic registration with wp-prometheus
- 24 panels with gauges, pie charts, and stat displays
- [x] Update settings page to enable/disable metrics
## Phase 10: Security Audit (v0.10.0) ### Phase 10: API Endpoints (v0.10.0) - Complete
- [ ] Check for Wordpress best-practises - [x] REST API for rooms (list, details, availability, calendar)
- [ ] Review the code for OWASP Top 10, including XSS, XSRF, SQLi and other critical threads - [x] REST API for availability (search available rooms)
- [x] REST API for bookings (CRUD, status transitions)
- [x] REST API for buildings, guests, services, pricing
- [x] Authentication (Application Passwords, edit_posts capability)
- [x] Transient-based rate limiting with tiered limits
- [x] API settings tab with enable/disable toggles
### Phase 11: WooCommerce Integration (v0.11.0) - Complete
- [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+) ## Future Considerations (v1.0.0+)
### WooCommerce Integration (Optional)
- [ ] Payment processing
- [ ] Invoice generation
- [ ] Order management
- [ ] Refund handling
### API Endpoints
- [ ] REST API for rooms
- [ ] REST API for availability
- [ ] REST API for bookings
- [ ] Authentication and rate limiting
### Multi-language Support ### Multi-language Support
- [ ] Full translation support - [ ] Full translation support
@@ -226,53 +239,90 @@ This document outlines the implementation plan for the WP BnB Management plugin.
```text ```text
wp-bnb/ wp-bnb/
├── wp-bnb.php # Main plugin file ├── wp-bnb.php # Main plugin file (entry point)
├── composer.json # Dependencies ├── composer.json # Composer configuration
├── src/ # PHP source (PSR-4) ├── composer.lock # Dependency lock file
│ ├── Plugin.php # Main plugin class ├── CHANGELOG.md # Version history
│ ├── License/ # License management ├── CLAUDE.md # AI assistant documentation
└── Manager.php ├── PLAN.md # Implementation roadmap
│ ├── PostTypes/ # Custom post types ├── README.md # User documentation
├── Building.php ├── .editorconfig # Editor configuration
├── Room.php ├── .gitignore # Git ignore patterns
├── Booking.php ├── .gitmodules # Git submodule configuration
│ │ ├── Guest.php ├── .gitea/
│ └── Service.php │ └── workflows/
├── Taxonomies/ # Custom taxonomies └── release.yml # CI/CD release pipeline
├── RoomType.php ├── src/ # PHP source (PSR-4: Magdev\WpBnb)
│ └── Amenity.php ├── Plugin.php # Main plugin singleton
│ ├── Admin/ # Admin functionality │ ├── Admin/ # Admin pages
│ │ ├── Dashboard.php │ │ ├── Calendar.php # Availability calendar page
│ │ ├── Settings.php │ │ ├── Dashboard.php # Dashboard page with statistics
│ │ ── MetaBoxes.php │ │ ── Reports.php # Reports page with exports
├── Frontend/ # Frontend functionality │ └── Seasons.php # Seasons management page
│ ├── Shortcodes.php │ ├── Api/ # REST API (v0.10.0+)
│ │ ├── Widgets.php │ │ ├── RestApi.php # Main API registration
│ │ ── Search.php │ │ ── 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 │ ├── Blocks/ # Gutenberg blocks
│ │ ── Building.php │ │ ── BlockRegistrar.php # Block registration and rendering
│ │ ├── Room.php
│ │ └── Search.php
│ ├── Pricing/ # Pricing logic
│ │ ├── Calculator.php
│ │ └── PricingTier.php
│ ├── Booking/ # Booking logic │ ├── Booking/ # Booking logic
│ │ ├── Manager.php │ │ ├── Availability.php # Availability checking
│ │ ── Calendar.php │ │ ── EmailNotifier.php # Email notifications
│ └── Workflow.php ├── Frontend/ # Frontend components
└── Integration/ # Third-party integrations │ ├── Search.php # Room search and AJAX handlers
── CF7.php ── Shortcodes.php # All shortcode handlers
├── templates/ # Twig templates │ │ └── Widgets/ # WordPress widgets
├── admin/ │ ├── AvailabilityCalendar.php
├── frontend/ │ ├── BuildingRooms.php
└── email/ │ └── 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 ├── assets/ # CSS, JS, images
│ ├── css/ │ ├── css/
│ ├── js/ │ ├── admin.css # Admin styles
└── images/ │ ├── blocks-editor.css # Gutenberg editor styles
├── languages/ # Translation files ├── 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 ├── lib/ # Git submodules
│ └── wc-licensed-product-client/ │ └── wc-licensed-product-client/
├── releases/ # Release packages (git-ignored)
├── templates/ # Twig templates (reserved for future)
└── vendor/ # Composer dependencies └── vendor/ # Composer dependencies
``` ```
@@ -298,7 +348,7 @@ The plugin will provide extensive hooks for customization:
## Version Milestones ## Version Milestones
| Version | Focus | Target | | Version | Focus | Target |
| ------- | ------------------ | -------- | | ------- | ----------------------- | -------- |
| 0.0.1 | Initial setup | Complete | | 0.0.1 | Initial setup | Complete |
| 0.1.0 | Data structures | Complete | | 0.1.0 | Data structures | Complete |
| 0.2.0 | Pricing | Complete | | 0.2.0 | Pricing | Complete |
@@ -308,6 +358,8 @@ The plugin will provide extensive hooks for customization:
| 0.6.0 | Frontend | Complete | | 0.6.0 | Frontend | Complete |
| 0.7.0 | CF7 Integration | Complete | | 0.7.0 | CF7 Integration | Complete |
| 0.8.0 | Dashboard | Complete | | 0.8.0 | Dashboard | Complete |
| 0.9.0 | Prometheus Metrics | TBD | | 0.9.0 | Prometheus Metrics | Complete |
| 0.10.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 | | 1.0.0 | Stable Release | TBD |

363
README.md
View File

@@ -19,8 +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 - **Auto-Updates**: Automatic update checks and installation from license server
- **Development Mode**: License bypass for local development environments - **Development Mode**: License bypass for local development environments
- **Contact Form 7 Integration**: Accept booking requests and inquiries through CF7 forms - **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 - **Dashboard**: Comprehensive admin dashboard with statistics and charts
- **Reports**: Detailed reports with CSV and PDF export - **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 ### Requirements
@@ -28,6 +31,7 @@ WP BnB Management enables WordPress to act as a full management system for B&B h
- PHP 8.3 or higher - PHP 8.3 or higher
- Valid license key - Valid license key
- Contact Form 7 (optional, for booking forms) - Contact Form 7 (optional, for booking forms)
- WooCommerce 8.0+ (optional, for payments and invoicing)
## Installation ## Installation
@@ -383,6 +387,363 @@ add_action( 'wp_bnb_before_booking_create', function( $booking_data ) {
} ); } );
``` ```
## Prometheus Metrics
The plugin integrates with [WP Prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) to expose operational metrics for monitoring with Prometheus and Grafana.
### Enabling Metrics
1. Install and activate the WP Prometheus plugin
2. Navigate to **WP BnB → Settings → Metrics**
3. Enable "Expose BnB metrics via Prometheus"
4. Metrics will be available at your site's `/metrics/` endpoint
### Available Metrics
**Inventory Metrics:**
- `wp_bnb_buildings_total` - Total number of buildings
- `wp_bnb_rooms_total{status}` - Rooms by status (available, occupied, maintenance, inactive)
- `wp_bnb_services_total{status}` - Services by status (active, inactive)
- `wp_bnb_total_capacity_beds` - Total bed capacity across all rooms
**Booking Metrics:**
- `wp_bnb_bookings_total{status}` - Bookings by status (pending, confirmed, checked_in, checked_out, cancelled)
- `wp_bnb_checkins_today` - Check-ins scheduled for today
- `wp_bnb_checkouts_today` - Check-outs scheduled for today
- `wp_bnb_bookings_upcoming_7days` - Bookings starting in next 7 days
- `wp_bnb_booking_avg_duration_nights` - Average booking duration
**Occupancy Metrics:**
- `wp_bnb_occupancy_rate_current` - Current room occupancy rate (percentage)
- `wp_bnb_occupancy_rate_this_month` - Monthly occupancy rate (percentage)
- `wp_bnb_rooms_currently_occupied` - Rooms currently occupied
**Revenue Metrics:**
- `wp_bnb_revenue_this_month{currency}` - Revenue for current month
- `wp_bnb_revenue_ytd{currency}` - Revenue year to date
- `wp_bnb_booking_avg_value{currency}` - Average booking value
- `wp_bnb_services_revenue_this_month{currency}` - Services revenue this month
**Guest Metrics:**
- `wp_bnb_guests_total` - Total registered guests
- `wp_bnb_guests_by_status{status}` - Guests by status (active, blocked, vip)
- `wp_bnb_guests_repeat` - Guests with more than one booking
- `wp_bnb_guests_new_this_month` - New guests this month
### Grafana Dashboard
A pre-configured Grafana dashboard is included at `assets/grafana/wp-bnb-dashboard.json`. If WP Prometheus is installed, the dashboard is automatically registered and available for export.
The dashboard includes:
- Occupancy gauges with color-coded thresholds
- Bookings, rooms, and guests pie charts by status
- Revenue and guest statistics panels
- 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.
### Enabling the API
1. Navigate to **WP BnB → Settings → 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
All API endpoints are prefixed with:
```txt
https://your-site.com/wp-json/wp-bnb/v1/
```
### Authentication
**Public endpoints** (room listings, availability checks) require no authentication.
**Admin endpoints** (booking management, guest data) require authentication via:
- **Cookie + Nonce**: For same-domain JavaScript requests
- **Application Passwords**: For external applications (WordPress 5.6+, recommended)
To create an Application Password:
1. Go to **Users → Profile**
2. Scroll to "Application Passwords"
3. Enter a name and click "Add New Application Password"
4. Use the generated password with HTTP Basic Auth
```bash
curl -u "username:app-password" https://site.com/wp-json/wp-bnb/v1/bookings
```
### Public Endpoints
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/buildings` | List all buildings |
| GET | `/buildings/{id}` | Get building details |
| GET | `/buildings/{id}/rooms` | Get rooms in a building |
| GET | `/rooms` | List/search rooms |
| GET | `/rooms/{id}` | Get room details |
| GET | `/rooms/{id}/availability` | Check room availability |
| GET | `/rooms/{id}/calendar` | Get monthly calendar data |
| POST | `/availability/search` | Search available rooms |
| GET | `/services` | List all services |
| GET | `/services/{id}` | Get service details |
| POST | `/pricing/calculate` | Calculate booking price |
| POST | `/bookings` | Create a new booking (pending status) |
### Admin Endpoints
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/bookings` | List all bookings |
| GET | `/bookings/{id}` | Get booking details |
| PATCH | `/bookings/{id}` | Update a booking |
| DELETE | `/bookings/{id}` | Cancel a booking |
| POST | `/bookings/{id}/confirm` | Confirm a pending booking |
| POST | `/bookings/{id}/check-in` | Check in a guest |
| POST | `/bookings/{id}/check-out` | Check out a guest |
| GET | `/guests` | List all guests |
| GET | `/guests/{id}` | Get guest details |
| GET | `/guests/search` | Search guests |
| GET | `/guests/{id}/bookings` | Get guest's booking history |
### Rate Limiting
When enabled, rate limits are applied per client (by user ID or IP address). Configure limits in **Settings → API → Rate Limits**.
**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
- `X-RateLimit-Remaining`: Requests remaining in window
- `X-RateLimit-Reset`: Unix timestamp when limit resets
### Example: Check Room Availability
```bash
curl "https://site.com/wp-json/wp-bnb/v1/rooms/42/availability?check_in=2026-03-15&check_out=2026-03-20"
```
Response:
```json
{
"available": true,
"room_id": 42,
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"nights": 5,
"pricing": {
"base_price": 500.00,
"seasonal_modifier": 1.0,
"weekend_surcharge": 40.00,
"total": 540.00,
"currency": "CHF"
}
}
```
### Example: Create a Booking
```bash
curl -X POST https://site.com/wp-json/wp-bnb/v1/bookings \
-H "Content-Type: application/json" \
-d '{
"room_id": 42,
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"guests": 2,
"guest_info": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+41 79 123 4567"
},
"services": [
{"service_id": 5, "quantity": 1}
],
"notes": "Late arrival expected"
}'
```
Response:
```json
{
"id": 123,
"reference": "BNB-2026-00042",
"status": "pending",
"room": {
"id": 42,
"title": "Deluxe Suite"
},
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"nights": 5,
"guests": 2,
"pricing": {
"room_total": 540.00,
"services_total": 50.00,
"grand_total": 590.00,
"currency": "CHF"
},
"_links": {
"self": [{"href": "https://site.com/wp-json/wp-bnb/v1/bookings/123"}]
}
}
```
### Example: Search Available Rooms
```bash
curl -X POST https://site.com/wp-json/wp-bnb/v1/availability/search \
-H "Content-Type: application/json" \
-d '{
"check_in": "2026-03-15",
"check_out": "2026-03-20",
"guests": 2,
"amenities": ["wifi", "parking"]
}'
```
### Error Responses
Errors follow WordPress REST API conventions:
```json
{
"code": "rest_not_found",
"message": "Room not found.",
"data": {
"status": 404
}
}
```
Common error codes:
- `rest_invalid_param` (400): Invalid request parameters
- `rest_forbidden` (403): Insufficient permissions
- `rest_not_found` (404): Resource not found
- `rest_conflict` (409): Booking conflict
- `rest_rate_limit_exceeded` (429): Rate limit exceeded
## Frequently Asked Questions ## Frequently Asked Questions
### Do I need a license to use this plugin? ### Do I need a license to use this plugin?
@@ -399,7 +760,7 @@ Yes, guest data can be exported and deleted on request, and consent is tracked a
### Does it integrate with WooCommerce? ### 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? ### How is guest data secured?

View File

@@ -491,6 +491,37 @@
height: 16px; 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 Tables */
.form-table th { .form-table th {
width: 200px; width: 200px;
@@ -567,12 +598,36 @@
display: inline-block; display: inline-block;
padding: 3px 8px; padding: 3px 8px;
border-radius: 3px; border-radius: 3px;
color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; 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 */ /* Room Details Meta Box */
#bnb_room_details .form-table td label { #bnb_room_details .form-table td label {
display: inline-block; display: inline-block;
@@ -895,13 +950,17 @@
/* Calendar Filters */ /* Calendar Filters */
.bnb-calendar-filters { .bnb-calendar-filters {
display: flex;
gap: 15px;
padding: 15px 20px; padding: 15px 20px;
border-bottom: 1px solid #c3c4c7; border-bottom: 1px solid #c3c4c7;
background: #f9f9f9; background: #f9f9f9;
} }
.bnb-calendar-filters form {
display: flex;
align-items: center;
gap: 20px;
}
.bnb-calendar-filters label { .bnb-calendar-filters label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -921,7 +980,7 @@
.bnb-calendar-table { .bnb-calendar-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: auto;
} }
.bnb-calendar-table th, .bnb-calendar-table th,
@@ -942,7 +1001,8 @@
.bnb-calendar-table th.room-header { .bnb-calendar-table th.room-header {
text-align: left; text-align: left;
padding-left: 10px; padding-left: 10px;
min-width: 150px; width: 200px;
min-width: 200px;
} }
/* Calendar Day Cell */ /* Calendar Day Cell */
@@ -1013,16 +1073,31 @@
} }
/* Room Row in Multi-Room Calendar */ /* Room Row in Multi-Room Calendar */
.bnb-calendar-room { .bnb-calendar-table .bnb-calendar-room {
font-weight: 600; font-weight: 600;
text-align: left; text-align: left;
padding: 8px 10px; padding: 8px 10px;
background: #f6f7f7; 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; font-weight: normal;
color: #646970; 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 */ /* Calendar Legend */
@@ -1102,8 +1177,9 @@
/* Responsive */ /* Responsive */
@media screen and (max-width: 782px) { @media screen and (max-width: 782px) {
.bnb-calendar-filters { .bnb-calendar-filters form {
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
.bnb-calendar-filters select { .bnb-calendar-filters select {

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

File diff suppressed because it is too large Load Diff

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 foreach ( $rooms as $room ) : ?>
<?php <?php
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true ); $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 ); $booked_dates = Availability::get_booked_dates( $room->ID, $year, $month );
?> ?>
<tr> <tr>
<td class="bnb-calendar-room"> <td class="bnb-calendar-room">
<a href="<?php echo esc_url( get_edit_post_link( $room->ID ) ); ?>"> <a href="<?php echo esc_url( get_edit_post_link( $room->ID ) ); ?>">
<?php echo esc_html( $room->post_title ); ?> <?php echo esc_html( $room->post_title ); ?>
</a>
<?php if ( $room_number ) : ?> <?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; ?> <?php endif; ?>
</td> </td>
<?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?> <?php for ( $day = 1; $day <= $days_in_month; $day++ ) : ?>

View File

@@ -0,0 +1,382 @@
<?php
/**
* Abstract REST Controller
*
* Base class for all REST API controllers with common functionality.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\Api\RestApi;
use Magdev\WpBnb\Api\RateLimiter;
use Magdev\WpBnb\Api\ResponseFormatter;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Abstract Controller class.
*/
abstract class AbstractController extends WP_REST_Controller {
/**
* API namespace.
*
* @var string
*/
protected $namespace = RestApi::NAMESPACE;
/**
* Rate limiter instance.
*
* @var RateLimiter
*/
protected RateLimiter $rate_limiter;
/**
* Response formatter instance.
*
* @var ResponseFormatter
*/
protected ResponseFormatter $formatter;
/**
* Constructor.
*/
public function __construct() {
$this->rate_limiter = new RateLimiter();
$this->formatter = new ResponseFormatter();
}
/**
* Check rate limit before processing request.
*
* @param WP_REST_Request $request Current request.
* @return WP_Error|null Error if rate limited, null otherwise.
*/
protected function check_rate_limit( WP_REST_Request $request ): ?WP_Error {
// Skip rate limiting if disabled.
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return null;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
if ( ! $this->rate_limiter->check( $identifier, $endpoint ) ) {
return $this->formatter->rate_limit_error(
$this->rate_limiter->get_retry_after( $identifier, $endpoint )
);
}
return null;
}
/**
* Add rate limit headers to response.
*
* @param WP_REST_Response $response Current response.
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response Response with headers.
*/
protected function add_rate_limit_headers( WP_REST_Response $response, WP_REST_Request $request ): WP_REST_Response {
if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) {
return $response;
}
$identifier = $this->get_client_identifier( $request );
$endpoint = $request->get_route();
$info = $this->rate_limiter->get_rate_limit_info( $identifier, $endpoint );
$response->header( 'X-RateLimit-Limit', (string) $info['limit'] );
$response->header( 'X-RateLimit-Remaining', (string) $info['remaining'] );
$response->header( 'X-RateLimit-Reset', (string) $info['reset'] );
return $response;
}
/**
* Get client identifier for rate limiting.
*
* @param WP_REST_Request $request Current request.
* @return string Client identifier.
*/
protected function get_client_identifier( WP_REST_Request $request ): string {
// Use user ID if authenticated.
$user_id = get_current_user_id();
if ( $user_id > 0 ) {
return 'user_' . $user_id;
}
return 'ip_' . $this->get_client_ip();
}
/**
* Get client IP address.
*
* Supports proxies and Cloudflare.
*
* @return string Client IP address.
*/
protected function get_client_ip(): string {
$headers = array(
'HTTP_CF_CONNECTING_IP', // Cloudflare.
'HTTP_X_FORWARDED_FOR', // Proxy.
'HTTP_X_REAL_IP', // Nginx.
'REMOTE_ADDR',
);
foreach ( $headers as $header ) {
if ( ! empty( $_SERVER[ $header ] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
// Handle comma-separated list (X-Forwarded-For).
if ( str_contains( $ip, ',' ) ) {
$ip = trim( explode( ',', $ip )[0] );
}
return $ip;
}
}
return '127.0.0.1';
}
/**
* Validate date format (Y-m-d).
*
* @param string $date Date string.
* @return bool True if valid.
*/
protected function validate_date( string $date ): bool {
$d = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
return $d && $d->format( 'Y-m-d' ) === $date;
}
/**
* Validate date is not in the past.
*
* @param string $date Date string (Y-m-d).
* @return bool True if date is today or future.
*/
protected function validate_future_date( string $date ): bool {
if ( ! $this->validate_date( $date ) ) {
return false;
}
$date_obj = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date );
$today = new \DateTimeImmutable( 'today' );
return $date_obj >= $today;
}
/**
* Permission callback for public endpoints.
*
* @return bool Always true.
*/
public function public_permission(): bool {
return true;
}
/**
* Permission callback for authenticated endpoints.
*
* @return bool True if logged in.
*/
public function authenticated_permission(): bool {
return is_user_logged_in();
}
/**
* Permission callback for admin endpoints.
*
* @return bool True if user can edit posts.
*/
public function admin_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Permission callback for managing bookings.
*
* @return bool True if user can edit posts.
*/
public function manage_bookings_permission(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Get pagination parameters from request.
*
* @param WP_REST_Request $request Current request.
* @return array{page: int, per_page: int, offset: int}
*/
protected function get_pagination_params( WP_REST_Request $request ): array {
$page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
$per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 10 ) );
$offset = ( $page - 1 ) * $per_page;
return array(
'page' => $page,
'per_page' => $per_page,
'offset' => $offset,
);
}
/**
* Get sorting parameters from request.
*
* @param WP_REST_Request $request Current request.
* @param array $allowed_orderby Allowed orderby values.
* @param string $default_orderby Default orderby value.
* @return array{orderby: string, order: string}
*/
protected function get_sorting_params( WP_REST_Request $request, array $allowed_orderby = array( 'title', 'date' ), string $default_orderby = 'title' ): array {
$orderby = $request->get_param( 'orderby' ) ?: $default_orderby;
$order = strtoupper( $request->get_param( 'order' ) ?: 'ASC' );
// Validate orderby.
if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
$orderby = $default_orderby;
}
// Validate order.
if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
$order = 'ASC';
}
return array(
'orderby' => $orderby,
'order' => $order,
);
}
/**
* Format post for API response.
*
* @param \WP_Post $post Post object.
* @return array Basic post data.
*/
protected function format_post_base( \WP_Post $post ): array {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'excerpt' => get_the_excerpt( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
);
}
/**
* Format featured image for API response.
*
* @param int $post_id Post ID.
* @return array|null Image data or null.
*/
protected function format_featured_image( int $post_id ): ?array {
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( ! $thumbnail_id ) {
return null;
}
return $this->format_image( $thumbnail_id );
}
/**
* Format image attachment for API response.
*
* @param int $attachment_id Attachment ID.
* @return array|null Image data or null.
*/
protected function format_image( int $attachment_id ): ?array {
$full = wp_get_attachment_image_src( $attachment_id, 'full' );
if ( ! $full ) {
return null;
}
$sizes = array();
foreach ( array( 'thumbnail', 'medium', 'large' ) as $size ) {
$src = wp_get_attachment_image_src( $attachment_id, $size );
if ( $src ) {
$sizes[ $size ] = array(
'url' => $src[0],
'width' => $src[1],
'height' => $src[2],
);
}
}
return array(
'id' => $attachment_id,
'url' => $full[0],
'width' => $full[1],
'height' => $full[2],
'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
'sizes' => $sizes,
);
}
/**
* Add HATEOAS links to response item.
*
* @param array $item Response item.
* @param string $route Base route for self link.
* @param int $id Item ID.
* @return array Item with _links.
*/
protected function add_links( array $item, string $route, int $id ): array {
$item['_links'] = array(
'self' => array(
array(
'href' => rest_url( $this->namespace . '/' . $route . '/' . $id ),
),
),
);
return $item;
}
/**
* Get common collection parameters for schema.
*
* @return array Collection parameters.
*/
public function get_collection_params(): array {
return array(
'page' => array(
'description' => __( 'Current page of the collection.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'description' => __( 'Maximum number of items to be returned per page.', 'wp-bnb' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
),
'search' => array(
'description' => __( 'Limit results to those matching a string.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'orderby' => array(
'description' => __( 'Sort collection by attribute.', 'wp-bnb' ),
'type' => 'string',
'default' => 'title',
'sanitize_callback' => 'sanitize_text_field',
),
'order' => array(
'description' => __( 'Order sort attribute ascending or descending.', 'wp-bnb' ),
'type' => 'string',
'default' => 'asc',
'enum' => array( 'asc', 'desc' ),
'sanitize_callback' => 'sanitize_text_field',
),
);
}
}

View File

@@ -0,0 +1,930 @@
<?php
/**
* Bookings REST Controller
*
* Handles REST API endpoints for bookings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Bookings Controller class.
*/
final class BookingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'bookings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /bookings - List bookings (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_bookings_collection_params(),
),
)
);
// POST /bookings - Create booking (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_create_booking_params(),
),
)
);
// GET /bookings/{id} - Get single booking.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// PATCH /bookings/{id} - Update booking (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_update_booking_params(),
),
)
);
// DELETE /bookings/{id} - Cancel booking (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/confirm - Confirm booking.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/confirm',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'confirm_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/check-in - Check in guest.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/check-in',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'check_in_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /bookings/{id}/check-out - Check out guest.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/check-out',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'check_out_booking' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of bookings.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$args = array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => 'date',
'order' => 'DESC',
);
$meta_query = array();
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$meta_query[] = array(
'key' => '_bnb_booking_status',
'value' => $status,
);
}
// Room filter.
$room_id = $request->get_param( 'room_id' );
if ( $room_id ) {
$meta_query[] = array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
);
}
// Guest filter.
$guest_id = $request->get_param( 'guest_id' );
if ( $guest_id ) {
$meta_query[] = array(
'key' => '_bnb_booking_guest_id',
'value' => $guest_id,
);
}
// Date range filter.
$date_from = $request->get_param( 'date_from' );
if ( $date_from && $this->validate_date( $date_from ) ) {
$meta_query[] = array(
'key' => '_bnb_booking_check_in',
'value' => $date_from,
'compare' => '>=',
'type' => 'DATE',
);
}
$date_to = $request->get_param( 'date_to' );
if ( $date_to && $this->validate_date( $date_to ) ) {
$meta_query[] = array(
'key' => '_bnb_booking_check_in',
'value' => $date_to,
'compare' => '<=',
'type' => 'DATE',
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$args['meta_query'] = $meta_query;
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_booking_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Create a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function create_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'room_id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
$guest = $request->get_param( 'guest' );
// Validate room.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Check availability.
if ( ! Availability::is_available( $room_id, $check_in, $check_out ) ) {
return $this->formatter->conflict(
__( 'Room is not available for the selected dates.', 'wp-bnb' )
);
}
// Validate guest info.
if ( empty( $guest['first_name'] ) || empty( $guest['last_name'] ) || empty( $guest['email'] ) ) {
return $this->formatter->validation_error( 'guest', __( 'Guest first name, last name, and email are required.', 'wp-bnb' ) );
}
if ( ! is_email( $guest['email'] ) ) {
return $this->formatter->validation_error( 'guest.email', __( 'Invalid email address.', 'wp-bnb' ) );
}
// Find or create guest.
$guest_id = $this->find_or_create_guest( $guest );
// Calculate price.
$calculator = new Calculator( $room_id, $check_in, $check_out );
$room_price = $calculator->calculate();
$services = $request->get_param( 'services' ) ?? array();
// Calculate services total.
$services_total = 0;
$services_data = array();
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
foreach ( $services as $service_item ) {
$service_id = $service_item['service_id'] ?? 0;
$quantity = $service_item['quantity'] ?? 1;
$service_data = Service::get_service_data( $service_id );
if ( $service_data ) {
$service_price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_total += $service_price;
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $service_price,
'pricing_type' => $service_data['pricing_type'],
);
}
}
$total_price = $room_price + $services_total;
// Generate reference.
$reference = Booking::generate_reference();
// Create booking post.
$guest_name = trim( $guest['first_name'] . ' ' . $guest['last_name'] );
$post_id = wp_insert_post(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'post_title' => $guest_name . ' (' . $check_in . ' - ' . $check_out . ')',
)
);
if ( is_wp_error( $post_id ) ) {
return $this->formatter->server_error( __( 'Failed to create booking.', 'wp-bnb' ) );
}
// Save meta.
update_post_meta( $post_id, '_bnb_booking_room_id', $room_id );
update_post_meta( $post_id, '_bnb_booking_guest_id', $guest_id );
update_post_meta( $post_id, '_bnb_booking_guest_name', $guest_name );
update_post_meta( $post_id, '_bnb_booking_guest_email', sanitize_email( $guest['email'] ) );
update_post_meta( $post_id, '_bnb_booking_guest_phone', sanitize_text_field( $guest['phone'] ?? '' ) );
update_post_meta( $post_id, '_bnb_booking_check_in', $check_in );
update_post_meta( $post_id, '_bnb_booking_check_out', $check_out );
update_post_meta( $post_id, '_bnb_booking_status', 'pending' );
update_post_meta( $post_id, '_bnb_booking_adults', absint( $request->get_param( 'guests_count' ) ?? 1 ) );
update_post_meta( $post_id, '_bnb_booking_calculated_price', $room_price );
update_post_meta( $post_id, '_bnb_booking_total_price', $total_price );
update_post_meta( $post_id, '_bnb_booking_reference', $reference );
if ( ! empty( $services_data ) ) {
update_post_meta( $post_id, '_bnb_booking_services', wp_json_encode( $services_data ) );
}
$notes = $request->get_param( 'notes' );
if ( $notes ) {
update_post_meta( $post_id, '_bnb_booking_guest_notes', sanitize_textarea_field( $notes ) );
}
// Send notification email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_admin_new_booking( $post_id );
}
// Prepare response.
$booking = get_post( $post_id );
$data = $this->prepare_booking_response( $booking, true );
$location = rest_url( $this->namespace . '/bookings/' . $post_id );
$response = $this->formatter->created( $data, $location );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
$data = $this->prepare_booking_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Update a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function update_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
// Update status if provided.
$status = $request->get_param( 'status' );
if ( $status ) {
$current_status = get_post_meta( $id, '_bnb_booking_status', true );
if ( ! Booking::can_transition_to( $current_status, $status ) ) {
return $this->formatter->validation_error(
'status',
sprintf(
/* translators: %1$s: current status, %2$s: target status */
__( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ),
$current_status,
$status
)
);
}
update_post_meta( $id, '_bnb_booking_status', $status );
if ( 'confirmed' === $status ) {
update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
}
}
// Update notes if provided.
$notes = $request->get_param( 'notes' );
if ( null !== $notes ) {
update_post_meta( $id, '_bnb_booking_notes', sanitize_textarea_field( $notes ) );
}
// Update guest notes if provided.
$guest_notes = $request->get_param( 'guest_notes' );
if ( null !== $guest_notes ) {
update_post_meta( $id, '_bnb_booking_guest_notes', sanitize_textarea_field( $guest_notes ) );
}
$booking = get_post( $id );
$data = $this->prepare_booking_response( $booking, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Cancel a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function delete_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
// Cancel the booking (don't delete).
update_post_meta( $id, '_bnb_booking_status', 'cancelled' );
// Send cancellation email.
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_cancellation( $id );
}
return $this->formatter->no_content();
}
/**
* Confirm a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function confirm_booking( $request ) {
return $this->transition_status( $request, 'confirmed' );
}
/**
* Check in a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function check_in_booking( $request ) {
return $this->transition_status( $request, 'checked_in' );
}
/**
* Check out a booking.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function check_out_booking( $request ) {
return $this->transition_status( $request, 'checked_out' );
}
/**
* Transition booking status.
*
* @param WP_REST_Request $request Current request.
* @param string $new_status Target status.
* @return WP_REST_Response|WP_Error Response object or error.
*/
private function transition_status( WP_REST_Request $request, string $new_status ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Booking::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) );
}
$current_status = get_post_meta( $id, '_bnb_booking_status', true );
if ( ! Booking::can_transition_to( $current_status, $new_status ) ) {
return $this->formatter->validation_error(
'status',
sprintf(
/* translators: %1$s: current status, %2$s: target status */
__( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ),
$current_status,
$new_status
)
);
}
update_post_meta( $id, '_bnb_booking_status', $new_status );
if ( 'confirmed' === $new_status ) {
update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) );
if ( class_exists( EmailNotifier::class ) ) {
EmailNotifier::send_guest_confirmation( $id );
}
}
$booking = get_post( $id );
$data = $this->prepare_booking_response( $booking, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Find or create a guest from booking data.
*
* @param array $guest_data Guest data.
* @return int Guest post ID.
*/
private function find_or_create_guest( array $guest_data ): int {
$email = sanitize_email( $guest_data['email'] );
// Check if guest exists.
$existing = Guest::get_by_email( $email );
if ( $existing ) {
return $existing->ID;
}
// Create new guest.
$guest_name = trim( $guest_data['first_name'] . ' ' . $guest_data['last_name'] );
$guest_id = wp_insert_post(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'post_title' => $guest_name,
)
);
if ( is_wp_error( $guest_id ) ) {
return 0;
}
// Save guest meta.
update_post_meta( $guest_id, '_bnb_guest_first_name', sanitize_text_field( $guest_data['first_name'] ) );
update_post_meta( $guest_id, '_bnb_guest_last_name', sanitize_text_field( $guest_data['last_name'] ) );
update_post_meta( $guest_id, '_bnb_guest_email', $email );
update_post_meta( $guest_id, '_bnb_guest_status', 'active' );
if ( ! empty( $guest_data['phone'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_phone', sanitize_text_field( $guest_data['phone'] ) );
}
if ( ! empty( $guest_data['address'] ) ) {
$address = $guest_data['address'];
if ( ! empty( $address['street'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_street', sanitize_text_field( $address['street'] ) );
}
if ( ! empty( $address['city'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_city', sanitize_text_field( $address['city'] ) );
}
if ( ! empty( $address['postal_code'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_postal_code', sanitize_text_field( $address['postal_code'] ) );
}
if ( ! empty( $address['country'] ) ) {
update_post_meta( $guest_id, '_bnb_guest_country', sanitize_text_field( $address['country'] ) );
}
}
return $guest_id;
}
/**
* Prepare booking data for response.
*
* @param \WP_Post $post Booking post object.
* @param bool $full Include full details.
* @return array Booking data.
*/
private function prepare_booking_response( \WP_Post $post, bool $full = false ): array {
$room_id = get_post_meta( $post->ID, '_bnb_booking_room_id', true );
$guest_id = get_post_meta( $post->ID, '_bnb_booking_guest_id', true );
$check_in = get_post_meta( $post->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $post->ID, '_bnb_booking_check_out', true );
$status = get_post_meta( $post->ID, '_bnb_booking_status', true );
// Calculate nights.
$nights = 0;
if ( $check_in && $check_out ) {
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
}
$room = $room_id ? get_post( $room_id ) : null;
$data = array(
'id' => $post->ID,
'reference' => get_post_meta( $post->ID, '_bnb_booking_reference', true ) ?: $post->post_title,
'status' => $status,
'room' => $room ? array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
) : null,
'guest' => array(
'id' => (int) $guest_id,
'name' => get_post_meta( $post->ID, '_bnb_booking_guest_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_booking_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_booking_guest_phone', true ),
),
'dates' => array(
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
),
'pricing' => array(
'room_total' => (float) get_post_meta( $post->ID, '_bnb_booking_calculated_price', true ),
'services_total' => 0,
'grand_total' => (float) get_post_meta( $post->ID, '_bnb_booking_total_price', true ),
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
),
'created_at' => $post->post_date_gmt,
);
// Get building info.
if ( $room ) {
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
if ( $building ) {
$data['building'] = array(
'id' => $building->ID,
'title' => get_the_title( $building ),
);
}
}
// Calculate services total.
$services_json = get_post_meta( $post->ID, '_bnb_booking_services', true );
if ( $services_json ) {
$services = json_decode( $services_json, true );
if ( is_array( $services ) ) {
$services_total = 0;
$services_list = array();
foreach ( $services as $service ) {
$services_total += (float) ( $service['price'] ?? 0 );
$service_post = get_post( $service['service_id'] );
$services_list[] = array(
'id' => $service['service_id'],
'name' => $service_post ? get_the_title( $service_post ) : '',
'quantity' => $service['quantity'] ?? 1,
'price' => (float) ( $service['price'] ?? 0 ),
);
}
$data['pricing']['services_total'] = $services_total;
$data['services'] = $services_list;
}
}
if ( $full ) {
$data['notes'] = get_post_meta( $post->ID, '_bnb_booking_notes', true );
$data['guest_notes'] = get_post_meta( $post->ID, '_bnb_booking_guest_notes', true );
$data['adults'] = (int) get_post_meta( $post->ID, '_bnb_booking_adults', true );
$data['children'] = (int) get_post_meta( $post->ID, '_bnb_booking_children', true );
$data['confirmed_at'] = get_post_meta( $post->ID, '_bnb_booking_confirmed_at', true );
}
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/bookings/' . $post->ID ) ),
),
'room' => $room ? array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ),
) : array(),
);
return $data;
}
/**
* Get bookings collection parameters.
*
* @return array Collection parameters.
*/
private function get_bookings_collection_params(): array {
$params = $this->get_collection_params();
$params['status'] = array(
'description' => __( 'Filter by booking status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ),
'sanitize_callback' => 'sanitize_text_field',
);
$params['room_id'] = array(
'description' => __( 'Filter by room ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['guest_id'] = array(
'description' => __( 'Filter by guest ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['date_from'] = array(
'description' => __( 'Filter bookings with check-in from this date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['date_to'] = array(
'description' => __( 'Filter bookings with check-in until this date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
return $params;
}
/**
* Get create booking parameters.
*
* @return array Create parameters.
*/
private function get_create_booking_params(): array {
return array(
'room_id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'guest' => array(
'description' => __( 'Guest information.', 'wp-bnb' ),
'type' => 'object',
'required' => true,
'properties' => array(
'first_name' => array( 'type' => 'string', 'required' => true ),
'last_name' => array( 'type' => 'string', 'required' => true ),
'email' => array( 'type' => 'string', 'required' => true, 'format' => 'email' ),
'phone' => array( 'type' => 'string' ),
'address' => array(
'type' => 'object',
'properties' => array(
'street' => array( 'type' => 'string' ),
'city' => array( 'type' => 'string' ),
'postal_code' => array( 'type' => 'string' ),
'country' => array( 'type' => 'string' ),
),
),
),
),
'guests_count' => array(
'description' => __( 'Number of guests.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'services' => array(
'description' => __( 'Additional services.', 'wp-bnb' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'service_id' => array( 'type' => 'integer', 'required' => true ),
'quantity' => array( 'type' => 'integer', 'default' => 1 ),
),
),
),
'notes' => array(
'description' => __( 'Guest notes or special requests.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
);
}
/**
* Get update booking parameters.
*
* @return array Update parameters.
*/
private function get_update_booking_params(): array {
return array(
'id' => array(
'description' => __( 'Booking ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'status' => array(
'description' => __( 'Booking status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ),
'sanitize_callback' => 'sanitize_text_field',
),
'notes' => array(
'description' => __( 'Internal staff notes.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
'guest_notes' => array(
'description' => __( 'Guest notes or special requests.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
);
}
}

View File

@@ -0,0 +1,323 @@
<?php
/**
* Buildings REST Controller
*
* Handles REST API endpoints for buildings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Buildings Controller class.
*/
final class BuildingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'buildings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /buildings - List all buildings.
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_collection_params(),
),
)
);
// GET /buildings/{id} - Get single building.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /buildings/{id}/rooms - Get rooms in building.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/rooms',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_building_rooms' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'status' => array(
'description' => __( 'Filter by room status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
}
/**
* Get collection of buildings.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' );
$args = array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => $sorting['orderby'],
'order' => $sorting['order'],
);
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_building_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single building.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Building::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) );
}
$data = $this->prepare_building_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get rooms in a building.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_building_rooms( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$building_id = $request->get_param( 'id' );
$building = get_post( $building_id );
if ( ! $building || Building::POST_TYPE !== $building->post_type || 'publish' !== $building->post_status ) {
return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) );
}
$args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
array(
'key' => '_bnb_room_building_id',
'value' => $building_id,
),
),
'orderby' => 'meta_value',
'meta_key' => '_bnb_room_room_number',
'order' => 'ASC',
);
// Filter by status.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_status',
'value' => $status,
);
}
$rooms = get_posts( $args );
$items = array();
foreach ( $rooms as $room ) {
$items[] = $this->prepare_room_summary( $room );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare building data for response.
*
* @param \WP_Post $post Building post object.
* @param bool $full Include full details.
* @return array Building data.
*/
private function prepare_building_response( \WP_Post $post, bool $full = false ): array {
$data = $this->format_post_base( $post );
// Featured image.
$data['featured_image'] = $this->format_featured_image( $post->ID );
$data['permalink'] = get_permalink( $post->ID );
// Address.
$data['address'] = array(
'street' => get_post_meta( $post->ID, '_bnb_building_street', true ),
'street2' => get_post_meta( $post->ID, '_bnb_building_street2', true ),
'city' => get_post_meta( $post->ID, '_bnb_building_city', true ),
'state' => get_post_meta( $post->ID, '_bnb_building_state', true ),
'postal_code' => get_post_meta( $post->ID, '_bnb_building_zip', true ),
'country' => get_post_meta( $post->ID, '_bnb_building_country', true ),
);
// Contact.
$data['contact'] = array(
'phone' => get_post_meta( $post->ID, '_bnb_building_phone', true ),
'email' => get_post_meta( $post->ID, '_bnb_building_email', true ),
'website' => get_post_meta( $post->ID, '_bnb_building_website', true ),
);
// Details.
$data['details'] = array(
'rooms_count' => (int) get_post_meta( $post->ID, '_bnb_building_total_rooms', true ),
'floors' => (int) get_post_meta( $post->ID, '_bnb_building_floors', true ),
'year_built' => (int) get_post_meta( $post->ID, '_bnb_building_year_built', true ),
'check_in_time' => get_post_meta( $post->ID, '_bnb_building_check_in_time', true ) ?: '14:00',
'check_out_time' => get_post_meta( $post->ID, '_bnb_building_check_out_time', true ) ?: '11:00',
);
// Count actual rooms.
$actual_rooms = Room::get_rooms_for_building( $post->ID );
$data['details']['actual_rooms_count'] = count( $actual_rooms );
// Full address formatted.
if ( $full ) {
$data['address']['formatted'] = Building::get_formatted_address( $post->ID );
// Country name.
$countries = Building::get_countries();
$country_code = $data['address']['country'];
$data['address']['country_name'] = $countries[ $country_code ] ?? $country_code;
}
// Add HATEOAS links.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID ) ),
),
'rooms' => array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID . '/rooms' ) ),
),
);
return $data;
}
/**
* Prepare room summary for building rooms list.
*
* @param \WP_Post $room Room post object.
* @return array Room summary data.
*/
private function prepare_room_summary( \WP_Post $room ): array {
return array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'slug' => $room->post_name,
'permalink' => get_permalink( $room->ID ),
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
'floor' => (int) get_post_meta( $room->ID, '_bnb_room_floor', true ),
'capacity' => (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ),
'status' => get_post_meta( $room->ID, '_bnb_room_status', true ) ?: 'available',
'thumbnail' => get_the_post_thumbnail_url( $room->ID, 'thumbnail' ) ?: null,
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ),
),
),
);
}
}

View File

@@ -0,0 +1,452 @@
<?php
/**
* Guests REST Controller
*
* Handles REST API endpoints for guests (admin only).
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Booking;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Guests Controller class.
*/
final class GuestsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'guests';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /guests - List guests (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => $this->get_collection_params(),
),
)
);
// GET /guests/search - Search guests (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/search',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'search_guests' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'q' => array(
'description' => __( 'Search query (name, email, phone).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'limit' => array(
'description' => __( 'Maximum results.', 'wp-bnb' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 50,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /guests/{id} - Get single guest (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Guest ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /guests/{id}/bookings - Get guest's bookings (admin).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/bookings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_guest_bookings' ),
'permission_callback' => array( $this, 'admin_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Guest ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of guests.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' );
$args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
'orderby' => $sorting['orderby'],
'order' => $sorting['order'],
);
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'] = array(
array(
'key' => '_bnb_guest_status',
'value' => $status,
),
);
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_guest_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Search guests by name, email, or phone.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function search_guests( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$query = $request->get_param( 'q' );
$limit = $request->get_param( 'limit' );
// Search by title (name) first.
$args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $limit,
's' => $query,
);
$results = get_posts( $args );
// Also search by email and phone if we have room for more results.
if ( count( $results ) < $limit ) {
$existing_ids = wp_list_pluck( $results, 'ID' );
$remaining = $limit - count( $results );
// Search by email.
$email_args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $remaining,
'post__not_in' => $existing_ids,
'meta_query' => array(
array(
'key' => '_bnb_guest_email',
'value' => $query,
'compare' => 'LIKE',
),
),
);
$email_results = get_posts( $email_args );
$results = array_merge( $results, $email_results );
// Search by phone if still room.
if ( count( $results ) < $limit ) {
$existing_ids = wp_list_pluck( $results, 'ID' );
$remaining = $limit - count( $results );
$phone_args = array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $remaining,
'post__not_in' => $existing_ids,
'meta_query' => array(
array(
'key' => '_bnb_guest_phone',
'value' => $query,
'compare' => 'LIKE',
),
),
);
$phone_results = get_posts( $phone_args );
$results = array_merge( $results, $phone_results );
}
}
$items = array();
foreach ( $results as $post ) {
$items[] = $this->prepare_guest_summary( $post );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single guest.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Guest::POST_TYPE !== $post->post_type ) {
return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) );
}
$data = $this->prepare_guest_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get guest's booking history.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_guest_bookings( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$guest_id = $request->get_param( 'id' );
$guest = get_post( $guest_id );
if ( ! $guest || Guest::POST_TYPE !== $guest->post_type ) {
return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) );
}
$bookings = Guest::get_bookings( $guest_id );
$items = array();
foreach ( $bookings as $booking ) {
$room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true );
$room = $room_id ? get_post( $room_id ) : null;
$check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true );
$check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true );
$items[] = array(
'id' => $booking->ID,
'reference' => get_post_meta( $booking->ID, '_bnb_booking_reference', true ) ?: $booking->post_title,
'room' => $room ? array(
'id' => $room->ID,
'title' => get_the_title( $room ),
) : null,
'check_in' => $check_in,
'check_out' => $check_out,
'status' => get_post_meta( $booking->ID, '_bnb_booking_status', true ),
'total' => (float) get_post_meta( $booking->ID, '_bnb_booking_total_price', true ),
'created_at' => $booking->post_date_gmt,
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/bookings/' . $booking->ID ) ),
),
),
);
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare guest data for response.
*
* @param \WP_Post $post Guest post object.
* @param bool $full Include full details.
* @return array Guest data.
*/
private function prepare_guest_response( \WP_Post $post, bool $full = false ): array {
$data = array(
'id' => $post->ID,
'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ),
'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ),
'status' => get_post_meta( $post->ID, '_bnb_guest_status', true ) ?: 'active',
'created_at' => $post->post_date_gmt,
);
// Address.
$data['address'] = array(
'street' => get_post_meta( $post->ID, '_bnb_guest_street', true ),
'city' => get_post_meta( $post->ID, '_bnb_guest_city', true ),
'postal_code' => get_post_meta( $post->ID, '_bnb_guest_postal_code', true ),
'country' => get_post_meta( $post->ID, '_bnb_guest_country', true ),
);
// Statistics.
$booking_count = Guest::get_booking_count( $post->ID );
$total_spent = Guest::get_total_spent( $post->ID );
$data['statistics'] = array(
'total_bookings' => $booking_count,
'total_spent' => $total_spent,
);
if ( $full ) {
$data['nationality'] = get_post_meta( $post->ID, '_bnb_guest_nationality', true );
$data['date_of_birth'] = get_post_meta( $post->ID, '_bnb_guest_date_of_birth', true );
$data['notes'] = get_post_meta( $post->ID, '_bnb_guest_notes', true );
$data['preferences'] = get_post_meta( $post->ID, '_bnb_guest_preferences', true );
// Get last stay date.
$bookings = Guest::get_bookings( $post->ID );
if ( ! empty( $bookings ) ) {
$last_booking = $bookings[0];
$data['statistics']['last_stay'] = get_post_meta( $last_booking->ID, '_bnb_booking_check_out', true );
}
// Formatted address.
$data['address']['formatted'] = Guest::get_formatted_address( $post->ID );
// GDPR consent info.
$data['consent'] = array(
'data_processing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_data', true ),
'marketing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_marketing', true ),
'date' => get_post_meta( $post->ID, '_bnb_guest_consent_date', true ),
);
}
// Note: ID/passport numbers are NOT exposed via API for security.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ),
),
'bookings' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID . '/bookings' ) ),
),
);
return $data;
}
/**
* Prepare guest summary for search results.
*
* @param \WP_Post $post Guest post object.
* @return array Guest summary.
*/
private function prepare_guest_summary( \WP_Post $post ): array {
return array(
'id' => $post->ID,
'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ),
'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ),
'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ),
'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ),
'_links' => array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ),
),
),
);
}
/**
* Get collection parameters with status filter.
*
* @return array Collection parameters.
*/
public function get_collection_params(): array {
$params = parent::get_collection_params();
$params['status'] = array(
'description' => __( 'Filter by guest status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'active', 'inactive', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
);
return $params;
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* Pricing REST Controller
*
* Handles REST API endpoints for price calculations.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\Season;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Pricing Controller class.
*/
final class PricingController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'pricing';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// POST /pricing/calculate - Calculate full booking price.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/calculate',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'calculate_price' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'room_id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'services' => array(
'description' => __( 'Additional services.', 'wp-bnb' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'service_id' => array( 'type' => 'integer', 'required' => true ),
'quantity' => array( 'type' => 'integer', 'default' => 1 ),
),
),
),
),
),
)
);
// GET /pricing/seasons - Get active seasons.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/seasons',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_seasons' ),
'permission_callback' => array( $this, 'public_permission' ),
),
)
);
}
/**
* Calculate full booking price.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function calculate_price( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'room_id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
$services = $request->get_param( 'services' ) ?? array();
// Validate room.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Calculate nights.
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
// Calculate room price.
$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.
$night_breakdown = array();
$current_date = $check_in_date;
$base_rate = $breakdown['base_price_per_night'] ?? 0;
while ( $current_date < $check_out_date ) {
$date_str = $current_date->format( 'Y-m-d' );
$day_of_week = (int) $current_date->format( 'w' );
$modifiers = array();
$rate = $base_rate;
// Check for weekend surcharge.
$weekend_days = explode( ',', get_option( 'wp_bnb_weekend_days', '5,6' ) );
if ( in_array( (string) $day_of_week, $weekend_days, true ) ) {
$weekend_surcharge = $breakdown['weekend_surcharge'] ?? 0;
if ( $weekend_surcharge > 0 ) {
$rate += $weekend_surcharge / max( 1, $breakdown['weekend_nights'] ?? 1 );
$modifiers[] = 'weekend_surcharge';
}
}
// Check for seasonal modifier.
$season = Season::get_active_season( $date_str );
if ( $season && $season['modifier'] != 1.0 ) {
$modifiers[] = 'season:' . $season['name'];
}
$night_breakdown[] = array(
'date' => $date_str,
'rate' => round( $rate, 2 ),
'modifiers' => $modifiers,
);
$current_date = $current_date->modify( '+1 day' );
}
// Calculate services.
$services_items = array();
$services_total = 0;
foreach ( $services as $service_item ) {
$service_id = $service_item['service_id'] ?? 0;
$quantity = $service_item['quantity'] ?? 1;
$service_data = Service::get_service_data( $service_id );
if ( $service_data && 'active' === $service_data['status'] ) {
$service_price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_total += $service_price;
$services_items[] = array(
'id' => $service_id,
'name' => $service_data['title'],
'quantity' => $quantity,
'nights' => 'per_night' === $service_data['pricing_type'] ? $nights : null,
'subtotal' => $service_price,
);
}
}
$grand_total = $room_total + $services_total;
// Build response.
$data = array(
'room' => array(
'id' => $room->ID,
'title' => get_the_title( $room ),
'pricing_tier' => $breakdown['tier']->value ?? 'short_term',
),
'dates' => array(
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
),
'room_pricing' => array(
'base_rate' => $breakdown['base_price_per_night'] ?? 0,
'subtotal' => $room_total,
'breakdown' => $night_breakdown,
),
'services_pricing' => array(
'items' => $services_items,
'subtotal' => $services_total,
),
'totals' => array(
'room' => $room_total,
'services' => $services_total,
'grand_total' => $grand_total,
'currency' => $currency,
'formatted' => Calculator::formatPrice( $grand_total ),
),
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get active seasons.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_seasons( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$seasons = Season::get_all();
$items = array();
foreach ( $seasons as $season ) {
$items[] = array(
'id' => $season['id'],
'name' => $season['name'],
'start_date' => $season['start_date'],
'end_date' => $season['end_date'],
'modifier' => (float) $season['modifier'],
'priority' => (int) $season['priority'],
);
}
// Sort by priority (highest first).
usort(
$items,
function ( $a, $b ) {
return $b['priority'] - $a['priority'];
}
);
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
}

View File

@@ -0,0 +1,770 @@
<?php
/**
* Rooms REST Controller
*
* Handles REST API endpoints for rooms, availability, and calendar.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\RoomType;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Frontend\Search;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Rooms Controller class.
*/
final class RoomsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'rooms';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /rooms - List all rooms.
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_rooms_collection_params(),
),
)
);
// GET /rooms/{id} - Get single room.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// GET /rooms/{id}/availability - Check room availability.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/availability',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_availability' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);
// GET /rooms/{id}/calendar - Get room calendar.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/calendar',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_calendar' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Room ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'year' => array(
'description' => __( 'Year.', 'wp-bnb' ),
'type' => 'integer',
'default' => (int) gmdate( 'Y' ),
'sanitize_callback' => 'absint',
),
'month' => array(
'description' => __( 'Month (1-12).', 'wp-bnb' ),
'type' => 'integer',
'default' => (int) gmdate( 'n' ),
'minimum' => 1,
'maximum' => 12,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /availability/search - Search available rooms.
register_rest_route(
$this->namespace,
'/availability/search',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'search_availability' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'check_in' => array(
'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'check_out' => array(
'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'guests' => array(
'description' => __( 'Number of guests.', 'wp-bnb' ),
'type' => 'integer',
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'building_id' => array(
'description' => __( 'Building ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
),
'room_type' => array(
'description' => __( 'Room type term ID or slug.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'amenities' => array(
'description' => __( 'Comma-separated amenity slugs.', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'price_min' => array(
'description' => __( 'Minimum price per night.', 'wp-bnb' ),
'type' => 'number',
),
'price_max' => array(
'description' => __( 'Maximum price per night.', 'wp-bnb' ),
'type' => 'number',
),
),
),
)
);
}
/**
* Get collection of rooms.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$pagination = $this->get_pagination_params( $request );
$sorting = $this->get_sorting_params( $request, array( 'title', 'date', 'capacity', 'price' ), 'title' );
$args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $pagination['per_page'],
'offset' => $pagination['offset'],
);
// Handle special orderby values.
switch ( $sorting['orderby'] ) {
case 'capacity':
$args['meta_key'] = '_bnb_room_capacity';
$args['orderby'] = 'meta_value_num';
break;
case 'price':
$args['meta_key'] = '_bnb_room_price_short_term';
$args['orderby'] = 'meta_value_num';
break;
default:
$args['orderby'] = $sorting['orderby'];
}
$args['order'] = $sorting['order'];
// Search filter.
$search = $request->get_param( 'search' );
if ( $search ) {
$args['s'] = $search;
}
// Building filter.
$building = $request->get_param( 'building' );
if ( $building ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_building_id',
'value' => $building,
);
}
// Status filter.
$status = $request->get_param( 'status' );
if ( $status ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_status',
'value' => $status,
);
}
// Capacity filter.
$capacity_min = $request->get_param( 'capacity_min' );
if ( $capacity_min ) {
$args['meta_query'][] = array(
'key' => '_bnb_room_capacity',
'value' => $capacity_min,
'compare' => '>=',
'type' => 'NUMERIC',
);
}
// Room type filter.
$room_type = $request->get_param( 'room_type' );
if ( $room_type ) {
$args['tax_query'][] = array(
'taxonomy' => RoomType::TAXONOMY,
'field' => is_numeric( $room_type ) ? 'term_id' : 'slug',
'terms' => $room_type,
);
}
// Amenities filter.
$amenities = $request->get_param( 'amenities' );
if ( $amenities ) {
$amenity_slugs = array_map( 'trim', explode( ',', $amenities ) );
$args['tax_query'][] = array(
'taxonomy' => Amenity::TAXONOMY,
'field' => 'slug',
'terms' => $amenity_slugs,
'operator' => 'AND',
);
}
if ( isset( $args['meta_query'] ) && count( $args['meta_query'] ) > 1 ) {
$args['meta_query']['relation'] = 'AND';
}
if ( isset( $args['tax_query'] ) && count( $args['tax_query'] ) > 1 ) {
$args['tax_query']['relation'] = 'AND';
}
$query = new \WP_Query( $args );
$items = array();
foreach ( $query->posts as $post ) {
$items[] = $this->prepare_room_response( $post );
}
$response = $this->formatter->collection(
$items,
$query->found_posts,
$pagination['page'],
$pagination['per_page']
);
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single room.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Room::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
$data = $this->prepare_room_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get room availability.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_availability( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'id' );
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
// Validate room exists.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Check availability.
$is_available = Availability::is_available( $room_id, $check_in, $check_out );
// Calculate nights.
$check_in_date = new \DateTimeImmutable( $check_in );
$check_out_date = new \DateTimeImmutable( $check_out );
$nights = (int) $check_in_date->diff( $check_out_date )->days;
$data = array(
'available' => $is_available,
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $nights,
);
if ( $is_available ) {
// Calculate pricing.
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
$data['pricing'] = array(
'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' => $breakdown,
);
} else {
// Get conflicts.
$conflicts = $this->get_conflicts( $room_id, $check_in, $check_out );
$data['conflicts'] = $conflicts;
}
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get room calendar.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_calendar( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$room_id = $request->get_param( 'id' );
$year = $request->get_param( 'year' );
$month = $request->get_param( 'month' );
// Validate room exists.
$room = get_post( $room_id );
if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) {
return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) );
}
// Validate month.
if ( $month < 1 || $month > 12 ) {
return $this->formatter->validation_error( 'month', __( 'Month must be between 1 and 12.', 'wp-bnb' ) );
}
$data = Availability::get_calendar_data( $room_id, $year, $month );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Search available rooms.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function search_availability( $request ) {
// Check rate limit.
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$check_in = $request->get_param( 'check_in' );
$check_out = $request->get_param( 'check_out' );
// Validate dates.
if ( ! $this->validate_date( $check_in ) ) {
return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( ! $this->validate_date( $check_out ) ) {
return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) );
}
if ( $check_in >= $check_out ) {
return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) );
}
// Build search args.
$search_args = array(
'check_in' => $check_in,
'check_out' => $check_out,
);
$guests = $request->get_param( 'guests' );
if ( $guests ) {
$search_args['guests'] = $guests;
}
$building_id = $request->get_param( 'building_id' );
if ( $building_id ) {
$search_args['building_id'] = $building_id;
}
$room_type = $request->get_param( 'room_type' );
if ( $room_type ) {
$search_args['room_type'] = $room_type;
}
$amenities = $request->get_param( 'amenities' );
if ( $amenities ) {
$search_args['amenities'] = array_map( 'trim', explode( ',', $amenities ) );
}
$price_min = $request->get_param( 'price_min' );
if ( $price_min ) {
$search_args['price_min'] = $price_min;
}
$price_max = $request->get_param( 'price_max' );
if ( $price_max ) {
$search_args['price_max'] = $price_max;
}
// Use existing Search class.
$results = Search::search( $search_args );
// Format response.
$items = array();
foreach ( $results['rooms'] as $room_data ) {
$items[] = array(
'room' => $room_data,
'availability' => array(
'available' => true,
'nights' => $room_data['nights'] ?? 0,
'total_price' => $room_data['stay_price'] ?? 0,
'formatted_price' => $room_data['stay_price_formatted'] ?? '',
),
);
}
$data = array(
'results' => $items,
'total' => $results['count'],
'filters_applied' => $search_args,
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get conflicting bookings for a date range.
*
* @param int $room_id Room ID.
* @param string $check_in Check-in date.
* @param string $check_out Check-out date.
* @return array Conflicting bookings.
*/
private function get_conflicts( int $room_id, string $check_in, string $check_out ): array {
$bookings = get_posts(
array(
'post_type' => Booking::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_bnb_booking_room_id',
'value' => $room_id,
),
array(
'key' => '_bnb_booking_status',
'value' => 'cancelled',
'compare' => '!=',
),
array(
'key' => '_bnb_booking_check_in',
'value' => $check_out,
'compare' => '<',
'type' => 'DATE',
),
array(
'key' => '_bnb_booking_check_out',
'value' => $check_in,
'compare' => '>',
'type' => 'DATE',
),
),
)
);
$conflicts = array();
foreach ( $bookings as $booking ) {
$conflicts[] = array(
'booking_id' => $booking->ID,
'reference' => $booking->post_title,
'check_in' => get_post_meta( $booking->ID, '_bnb_booking_check_in', true ),
'check_out' => get_post_meta( $booking->ID, '_bnb_booking_check_out', true ),
);
}
return $conflicts;
}
/**
* Prepare room data for response.
*
* @param \WP_Post $post Room post object.
* @param bool $full Include full details.
* @return array Room data.
*/
private function prepare_room_response( \WP_Post $post, bool $full = false ): array {
$data = $this->format_post_base( $post );
$data['permalink'] = get_permalink( $post->ID );
$data['featured_image'] = $this->format_featured_image( $post->ID );
// Gallery.
$gallery_ids = get_post_meta( $post->ID, '_bnb_room_gallery', true );
$gallery = array();
if ( $gallery_ids ) {
foreach ( explode( ',', $gallery_ids ) as $image_id ) {
$image = $this->format_image( (int) $image_id );
if ( $image ) {
$gallery[] = $image;
}
}
}
$data['gallery'] = $gallery;
// Building reference.
$building_id = get_post_meta( $post->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
$data['building'] = $building ? array(
'id' => $building->ID,
'title' => get_the_title( $building ),
'slug' => $building->post_name,
'permalink' => get_permalink( $building->ID ),
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
) : null;
// Room details.
$data['room_number'] = get_post_meta( $post->ID, '_bnb_room_room_number', true );
$data['floor'] = (int) get_post_meta( $post->ID, '_bnb_room_floor', true );
$data['size_sqm'] = (float) get_post_meta( $post->ID, '_bnb_room_size', true );
// Capacity.
$data['capacity'] = array(
'max_guests' => (int) get_post_meta( $post->ID, '_bnb_room_capacity', true ),
'adults' => (int) get_post_meta( $post->ID, '_bnb_room_max_adults', true ),
'children' => (int) get_post_meta( $post->ID, '_bnb_room_max_children', true ),
);
$data['beds'] = get_post_meta( $post->ID, '_bnb_room_beds', true );
$data['bathrooms'] = (float) get_post_meta( $post->ID, '_bnb_room_bathrooms', true );
$data['status'] = get_post_meta( $post->ID, '_bnb_room_status', true ) ?: 'available';
// Room types.
$room_types = wp_get_post_terms( $post->ID, RoomType::TAXONOMY );
$data['room_types'] = array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
);
},
is_array( $room_types ) ? $room_types : array()
);
// Amenities.
$amenities = wp_get_post_terms( $post->ID, Amenity::TAXONOMY );
$data['amenities'] = array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'icon' => get_term_meta( $term->term_id, '_bnb_amenity_icon', true ),
);
},
is_array( $amenities ) ? $amenities : array()
);
// Pricing.
$pricing = Calculator::getRoomPricing( $post->ID );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
$data['pricing'] = array(
'currency' => $currency,
);
foreach ( PricingTier::cases() as $tier ) {
$price = $pricing[ $tier->value ]['price'] ?? null;
$data['pricing'][ $tier->value ] = array(
'price' => $price,
'formatted' => $price ? Calculator::formatPrice( $price ) : null,
'unit' => $tier->unit(),
);
}
$weekend_surcharge = $pricing['weekend_surcharge']['price'] ?? null;
$data['pricing']['weekend_surcharge'] = array(
'price' => $weekend_surcharge,
'formatted' => $weekend_surcharge ? Calculator::formatPrice( $weekend_surcharge ) : null,
);
// Add HATEOAS links.
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID ) ),
),
'building' => $building ? array(
array( 'href' => rest_url( $this->namespace . '/buildings/' . $building->ID ) ),
) : array(),
'availability' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/availability' ) ),
),
'calendar' => array(
array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/calendar' ) ),
),
);
return $data;
}
/**
* Get rooms collection parameters.
*
* @return array Collection parameters.
*/
private function get_rooms_collection_params(): array {
$params = $this->get_collection_params();
$params['building'] = array(
'description' => __( 'Filter by building ID.', 'wp-bnb' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
);
$params['room_type'] = array(
'description' => __( 'Filter by room type (term ID or slug).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['amenities'] = array(
'description' => __( 'Filter by amenities (comma-separated slugs).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
);
$params['capacity_min'] = array(
'description' => __( 'Minimum guest capacity.', 'wp-bnb' ),
'type' => 'integer',
'minimum' => 1,
'sanitize_callback' => 'absint',
);
$params['status'] = array(
'description' => __( 'Filter by room status.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ),
'sanitize_callback' => 'sanitize_text_field',
);
$params['orderby']['enum'] = array( 'title', 'date', 'capacity', 'price' );
return $params;
}
}

View File

@@ -0,0 +1,375 @@
<?php
/**
* Services REST Controller
*
* Handles REST API endpoints for services.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Taxonomies\ServiceCategory;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Services Controller class.
*/
final class ServicesController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'services';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /services - List active services (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => $this->get_services_collection_params(),
),
)
);
// GET /services/{id} - Get single service (public).
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Service ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
),
),
)
);
// POST /services/{id}/calculate - Calculate service price.
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/calculate',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'calculate_price' ),
'permission_callback' => array( $this, 'public_permission' ),
'args' => array(
'id' => array(
'description' => __( 'Service ID.', 'wp-bnb' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
'quantity' => array(
'description' => __( 'Quantity.', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'nights' => array(
'description' => __( 'Number of nights (for per-night services).', 'wp-bnb' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
),
),
)
);
}
/**
* Get collection of services.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_items( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$args = array(
'post_type' => Service::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 100, // Services typically don't need pagination.
'orderby' => 'meta_value_num',
'meta_key' => '_bnb_service_sort_order',
'order' => 'ASC',
);
$meta_query = array();
// Status filter (default: active only).
$status = $request->get_param( 'status' ) ?: 'active';
if ( 'all' !== $status ) {
$meta_query[] = array(
'key' => '_bnb_service_status',
'value' => $status,
);
}
// Pricing type filter.
$pricing_type = $request->get_param( 'pricing_type' );
if ( $pricing_type ) {
$meta_query[] = array(
'key' => '_bnb_service_pricing_type',
'value' => $pricing_type,
);
}
if ( ! empty( $meta_query ) ) {
$meta_query['relation'] = 'AND';
$args['meta_query'] = $meta_query;
}
// Category filter.
$category = $request->get_param( 'category' );
if ( $category ) {
$args['tax_query'] = array(
array(
'taxonomy' => 'bnb_service_category',
'field' => is_numeric( $category ) ? 'term_id' : 'slug',
'terms' => $category,
),
);
}
$services = get_posts( $args );
$items = array();
foreach ( $services as $service ) {
$items[] = $this->prepare_service_response( $service );
}
$response = $this->formatter->success( $items );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Get single service.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function get_item( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$id = $request->get_param( 'id' );
$post = get_post( $id );
if ( ! $post || Service::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) {
return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) );
}
$data = $this->prepare_service_response( $post, true );
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Calculate service price.
*
* @param WP_REST_Request $request Current request.
* @return WP_REST_Response|WP_Error Response object or error.
*/
public function calculate_price( $request ) {
$rate_limit_error = $this->check_rate_limit( $request );
if ( $rate_limit_error ) {
return $rate_limit_error;
}
$service_id = $request->get_param( 'id' );
$quantity = $request->get_param( 'quantity' );
$nights = $request->get_param( 'nights' );
// Validate service.
$service = get_post( $service_id );
if ( ! $service || Service::POST_TYPE !== $service->post_type || 'publish' !== $service->post_status ) {
return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) );
}
// Check if service is active.
$status = get_post_meta( $service_id, '_bnb_service_status', true );
if ( 'active' !== $status ) {
return $this->formatter->validation_error( 'id', __( 'Service is not available.', 'wp-bnb' ) );
}
// Check max quantity.
$max_quantity = (int) get_post_meta( $service_id, '_bnb_service_max_quantity', true ) ?: 1;
if ( $quantity > $max_quantity ) {
return $this->formatter->validation_error(
'quantity',
sprintf(
/* translators: %d: maximum quantity */
__( 'Maximum quantity is %d.', 'wp-bnb' ),
$max_quantity
)
);
}
// Calculate price.
$total = Service::calculate_service_price( $service_id, $quantity, $nights );
$pricing_type = get_post_meta( $service_id, '_bnb_service_pricing_type', true );
$unit_price = (float) get_post_meta( $service_id, '_bnb_service_price', true );
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Build calculation string.
$calculation = '';
switch ( $pricing_type ) {
case 'included':
$calculation = __( 'Included', 'wp-bnb' );
break;
case 'per_booking':
$calculation = sprintf(
'%s x %d',
Calculator::formatPrice( $unit_price ),
$quantity
);
break;
case 'per_night':
$calculation = sprintf(
'%s x %d x %d %s',
Calculator::formatPrice( $unit_price ),
$quantity,
$nights,
_n( 'night', 'nights', $nights, 'wp-bnb' )
);
break;
}
$data = array(
'service_id' => $service_id,
'quantity' => $quantity,
'nights' => $nights,
'unit_price' => $unit_price,
'total' => $total,
'formatted' => Calculator::formatPrice( $total ),
'currency' => $currency,
'calculation' => $calculation,
);
$response = $this->formatter->success( $data );
return $this->add_rate_limit_headers( $response, $request );
}
/**
* Prepare service data for response.
*
* @param \WP_Post $post Service post object.
* @param bool $full Include full details.
* @return array Service data.
*/
private function prepare_service_response( \WP_Post $post, bool $full = false ): array {
$pricing_type = get_post_meta( $post->ID, '_bnb_service_pricing_type', true );
$price = (float) get_post_meta( $post->ID, '_bnb_service_price', true );
$status = get_post_meta( $post->ID, '_bnb_service_status', true ) ?: 'active';
$max_quantity = (int) get_post_meta( $post->ID, '_bnb_service_max_quantity', true ) ?: 1;
$currency = get_option( 'wp_bnb_currency', 'CHF' );
$data = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'description' => get_the_excerpt( $post ),
'pricing' => array(
'type' => $pricing_type,
'price' => $price,
'formatted' => Service::format_service_price( Service::get_service_data( $post->ID ) ),
'currency' => $currency,
),
'max_quantity' => $max_quantity,
'status' => $status,
);
// Category.
$categories = wp_get_post_terms( $post->ID, 'bnb_service_category' );
if ( ! empty( $categories ) && ! is_wp_error( $categories ) ) {
$category = $categories[0];
$data['category'] = array(
'id' => $category->term_id,
'name' => $category->name,
'slug' => $category->slug,
'icon' => get_term_meta( $category->term_id, '_bnb_service_category_icon', true ),
);
}
if ( $full ) {
$data['content'] = apply_filters( 'the_content', $post->post_content );
$data['sort_order'] = (int) get_post_meta( $post->ID, '_bnb_service_sort_order', true );
}
$data['_links'] = array(
'self' => array(
array( 'href' => rest_url( $this->namespace . '/services/' . $post->ID ) ),
),
);
return $data;
}
/**
* Get services collection parameters.
*
* @return array Collection parameters.
*/
private function get_services_collection_params(): array {
return array(
'status' => array(
'description' => __( 'Filter by status (default: active).', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'active', 'inactive', 'all' ),
'default' => 'active',
'sanitize_callback' => 'sanitize_text_field',
),
'pricing_type' => array(
'description' => __( 'Filter by pricing type.', 'wp-bnb' ),
'type' => 'string',
'enum' => array( 'included', 'per_booking', 'per_night' ),
'sanitize_callback' => 'sanitize_text_field',
),
'category' => array(
'description' => __( 'Filter by category (term ID or slug).', 'wp-bnb' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
);
}
}

232
src/Api/RateLimiter.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
/**
* REST API Rate Limiter
*
* Provides transient-based rate limiting for API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
/**
* Rate Limiter class.
*/
final class RateLimiter {
/**
* Transient prefix for rate limit data.
*/
private const TRANSIENT_PREFIX = 'wp_bnb_rate_';
/**
* Default rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
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;
/**
* 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.
*
* @param string $identifier Client identifier (user ID or IP).
* @param string $endpoint Request endpoint.
* @return bool True if within limit, false if exceeded.
*/
public function check( string $identifier, string $endpoint ): bool {
$type = $this->get_endpoint_type( $endpoint );
$limit = $this->limits[ $type ] ?? $this->limits['public'];
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
// First request in window.
set_transient(
$key,
array(
'count' => 1,
'start' => time(),
),
$this->window
);
return true;
}
// Check if window expired.
if ( time() - $data['start'] >= $this->window ) {
set_transient(
$key,
array(
'count' => 1,
'start' => time(),
),
$this->window
);
return true;
}
// Check if limit exceeded.
if ( $data['count'] >= $limit ) {
return false;
}
// Increment counter.
++$data['count'];
$remaining_window = $this->window - ( time() - $data['start'] );
set_transient( $key, $data, $remaining_window );
return true;
}
/**
* Get seconds until rate limit resets.
*
* @param string $identifier Client identifier.
* @param string $endpoint Request endpoint.
* @return int Seconds until reset.
*/
public function get_retry_after( string $identifier, string $endpoint ): int {
$type = $this->get_endpoint_type( $endpoint );
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
return 0;
}
return max( 0, $this->window - ( time() - $data['start'] ) );
}
/**
* Get current rate limit info for headers.
*
* @param string $identifier Client identifier.
* @param string $endpoint Request endpoint.
* @return array{limit: int, remaining: int, reset: int}
*/
public function get_rate_limit_info( string $identifier, string $endpoint ): array {
$type = $this->get_endpoint_type( $endpoint );
$limit = $this->limits[ $type ] ?? $this->limits['public'];
$key = $this->get_transient_key( $identifier, $type );
$data = get_transient( $key );
if ( false === $data ) {
return array(
'limit' => $limit,
'remaining' => $limit,
'reset' => time() + $this->window,
);
}
$remaining = max( 0, $limit - $data['count'] );
$reset = $data['start'] + $this->window;
return array(
'limit' => $limit,
'remaining' => $remaining,
'reset' => $reset,
);
}
/**
* Determine endpoint type from route.
*
* @param string $endpoint Request endpoint.
* @return string Endpoint type.
*/
private function get_endpoint_type( string $endpoint ): string {
if ( str_contains( $endpoint, '/availability' ) || str_contains( $endpoint, '/calendar' ) ) {
return 'availability';
}
if ( str_contains( $endpoint, '/bookings' ) ) {
return 'booking';
}
if ( str_contains( $endpoint, '/guests' ) ) {
return 'admin';
}
return 'public';
}
/**
* Get transient key for rate limit data.
*
* @param string $identifier Client identifier.
* @param string $type Endpoint type.
* @return string Transient key.
*/
private function get_transient_key( string $identifier, string $type ): string {
return self::TRANSIENT_PREFIX . md5( $identifier . '_' . $type );
}
/**
* Set custom rate limits.
*
* @param array<string, int> $limits Rate limits by type.
* @return void
*/
public function set_limits( array $limits ): void {
$this->limits = array_merge( $this->limits, $limits );
}
/**
* Set custom time window.
*
* @param int $window Window in seconds.
* @return void
*/
public function set_window( int $window ): void {
$this->window = $window;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* REST API Response Formatter
*
* Provides standardized response formatting for all API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use WP_REST_Response;
use WP_Error;
/**
* Response Formatter class.
*/
final class ResponseFormatter {
/**
* Format successful response.
*
* @param mixed $data Response data.
* @param int $status HTTP status code.
* @return WP_REST_Response
*/
public function success( mixed $data, int $status = 200 ): WP_REST_Response {
return new WP_REST_Response( $data, $status );
}
/**
* Format collection response with pagination headers.
*
* @param array $items Collection items.
* @param int $total Total number of items.
* @param int $page Current page number.
* @param int $per_page Items per page.
* @return WP_REST_Response
*/
public function collection( array $items, int $total, int $page, int $per_page ): WP_REST_Response {
$response = new WP_REST_Response( $items, 200 );
$max_pages = (int) ceil( $total / $per_page );
$response->header( 'X-WP-Total', (string) $total );
$response->header( 'X-WP-TotalPages', (string) $max_pages );
return $response;
}
/**
* Format created response (201).
*
* @param mixed $data Response data.
* @param string $location Location header URL.
* @return WP_REST_Response
*/
public function created( mixed $data, string $location = '' ): WP_REST_Response {
$response = new WP_REST_Response( $data, 201 );
if ( $location ) {
$response->header( 'Location', $location );
}
return $response;
}
/**
* Format no content response (204).
*
* @return WP_REST_Response
*/
public function no_content(): WP_REST_Response {
return new WP_REST_Response( null, 204 );
}
/**
* Create validation error.
*
* @param string $param Parameter name.
* @param string $message Error message.
* @return WP_Error
*/
public function validation_error( string $param, string $message ): WP_Error {
return new WP_Error(
'rest_invalid_param',
$message,
array(
'status' => 400,
'param' => $param,
)
);
}
/**
* Create not found error.
*
* @param string $resource Resource name.
* @return WP_Error
*/
public function not_found( string $resource = 'Resource' ): WP_Error {
return new WP_Error(
'rest_not_found',
/* translators: %s: Resource name */
sprintf( __( '%s not found.', 'wp-bnb' ), $resource ),
array( 'status' => 404 )
);
}
/**
* Create forbidden error.
*
* @param string $message Error message.
* @return WP_Error
*/
public function forbidden( string $message = '' ): WP_Error {
return new WP_Error(
'rest_forbidden',
$message ?: __( 'You do not have permission to access this resource.', 'wp-bnb' ),
array( 'status' => 403 )
);
}
/**
* Create rate limit error.
*
* @param int $retry_after Seconds until rate limit resets.
* @return WP_Error
*/
public function rate_limit_error( int $retry_after = 60 ): WP_Error {
return new WP_Error(
'rest_rate_limit_exceeded',
__( 'Rate limit exceeded. Please try again later.', 'wp-bnb' ),
array(
'status' => 429,
'retry_after' => $retry_after,
)
);
}
/**
* Create conflict error (e.g., booking conflict).
*
* @param string $message Error message.
* @param array $conflicts Conflicting resources.
* @return WP_Error
*/
public function conflict( string $message, array $conflicts = array() ): WP_Error {
return new WP_Error(
'rest_conflict',
$message,
array(
'status' => 409,
'conflicts' => $conflicts,
)
);
}
/**
* Create internal server error.
*
* @param string $message Error message.
* @return WP_Error
*/
public function server_error( string $message = '' ): WP_Error {
return new WP_Error(
'rest_server_error',
$message ?: __( 'An internal server error occurred.', 'wp-bnb' ),
array( 'status' => 500 )
);
}
}

113
src/Api/RestApi.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/**
* REST API Main Class
*
* Registers all REST API controllers and routes.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use Magdev\WpBnb\Api\Controllers\BuildingsController;
use Magdev\WpBnb\Api\Controllers\RoomsController;
use Magdev\WpBnb\Api\Controllers\BookingsController;
use Magdev\WpBnb\Api\Controllers\GuestsController;
use Magdev\WpBnb\Api\Controllers\ServicesController;
use Magdev\WpBnb\Api\Controllers\PricingController;
/**
* REST API class.
*/
final class RestApi {
/**
* API namespace.
*/
public const NAMESPACE = 'wp-bnb/v1';
/**
* API version.
*/
public const VERSION = '1.0.0';
/**
* Controller instances.
*
* @var array
*/
private array $controllers = array();
/**
* Initialize the REST API.
*
* @return void
*/
public function init(): void {
// Check if API is enabled.
if ( 'yes' !== get_option( 'wp_bnb_api_enabled', 'yes' ) ) {
return;
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register all API routes.
*
* @return void
*/
public function register_routes(): void {
$this->controllers = array(
new BuildingsController(),
new RoomsController(),
new BookingsController(),
new GuestsController(),
new ServicesController(),
new PricingController(),
);
foreach ( $this->controllers as $controller ) {
$controller->register_routes();
}
// Register API info endpoint.
register_rest_route(
self::NAMESPACE,
'/info',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_api_info' ),
'permission_callback' => '__return_true',
)
);
}
/**
* Get API information.
*
* @return \WP_REST_Response API info response.
*/
public function get_api_info(): \WP_REST_Response {
return new \WP_REST_Response(
array(
'name' => 'WP BnB REST API',
'version' => self::VERSION,
'namespace' => self::NAMESPACE,
'description' => __( 'REST API for WP BnB booking management.', 'wp-bnb' ),
'endpoints' => array(
'buildings' => rest_url( self::NAMESPACE . '/buildings' ),
'rooms' => rest_url( self::NAMESPACE . '/rooms' ),
'bookings' => rest_url( self::NAMESPACE . '/bookings' ),
'guests' => rest_url( self::NAMESPACE . '/guests' ),
'services' => rest_url( self::NAMESPACE . '/services' ),
'pricing' => rest_url( self::NAMESPACE . '/pricing' ),
'availability' => rest_url( self::NAMESPACE . '/availability' ),
),
),
200
);
}
}

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. * @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 { 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. // Try to get data from Guest CPT.
if ( $guest_id ) { if ( $guest_id > 0 ) {
$guest = get_post( $guest_id ); $guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->post_type ) { if ( $guest && Guest::POST_TYPE === $guest->post_type ) {
$first_name = get_post_meta( $guest_id, '_bnb_guest_first_name', true ); $first_name = get_post_meta( $guest_id, '_bnb_guest_first_name', true );

View File

@@ -0,0 +1,877 @@
<?php
/**
* Prometheus Metrics Integration.
*
* Provides meaningful metrics for monitoring BnB operations.
*
* @package Magdev\WpBnb\Integration
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Integration;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
/**
* Prometheus Metrics Integration class.
*
* Exposes BnB metrics via the wp-prometheus plugin.
*/
class Prometheus {
/**
* Option key for enabling metrics.
*/
public const OPTION_ENABLED = 'wp_bnb_metrics_enabled';
/**
* Initialize the Prometheus integration.
*
* @return void
*/
public static function init(): void {
// Only hook if metrics are enabled.
if ( ! self::is_enabled() ) {
return;
}
// Hook into wp-prometheus collector.
add_action( 'wp_prometheus_collect_metrics', array( self::class, 'collect_metrics' ) );
// Register Grafana dashboard.
add_action( 'wp_prometheus_register_dashboards', array( self::class, 'register_dashboards' ) );
}
/**
* Check if metrics collection is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return 'yes' === get_option( self::OPTION_ENABLED, 'yes' );
}
/**
* Enable metrics collection.
*
* @return void
*/
public static function enable(): void {
update_option( self::OPTION_ENABLED, 'yes' );
}
/**
* Disable metrics collection.
*
* @return void
*/
public static function disable(): void {
update_option( self::OPTION_ENABLED, 'no' );
}
/**
* Collect and register all BnB metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
public static function collect_metrics( $collector ): void {
self::collect_inventory_metrics( $collector );
self::collect_booking_metrics( $collector );
self::collect_guest_metrics( $collector );
self::collect_occupancy_metrics( $collector );
self::collect_revenue_metrics( $collector );
}
/**
* Collect inventory metrics (buildings, rooms, services).
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_inventory_metrics( $collector ): void {
// Buildings total.
$buildings_total = wp_count_posts( Building::POST_TYPE );
$gauge = $collector->register_gauge(
'wp_bnb_buildings_total',
'Total number of buildings',
array()
);
$gauge->set( (int) $buildings_total->publish, array() );
// Rooms by status.
$rooms_gauge = $collector->register_gauge(
'wp_bnb_rooms_total',
'Total number of rooms by status',
array( 'status' )
);
$room_statuses = array( 'available', 'occupied', 'maintenance', 'inactive' );
foreach ( $room_statuses as $status ) {
$count = self::count_rooms_by_status( $status );
$rooms_gauge->set( $count, array( $status ) );
}
// Services by status.
$services_gauge = $collector->register_gauge(
'wp_bnb_services_total',
'Total number of services by status',
array( 'status' )
);
$service_statuses = array( 'active', 'inactive' );
foreach ( $service_statuses as $status ) {
$count = self::count_services_by_status( $status );
$services_gauge->set( $count, array( $status ) );
}
}
/**
* Collect booking metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_booking_metrics( $collector ): void {
// Bookings by status.
$bookings_gauge = $collector->register_gauge(
'wp_bnb_bookings_total',
'Total number of bookings by status',
array( 'status' )
);
$booking_statuses = array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' );
foreach ( $booking_statuses as $status ) {
$count = self::count_bookings_by_status( $status );
$bookings_gauge->set( $count, array( $status ) );
}
// Today's check-ins.
$checkins_gauge = $collector->register_gauge(
'wp_bnb_checkins_today',
'Number of check-ins scheduled for today',
array()
);
$checkins_gauge->set( self::count_todays_checkins(), array() );
// Today's check-outs.
$checkouts_gauge = $collector->register_gauge(
'wp_bnb_checkouts_today',
'Number of check-outs scheduled for today',
array()
);
$checkouts_gauge->set( self::count_todays_checkouts(), array() );
// Upcoming bookings (next 7 days).
$upcoming_gauge = $collector->register_gauge(
'wp_bnb_bookings_upcoming_7days',
'Number of bookings starting in the next 7 days',
array()
);
$upcoming_gauge->set( self::count_upcoming_bookings( 7 ), array() );
// Average booking duration (nights).
$avg_duration = $collector->register_gauge(
'wp_bnb_booking_avg_duration_nights',
'Average booking duration in nights',
array()
);
$avg_duration->set( self::get_average_booking_duration(), array() );
}
/**
* Collect guest metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_guest_metrics( $collector ): void {
// Total guests.
$guests_total = wp_count_posts( Guest::POST_TYPE );
$guests_gauge = $collector->register_gauge(
'wp_bnb_guests_total',
'Total number of registered guests',
array()
);
$guests_gauge->set( (int) $guests_total->publish, array() );
// Guests by status.
$guests_status_gauge = $collector->register_gauge(
'wp_bnb_guests_by_status',
'Number of guests by status',
array( 'status' )
);
$guest_statuses = array( 'active', 'blocked', 'vip' );
foreach ( $guest_statuses as $status ) {
$count = self::count_guests_by_status( $status );
$guests_status_gauge->set( $count, array( $status ) );
}
// Repeat guests (guests with more than one booking).
$repeat_gauge = $collector->register_gauge(
'wp_bnb_guests_repeat',
'Number of guests with more than one booking',
array()
);
$repeat_gauge->set( self::count_repeat_guests(), array() );
// New guests this month.
$new_guests_gauge = $collector->register_gauge(
'wp_bnb_guests_new_this_month',
'Number of new guests registered this month',
array()
);
$new_guests_gauge->set( self::count_new_guests_this_month(), array() );
}
/**
* Collect occupancy metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_occupancy_metrics( $collector ): void {
// Current occupancy rate (percentage).
$occupancy_gauge = $collector->register_gauge(
'wp_bnb_occupancy_rate_current',
'Current room occupancy rate (percentage)',
array()
);
$occupancy_gauge->set( self::get_current_occupancy_rate(), array() );
// Occupancy rate this month.
$occupancy_month_gauge = $collector->register_gauge(
'wp_bnb_occupancy_rate_this_month',
'Room occupancy rate for the current month (percentage)',
array()
);
$occupancy_month_gauge->set( self::get_monthly_occupancy_rate(), array() );
// Rooms currently occupied.
$occupied_gauge = $collector->register_gauge(
'wp_bnb_rooms_currently_occupied',
'Number of rooms currently occupied',
array()
);
$occupied_gauge->set( self::count_currently_occupied_rooms(), array() );
// Total room capacity (beds).
$capacity_gauge = $collector->register_gauge(
'wp_bnb_total_capacity_beds',
'Total bed capacity across all rooms',
array()
);
$capacity_gauge->set( self::get_total_bed_capacity(), array() );
}
/**
* Collect revenue metrics.
*
* @param object $collector The wp-prometheus collector instance.
* @return void
*/
private static function collect_revenue_metrics( $collector ): void {
$currency = get_option( 'wp_bnb_currency', 'CHF' );
// Revenue this month.
$revenue_month_gauge = $collector->register_gauge(
'wp_bnb_revenue_this_month',
'Total revenue for the current month',
array( 'currency' )
);
$revenue_month_gauge->set( self::get_revenue_this_month(), array( $currency ) );
// Revenue year to date.
$revenue_ytd_gauge = $collector->register_gauge(
'wp_bnb_revenue_ytd',
'Total revenue year to date',
array( 'currency' )
);
$revenue_ytd_gauge->set( self::get_revenue_ytd(), array( $currency ) );
// Average booking value.
$avg_value_gauge = $collector->register_gauge(
'wp_bnb_booking_avg_value',
'Average booking value',
array( 'currency' )
);
$avg_value_gauge->set( self::get_average_booking_value(), array( $currency ) );
// Revenue from services this month.
$services_revenue_gauge = $collector->register_gauge(
'wp_bnb_services_revenue_this_month',
'Revenue from additional services this month',
array( 'currency' )
);
$services_revenue_gauge->set( self::get_services_revenue_this_month(), array( $currency ) );
}
/**
* Register Grafana dashboards.
*
* @param object $provider The wp-prometheus dashboard provider instance.
* @return void
*/
public static function register_dashboards( $provider ): void {
$dashboard_file = WP_BNB_PATH . 'assets/grafana/wp-bnb-dashboard.json';
if ( file_exists( $dashboard_file ) ) {
$provider->register_dashboard(
'wp-bnb',
array(
'title' => __( 'WP BnB Dashboard', 'wp-bnb' ),
'description' => __( 'Monitor occupancy, bookings, revenue, and guest statistics for your B&B.', 'wp-bnb' ),
'icon' => 'dashicons-building',
'file' => $dashboard_file,
'plugin' => 'WP BnB Manager',
)
);
}
}
// =========================================================================
// Helper Methods - Inventory
// =========================================================================
/**
* Count rooms by status.
*
* @param string $status Room status.
* @return int
*/
private static function count_rooms_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_room_status'
AND pm.meta_value = %s",
Room::POST_TYPE,
$status
)
);
}
/**
* Count services by status.
*
* @param string $status Service status.
* @return int
*/
private static function count_services_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_service_status'
AND pm.meta_value = %s",
Service::POST_TYPE,
$status
)
);
}
// =========================================================================
// Helper Methods - Bookings
// =========================================================================
/**
* Count bookings by status.
*
* @param string $status Booking status.
* @return int
*/
private static function count_bookings_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_booking_status'
AND pm.meta_value = %s",
Booking::POST_TYPE,
$status
)
);
}
/**
* Count today's check-ins.
*
* @return int
*/
private static function count_todays_checkins(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_in'
AND pm_date.meta_value = %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'pending')",
Booking::POST_TYPE,
$today
)
);
}
/**
* Count today's check-outs.
*
* @return int
*/
private static function count_todays_checkouts(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_out'
AND pm_date.meta_value = %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value = 'checked_in'",
Booking::POST_TYPE,
$today
)
);
}
/**
* Count upcoming bookings within given days.
*
* @param int $days Number of days to look ahead.
* @return int
*/
private static function count_upcoming_bookings( int $days ): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
$end_date = gmdate( 'Y-m-d', strtotime( "+{$days} days" ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_date ON p.ID = pm_date.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_date.meta_key = '_bnb_booking_check_in'
AND pm_date.meta_value >= %s
AND pm_date.meta_value <= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'pending')",
Booking::POST_TYPE,
$today,
$end_date
)
);
}
/**
* Get average booking duration in nights.
*
* @return float
*/
private static function get_average_booking_duration(): float {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT AVG(DATEDIFF(pm_out.meta_value, pm_in.meta_value))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value NOT IN ('cancelled')",
Booking::POST_TYPE
)
);
return round( (float) $result, 1 );
}
// =========================================================================
// Helper Methods - Guests
// =========================================================================
/**
* Count guests by status.
*
* @param string $status Guest status.
* @return int
*/
private static function count_guests_by_status( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_guest_status'
AND pm.meta_value = %s",
Guest::POST_TYPE,
$status
)
);
}
/**
* Count repeat guests (more than one booking).
*
* @return int
*/
private static function count_repeat_guests(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.meta_value)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_booking_guest_id'
AND pm.meta_value != ''
GROUP BY pm.meta_value
HAVING COUNT(*) > 1",
Booking::POST_TYPE
)
);
}
/**
* Count new guests registered this month.
*
* @return int
*/
private static function count_new_guests_this_month(): int {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = 'publish'
AND post_date >= %s",
Guest::POST_TYPE,
$first_of_month
)
);
}
// =========================================================================
// Helper Methods - Occupancy
// =========================================================================
/**
* Get current occupancy rate (percentage of rooms occupied today).
*
* @return float
*/
private static function get_current_occupancy_rate(): float {
$total_rooms = self::count_available_rooms();
if ( $total_rooms <= 0 ) {
return 0.0;
}
$occupied = self::count_currently_occupied_rooms();
return round( ( $occupied / $total_rooms ) * 100, 1 );
}
/**
* Get monthly occupancy rate.
*
* @return float
*/
private static function get_monthly_occupancy_rate(): float {
global $wpdb;
$total_rooms = self::count_available_rooms();
if ( $total_rooms <= 0 ) {
return 0.0;
}
$first_of_month = gmdate( 'Y-m-01' );
$today = gmdate( 'Y-m-d' );
$days_so_far = (int) gmdate( 'd' );
$total_room_nights = $total_rooms * $days_so_far;
// Count booked nights this month (simplified: count bookings that overlap with this month).
$booked_nights = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(
DATEDIFF(
LEAST(pm_out.meta_value, %s),
GREATEST(pm_in.meta_value, %s)
)
)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')
AND pm_in.meta_value <= %s
AND pm_out.meta_value >= %s",
$today,
$first_of_month,
Booking::POST_TYPE,
$today,
$first_of_month
)
);
if ( $total_room_nights <= 0 ) {
return 0.0;
}
return round( ( $booked_nights / $total_room_nights ) * 100, 1 );
}
/**
* Count currently occupied rooms.
*
* @return int
*/
private static function count_currently_occupied_rooms(): int {
global $wpdb;
$today = gmdate( 'Y-m-d' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm_room.meta_value)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_room ON p.ID = pm_room.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_out ON p.ID = pm_out.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_room.meta_key = '_bnb_booking_room_id'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_out.meta_key = '_bnb_booking_check_out'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in')
AND pm_in.meta_value <= %s
AND pm_out.meta_value > %s",
Booking::POST_TYPE,
$today,
$today
)
);
}
/**
* Count available rooms (not in maintenance/inactive).
*
* @return int
*/
private static function count_available_rooms(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_bnb_room_status'
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND (pm.meta_value IS NULL OR pm.meta_value IN ('available', 'occupied'))",
Room::POST_TYPE
)
);
}
/**
* Get total bed capacity.
*
* @return int
*/
private static function get_total_bed_capacity(): int {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm.meta_value AS UNSIGNED))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm.meta_key = '_bnb_room_beds'",
Room::POST_TYPE
)
);
return (int) $result;
}
// =========================================================================
// Helper Methods - Revenue
// =========================================================================
/**
* Get revenue for the current month.
*
* @return float
*/
private static function get_revenue_this_month(): float {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_month
)
);
return round( (float) $result, 2 );
}
/**
* Get revenue year to date.
*
* @return float
*/
private static function get_revenue_ytd(): float {
global $wpdb;
$first_of_year = gmdate( 'Y-01-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_year
)
);
return round( (float) $result, 2 );
}
/**
* Get average booking value.
*
* @return float
*/
private static function get_average_booking_value(): float {
global $wpdb;
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT AVG(CAST(pm_price.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_price ON p.ID = pm_price.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_price.meta_key = '_bnb_booking_total_price'
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE
)
);
return round( (float) $result, 2 );
}
/**
* Get revenue from services this month.
*
* @return float
*/
private static function get_services_revenue_this_month(): float {
global $wpdb;
$first_of_month = gmdate( 'Y-m-01' );
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(CAST(pm_services.meta_value AS DECIMAL(10,2)))
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_services ON p.ID = pm_services.post_id
INNER JOIN {$wpdb->postmeta} pm_in ON p.ID = pm_in.post_id
INNER JOIN {$wpdb->postmeta} pm_status ON p.ID = pm_status.post_id
WHERE p.post_type = %s
AND p.post_status = 'publish'
AND pm_services.meta_key = '_bnb_booking_services_total'
AND pm_in.meta_key = '_bnb_booking_check_in'
AND pm_in.meta_value >= %s
AND pm_status.meta_key = '_bnb_booking_status'
AND pm_status.meta_value IN ('confirmed', 'checked_in', 'checked_out')",
Booking::POST_TYPE,
$first_of_month
)
);
return round( (float) $result, 2 );
}
}

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,435 @@
<?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' ) );
// Only initialize 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,515 @@
<?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 {
/**
* 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' ) );
// AJAX handler for syncing all rooms.
add_action( 'wp_ajax_wp_bnb_sync_all_rooms', array( self::class, 'ajax_sync_all_rooms' ) );
}
/**
* 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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP BnB Management * Plugin Name: WP BnB Management
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb
* Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests.
* Version: 0.8.0 * Version: 0.12.0
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
// Plugin version constant - MUST match Version in header above. // Plugin version constant - MUST match Version in header above.
define( 'WP_BNB_VERSION', '0.8.0' ); define( 'WP_BNB_VERSION', '0.12.0' );
// Plugin path constants. // Plugin path constants.
define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) ); define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );