From dabfe1e826a16b446b3c02f4267af761a16f02cc Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 31 Jan 2026 14:10:30 +0100 Subject: [PATCH] Add pricing system with tiers, seasons, and calculator (v0.2.0) - Create PricingTier enum for short/mid/long-term pricing - Add Season class for seasonal pricing with date ranges - Implement Calculator for price calculations with breakdown - Add pricing meta box to Room post type - Create Seasons admin page for managing seasonal pricing - Add Pricing settings tab with tier thresholds - Support weekend surcharges and configurable weekend days - Add price column to room list admin Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 45 ++++ CLAUDE.md | 65 +++++- PLAN.md | 24 +-- assets/css/admin.css | 132 ++++++++++++ assets/js/admin.js | 97 +++++++++ src/Admin/Seasons.php | 419 ++++++++++++++++++++++++++++++++++++ src/Plugin.php | 189 +++++++++++++++- src/PostTypes/Room.php | 120 +++++++++++ src/Pricing/Calculator.php | 363 +++++++++++++++++++++++++++++++ src/Pricing/PricingTier.php | 159 ++++++++++++++ src/Pricing/Season.php | 319 +++++++++++++++++++++++++++ wp-bnb.php | 4 +- 12 files changed, 1911 insertions(+), 25 deletions(-) create mode 100644 src/Admin/Seasons.php create mode 100644 src/Pricing/Calculator.php create mode 100644 src/Pricing/PricingTier.php create mode 100644 src/Pricing/Season.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 439e081..d7faafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-31 + +### Added + +- Pricing System with three tiers: + - Short-term (nightly) pricing for stays up to 6 nights + - Mid-term (weekly) pricing for stays 7-27 nights + - Long-term (monthly) pricing for stays 28+ nights +- PricingTier enum class with automatic tier detection +- Season class for seasonal pricing management + - Date range support (MM-DD format) + - Year-spanning seasons (e.g., winter holidays Dec-Jan) + - Price modifier (multiplier) per season + - Priority system for overlapping seasons + - Active/inactive status toggle +- Calculator class for price calculations + - Automatic tier detection based on stay duration + - Seasonal price adjustments + - Weekend surcharge support + - Price breakdown for detailed invoicing + - Currency formatting with symbol/suffix support +- Pricing meta box on Room edit screen + - Base prices for each tier (nightly, weekly, monthly) + - Weekend surcharge field + - Link to pricing settings +- Pricing Settings tab in plugin settings + - Configurable tier thresholds + - Weekend days selection + - Quick view of configured seasons +- Seasons admin page (WP BnB > Seasons) + - List view with all seasons + - Add/Edit season form + - Delete confirmation + - Create default seasons option +- Price column in room list admin +- Admin CSS for pricing UI +- Admin JavaScript for pricing interactions + +### Changed + +- Room post type now includes pricing fields +- Plugin settings page has new Pricing tab +- Enhanced asset localization with pricing i18n strings + ## [0.1.0] - 2026-01-31 ### Added @@ -80,5 +124,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input sanitization and output escaping - Server secret masking in license settings +[0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.2.0 [0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.1.0 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wp-bnb/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 352bb0a..14a861a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,15 +38,6 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. -### Version 0.1.0 - Core Data Structures - -- Custom Post Type: Buildings (address, contact, description, images) -- Custom Post Type: Rooms (building reference, capacity, amenities) -- Custom Taxonomy: Room Types (Standard, Suite, Family, etc.) -- Custom Taxonomy: Amenities (WiFi, Parking, Breakfast, etc.) -- Gutenberg blocks for Buildings and Rooms -- See `PLAN.md` for full implementation roadmap - ## Technical Stack - **Language:** PHP 8.3.x @@ -215,11 +206,17 @@ wp-bnb/ │ └── release.yml # CI/CD release pipeline ├── src/ # PHP source code (PSR-4: Magdev\WpBnb) │ ├── Plugin.php # Main plugin singleton +│ ├── Admin/ # Admin pages +│ │ └── Seasons.php # Seasons management page │ ├── License/ │ │ └── Manager.php # License management │ ├── PostTypes/ # Custom post types │ │ ├── Building.php # Building post type │ │ └── Room.php # Room post type +│ ├── Pricing/ # Pricing system +│ │ ├── Calculator.php # Price calculation +│ │ ├── PricingTier.php # Pricing tier enum +│ │ └── Season.php # Seasonal pricing │ └── Taxonomies/ # Custom taxonomies │ ├── Amenity.php # Amenity taxonomy (tags) │ └── RoomType.php # Room type taxonomy (categories) @@ -350,3 +347,53 @@ Admin features always work; frontend requires valid license. - Gallery implementation uses `wp.media` frame with multiple selection - Admin assets need conditional loading based on both hook suffix and post type - Status badges use inline styles for color coding (avoiding extra CSS complexity) + +### 2026-01-31 - Version 0.2.0 (Pricing System) + +**Completed:** + +- Created `src/Pricing/PricingTier.php` enum class + - SHORT_TERM, MID_TERM, LONG_TERM cases + - Labels, units, default thresholds + - `fromNights()` for automatic tier detection +- Created `src/Pricing/Season.php` class + - Seasonal pricing with date ranges (MM-DD format) + - Year-spanning seasons support (e.g., Dec 20 to Jan 6) + - Price modifier (multiplier) per season + - Priority system for overlapping seasons + - CRUD operations stored in wp_options + - Default seasons: High Season, Winter Holidays, Low Season +- Created `src/Pricing/Calculator.php` class + - Price calculation with tier detection + - Seasonal modifier application + - Weekend surcharge support + - Detailed price breakdown + - Currency formatting with symbol/suffix +- Added pricing meta box to Room post type + - Base prices for each tier + - Weekend surcharge field + - Link to pricing settings + - Price column in admin list +- Added Pricing tab to settings page + - Configurable tier thresholds + - Weekend days selection (checkboxes) + - Quick view of configured seasons +- Created `src/Admin/Seasons.php` admin page + - List view with all seasons + - Add/Edit season forms + - Delete with confirmation + - Create default seasons option + - Date formatting for display +- Updated admin.css with pricing styles +- Updated admin.js with pricing interactions +- Updated Plugin.php to register Seasons admin page +- Updated version to 0.2.0 + +**Learnings:** + +- PHP 8.1+ enums work well for pricing tiers with methods +- Season date ranges use MM-DD format for annual recurrence +- Year-spanning seasons require special comparison logic +- Price modifiers as multipliers are more flexible than percentages +- Calculator class separates concerns from post type class +- Weekend days stored as comma-separated string in options diff --git a/PLAN.md b/PLAN.md index 2e63a1f..75e62a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,26 +38,26 @@ This document outlines the implementation plan for the WP BnB Management plugin. - WiFi, Parking, Breakfast, etc. - Non-hierarchical (tags) -## Phase 2: Pricing System (v0.2.0) +## Phase 2: Pricing System (v0.2.0) - Complete ### Pricing Classes -- [ ] Short-term pricing (per night, 1-6 nights) -- [ ] Mid-term pricing (per week, 1-4 weeks) -- [ ] Long-term pricing (per month, 1+ months) +- [x] Short-term pricing (per night, 1-6 nights) +- [x] Mid-term pricing (per week, 1-4 weeks) +- [x] Long-term pricing (per month, 1+ months) ### Price Configuration -- [ ] Room-level price settings -- [ ] Seasonal pricing periods -- [ ] Weekend/weekday differentiation -- [ ] Currency formatting and display +- [x] Room-level price settings +- [x] Seasonal pricing periods +- [x] Weekend/weekday differentiation +- [x] Currency formatting and display ### Price Calculation -- [ ] Automatic tier detection based on duration -- [ ] Price breakdown display -- [ ] Discount handling +- [x] Automatic tier detection based on duration +- [x] Price breakdown display +- [x] Discount handling (via seasonal modifiers) ## Phase 3: Booking System (v0.3.0) @@ -289,7 +289,7 @@ The plugin will provide extensive hooks for customization: | ------- | --------------- | -------- | | 0.0.1 | Initial setup | Complete | | 0.1.0 | Data structures | Complete | -| 0.2.0 | Pricing | TBD | +| 0.2.0 | Pricing | Complete | | 0.3.0 | Bookings | TBD | | 0.4.0 | Guests | TBD | | 0.5.0 | Services | TBD | diff --git a/assets/css/admin.css b/assets/css/admin.css index e2647ba..8902068 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -191,3 +191,135 @@ vertical-align: middle; margin-right: 3px; } + +/* Price column */ +.column-price { + width: 120px; +} + +.bnb-no-price { + color: #646970; + font-style: italic; +} + +/* Pricing Meta Box */ +.bnb-pricing-table h4 { + margin: 0 0 10px 0; + padding: 0; + font-size: 13px; + font-weight: 600; + color: #1d2327; +} + +.bnb-pricing-table tr:first-child th, +.bnb-pricing-table tr:first-child td { + padding-top: 0; +} + +.bnb-price-input-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.bnb-price-input-wrapper input { + width: 100px !important; +} + +.bnb-price-unit { + color: #646970; + font-size: 13px; +} + +/* Seasons Page */ +.bnb-seasons-description { + background: #fff; + border: 1px solid #c3c4c7; + border-left: 4px solid #72aee6; + padding: 12px 15px; + margin: 20px 0; +} + +.bnb-seasons-description p { + margin: 5px 0; +} + +.bnb-no-seasons { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 40px; + text-align: center; +} + +.bnb-no-seasons p { + margin: 15px 0; +} + +.bnb-no-seasons .button { + margin: 0 5px; +} + +/* Season List Columns */ +.column-name { + width: 25%; +} + +.column-dates { + width: 20%; +} + +.column-modifier { + width: 20%; +} + +.column-priority { + width: 10%; +} + +.column-status { + width: 10%; +} + +.bnb-modifier-visual { + font-size: 12px; + margin-left: 5px; +} + +.bnb-status-active { + display: inline-flex; + align-items: center; + color: #00a32a; + font-weight: 600; +} + +.bnb-status-active::before { + content: "\f147"; + font-family: dashicons; + margin-right: 3px; +} + +.bnb-status-inactive { + display: inline-flex; + align-items: center; + color: #646970; +} + +.bnb-status-inactive::before { + content: "\f460"; + font-family: dashicons; + margin-right: 3px; +} + +/* Season Form */ +.bnb-season-form { + max-width: 800px; +} + +.bnb-season-form .form-table th { + width: 180px; +} + +.bnb-season-form input[type="text"].small-text { + width: 80px; +} diff --git a/assets/js/admin.js b/assets/js/admin.js index 17c565e..6aa6703 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -183,10 +183,107 @@ } } + /** + * Initialize pricing settings functionality. + */ + function initPricingSettings() { + var $midTermInput = $('#wp_bnb_mid_term_max_nights'); + var $longTermMin = $('#wp-bnb-long-term-min'); + + if (!$midTermInput.length || !$longTermMin.length) { + return; + } + + // Update long-term minimum display when mid-term max changes. + $midTermInput.on('input', function() { + $longTermMin.text($(this).val()); + }); + } + + /** + * Initialize season form validation. + */ + function initSeasonForm() { + var $form = $('.bnb-season-form'); + + if (!$form.length) { + return; + } + + // Validate date format on input. + $form.find('#season_start_date, #season_end_date').on('input', function() { + var value = $(this).val(); + var isValid = /^\d{2}-\d{2}$/.test(value); + + if (value.length > 0 && !isValid) { + $(this).css('border-color', '#d63638'); + } else { + $(this).css('border-color', ''); + } + }); + + // Validate modifier range. + $form.find('#season_modifier').on('input', function() { + var value = parseFloat($(this).val()); + var $preview = $(this).siblings('.bnb-modifier-preview'); + + if ($preview.length === 0) { + $preview = $(''); + $(this).after($preview); + } + + if (!isNaN(value) && value > 0) { + var percentage = ((value - 1) * 100).toFixed(0); + var text = ''; + if (percentage > 0) { + text = '+' + percentage + '% ' + (wpBnbAdmin.i18n.increase || 'increase'); + $preview.css('color', '#d63638'); + } else if (percentage < 0) { + text = percentage + '% ' + (wpBnbAdmin.i18n.discount || 'discount'); + $preview.css('color', '#00a32a'); + } else { + text = wpBnbAdmin.i18n.normalPrice || 'Normal price'; + $preview.css('color', '#646970'); + } + $preview.text(' = ' + text); + } else { + $preview.text(''); + } + }).trigger('input'); + } + + /** + * Initialize pricing meta box interactions. + */ + function initPricingMetaBox() { + var $pricingTable = $('.bnb-pricing-table'); + + if (!$pricingTable.length) { + return; + } + + // Highlight empty required prices. + $pricingTable.find('input[type="number"]').on('blur', function() { + var $input = $(this); + var value = $input.val(); + var isShortTerm = $input.attr('id').indexOf('short_term') !== -1; + + // Short-term price is recommended. + if (isShortTerm && (value === '' || parseFloat(value) <= 0)) { + $input.css('background-color', '#fcf0f1'); + } else { + $input.css('background-color', ''); + } + }); + } + // Initialize on document ready. $(document).ready(function() { initLicenseManagement(); initRoomGallery(); + initPricingSettings(); + initSeasonForm(); + initPricingMetaBox(); }); })(jQuery); diff --git a/src/Admin/Seasons.php b/src/Admin/Seasons.php new file mode 100644 index 0000000..9fa784b --- /dev/null +++ b/src/Admin/Seasons.php @@ -0,0 +1,419 @@ + isset( $_POST['season_id'] ) && '' !== $_POST['season_id'] + ? sanitize_text_field( wp_unslash( $_POST['season_id'] ) ) + : wp_generate_uuid4(), + 'name' => isset( $_POST['season_name'] ) + ? sanitize_text_field( wp_unslash( $_POST['season_name'] ) ) + : '', + 'start_date' => isset( $_POST['season_start_date'] ) + ? sanitize_text_field( wp_unslash( $_POST['season_start_date'] ) ) + : '', + 'end_date' => isset( $_POST['season_end_date'] ) + ? sanitize_text_field( wp_unslash( $_POST['season_end_date'] ) ) + : '', + 'modifier' => isset( $_POST['season_modifier'] ) + ? floatval( $_POST['season_modifier'] ) + : 1.0, + 'priority' => isset( $_POST['season_priority'] ) + ? absint( $_POST['season_priority'] ) + : 0, + 'active' => isset( $_POST['season_active'] ), + ); + + $season = new Season( $data ); + Season::save( $season ); + + $is_edit = isset( $_POST['season_id'] ) && '' !== $_POST['season_id']; + $message = $is_edit ? 'updated' : 'created'; + + wp_safe_redirect( admin_url( 'admin.php?page=wp-bnb-seasons&' . $message . '=1' ) ); + exit; + } + + /** + * Render the admin page. + * + * @return void + */ + public static function render_page(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. + $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : 'list'; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. + $season_id = isset( $_GET['season_id'] ) ? sanitize_text_field( wp_unslash( $_GET['season_id'] ) ) : ''; + + ?> +
+

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

' . esc_html__( 'Season deleted successfully.', 'wp-bnb' ) . '

'; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. + if ( isset( $_GET['created'] ) ) { + echo '

' . esc_html__( 'Season created successfully.', 'wp-bnb' ) . '

'; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. + if ( isset( $_GET['updated'] ) ) { + echo '

' . esc_html__( 'Season updated successfully.', 'wp-bnb' ) . '

'; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. + if ( isset( $_GET['defaults_created'] ) ) { + echo '

' . esc_html__( 'Default seasons created successfully.', 'wp-bnb' ) . '

'; + } + } + + /** + * Render the seasons list. + * + * @return void + */ + private static function render_list(): void { + $seasons = Season::all(); + ?> +
+

+

+
+ + +
+

+

+ + + + + + +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + name ); ?> + + +
+ + + + | + + + + + + +
+
+ start_date, $season->end_date ) ); ?> + + getModifierLabel() ); ?> + modifier !== 1.0 ) : ?> + + (modifier, 2 ) ); ?>x) + + + + priority ); ?> + + active ) : ?> + + + + +
+ +

' . esc_html__( 'Season not found.', 'wp-bnb' ) . '

'; + return; + } + ?> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + +

+
+ __( 'Jan', 'wp-bnb' ), + '02' => __( 'Feb', 'wp-bnb' ), + '03' => __( 'Mar', 'wp-bnb' ), + '04' => __( 'Apr', 'wp-bnb' ), + '05' => __( 'May', 'wp-bnb' ), + '06' => __( 'Jun', 'wp-bnb' ), + '07' => __( 'Jul', 'wp-bnb' ), + '08' => __( 'Aug', 'wp-bnb' ), + '09' => __( 'Sep', 'wp-bnb' ), + '10' => __( 'Oct', 'wp-bnb' ), + '11' => __( 'Nov', 'wp-bnb' ), + '12' => __( 'Dec', 'wp-bnb' ), + ); + + $format_date = function ( string $date ) use ( $months ): string { + $parts = explode( '-', $date ); + if ( count( $parts ) !== 2 ) { + return $date; + } + $month = $months[ $parts[0] ] ?? $parts[0]; + $day = ltrim( $parts[1], '0' ); + return $month . ' ' . $day; + }; + + return $format_date( $start ) . ' - ' . $format_date( $end ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e83773c..323d882 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,9 +9,11 @@ declare( strict_types=1 ); namespace Magdev\WpBnb; +use Magdev\WpBnb\Admin\Seasons as SeasonsAdmin; use Magdev\WpBnb\License\Manager as LicenseManager; use Magdev\WpBnb\PostTypes\Building; use Magdev\WpBnb\PostTypes\Room; +use Magdev\WpBnb\Pricing\Season; use Magdev\WpBnb\Taxonomies\Amenity; use Magdev\WpBnb\Taxonomies\RoomType; use Twig\Environment; @@ -127,6 +129,9 @@ final class Plugin { // Admin menu and settings will be added here. add_action( 'admin_menu', array( $this, 'register_admin_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); + + // Initialize seasons admin page. + SeasonsAdmin::init(); } /** @@ -161,9 +166,9 @@ final class Plugin { global $post_type; // Check if we're on plugin pages or editing our custom post types. - $is_plugin_page = strpos( $hook_suffix, 'wp-bnb' ) !== false; + $is_plugin_page = strpos( $hook_suffix, 'wp-bnb' ) !== false; $is_our_post_type = in_array( $post_type, array( Building::POST_TYPE, Room::POST_TYPE ), true ); - $is_edit_screen = in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ); + $is_edit_screen = in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ); if ( ! $is_plugin_page && ! ( $is_our_post_type && $is_edit_screen ) ) { return; @@ -206,6 +211,9 @@ final class Plugin { 'selectImages' => __( 'Select Images', 'wp-bnb' ), 'addToGallery' => __( 'Add to Gallery', 'wp-bnb' ), 'confirmRemove' => __( 'Are you sure you want to remove this image?', 'wp-bnb' ), + 'increase' => __( 'increase', 'wp-bnb' ), + 'discount' => __( 'discount', 'wp-bnb' ), + 'normalPrice' => __( 'Normal price', 'wp-bnb' ), ), ) ); @@ -353,6 +361,10 @@ final class Plugin { class="nav-tab "> + + + @@ -362,6 +374,9 @@ final class Plugin {
render_pricing_settings(); + break; case 'license': $this->render_license_settings(); break; @@ -427,6 +442,143 @@ final class Plugin { __( 'Monday', 'wp-bnb' ), + 2 => __( 'Tuesday', 'wp-bnb' ), + 3 => __( 'Wednesday', 'wp-bnb' ), + 4 => __( 'Thursday', 'wp-bnb' ), + 5 => __( 'Friday', 'wp-bnb' ), + 6 => __( 'Saturday', 'wp-bnb' ), + 7 => __( 'Sunday', 'wp-bnb' ), + ); + $selected_days = array_map( 'intval', explode( ',', $weekend_days ) ); + ?> +
+ + +

+

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

+

+ + + + + + + + +

+

+ ' . esc_html__( 'Seasons Manager', 'wp-bnb' ) . '' + ); + ?> +

+ + + + + + + + + + + + + + + + + + + + + +
name ); ?>start_date . ' - ' . $season->end_date ); ?>getModifierLabel() ); ?> + active ) : ?> + + + + +
+ +

+ + + +
+ save_pricing_settings(); + break; case 'license': $this->save_license_settings(); break; @@ -614,6 +769,36 @@ final class Plugin { settings_errors( 'wp_bnb_settings' ); } + /** + * Save pricing settings. + * + * @return void + */ + private function save_pricing_settings(): void { + if ( isset( $_POST['wp_bnb_short_term_max_nights'] ) ) { + $value = absint( $_POST['wp_bnb_short_term_max_nights'] ); + $value = max( 1, min( 30, $value ) ); + update_option( 'wp_bnb_short_term_max_nights', $value ); + } + + if ( isset( $_POST['wp_bnb_mid_term_max_nights'] ) ) { + $value = absint( $_POST['wp_bnb_mid_term_max_nights'] ); + $value = max( 7, min( 90, $value ) ); + update_option( 'wp_bnb_mid_term_max_nights', $value ); + } + + if ( isset( $_POST['wp_bnb_weekend_days'] ) && is_array( $_POST['wp_bnb_weekend_days'] ) ) { + $days = array_map( 'absint', $_POST['wp_bnb_weekend_days'] ); + $days = array_filter( $days, fn( $d ) => $d >= 1 && $d <= 7 ); + update_option( 'wp_bnb_weekend_days', implode( ',', $days ) ); + } else { + update_option( 'wp_bnb_weekend_days', '' ); + } + + add_settings_error( 'wp_bnb_settings', 'settings_saved', __( 'Pricing settings saved.', 'wp-bnb' ), 'success' ); + settings_errors( 'wp_bnb_settings' ); + } + /** * Save license settings. * diff --git a/src/PostTypes/Room.php b/src/PostTypes/Room.php index 6617163..a00ab3a 100644 --- a/src/PostTypes/Room.php +++ b/src/PostTypes/Room.php @@ -11,6 +11,8 @@ declare( strict_types=1 ); namespace Magdev\WpBnb\PostTypes; +use Magdev\WpBnb\Pricing\Calculator; +use Magdev\WpBnb\Pricing\PricingTier; use Magdev\WpBnb\Taxonomies\Amenity; use Magdev\WpBnb\Taxonomies\RoomType; @@ -147,6 +149,15 @@ final class Room { 'normal', 'default' ); + + add_meta_box( + 'bnb_room_pricing', + __( 'Room Pricing', 'wp-bnb' ), + array( self::class, 'render_pricing_meta_box' ), + self::POST_TYPE, + 'normal', + 'high' + ); } /** @@ -327,6 +338,87 @@ final class Room { ID ); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + ?> + + + + + + + + + + + + + + + + + +
+

+
+ + +
+ + + unit() ); ?> + +
+
+

+
+ + +
+ + + + +
+

+ +

+
+

+ ' . esc_html__( 'Pricing Settings', 'wp-bnb' ) . '' + ); + ?> +

+ value; + if ( isset( $_POST[ $key ] ) && '' !== $_POST[ $key ] ) { + $pricing_data[ $tier->value ] = floatval( $_POST[ $key ] ); + } + } + + if ( isset( $_POST['bnb_room_price_weekend_surcharge'] ) ) { + $pricing_data['weekend_surcharge'] = floatval( $_POST['bnb_room_price_weekend_surcharge'] ); + } + + if ( ! empty( $pricing_data ) ) { + Calculator::saveRoomPricing( $post_id, $pricing_data ); + } } /** @@ -433,6 +542,7 @@ final class Room { $new_columns['room_number'] = __( 'Room #', 'wp-bnb' ); $new_columns['taxonomy-bnb_room_type'] = __( 'Room Type', 'wp-bnb' ); $new_columns['capacity'] = __( 'Capacity', 'wp-bnb' ); + $new_columns['price'] = __( 'Price/Night', 'wp-bnb' ); $new_columns['status'] = __( 'Status', 'wp-bnb' ); } } @@ -483,6 +593,16 @@ final class Room { } break; + case 'price': + $pricing = Calculator::getRoomPricing( $post_id ); + $nightly = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? null; + if ( $nightly ) { + echo esc_html( Calculator::formatPrice( $nightly ) ); + } else { + echo '' . esc_html__( 'Not set', 'wp-bnb' ) . ''; + } + break; + case 'status': $status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'available'; $statuses = self::get_room_statuses(); diff --git a/src/Pricing/Calculator.php b/src/Pricing/Calculator.php new file mode 100644 index 0000000..961a9fc --- /dev/null +++ b/src/Pricing/Calculator.php @@ -0,0 +1,363 @@ +room_id = $room_id; + + if ( is_string( $check_in ) ) { + $check_in = new \DateTimeImmutable( $check_in ); + } elseif ( $check_in instanceof \DateTime ) { + $check_in = \DateTimeImmutable::createFromMutable( $check_in ); + } + + if ( is_string( $check_out ) ) { + $check_out = new \DateTimeImmutable( $check_out ); + } elseif ( $check_out instanceof \DateTime ) { + $check_out = \DateTimeImmutable::createFromMutable( $check_out ); + } + + $this->check_in = $check_in; + $this->check_out = $check_out; + } + + /** + * Get the number of nights. + * + * @return int + */ + public function getNights(): int { + $interval = $this->check_in->diff( $this->check_out ); + return max( 1, $interval->days ); + } + + /** + * Get the pricing tier for this stay. + * + * @return PricingTier + */ + public function getTier(): PricingTier { + return PricingTier::fromNights( $this->getNights() ); + } + + /** + * Get the base price for a room. + * + * @param PricingTier $tier Pricing tier. + * @return float + */ + public function getBasePrice( PricingTier $tier ): float { + $meta_key = self::META_PREFIX . $tier->value; + $price = get_post_meta( $this->room_id, $meta_key, true ); + return $price ? (float) $price : 0.0; + } + + /** + * Get the weekend surcharge for a room. + * + * @return float Surcharge as absolute amount. + */ + public function getWeekendSurcharge(): float { + $surcharge = get_post_meta( $this->room_id, self::META_PREFIX . 'weekend_surcharge', true ); + return $surcharge ? (float) $surcharge : 0.0; + } + + /** + * Check if weekend surcharge is enabled for this room. + * + * @return bool + */ + public function hasWeekendSurcharge(): bool { + return $this->getWeekendSurcharge() > 0; + } + + /** + * Check if a date is a weekend day. + * + * @param \DateTimeInterface $date Date to check. + * @return bool + */ + public static function isWeekend( \DateTimeInterface $date ): bool { + $day_of_week = (int) $date->format( 'N' ); // 1 = Monday, 7 = Sunday. + $weekend_days = array_map( + 'intval', + explode( ',', (string) get_option( 'wp_bnb_weekend_days', '5,6' ) ) // Default: Friday, Saturday. + ); + return in_array( $day_of_week, $weekend_days, true ); + } + + /** + * Calculate the total price. + * + * @return float + */ + public function calculate(): float { + $this->breakdown = array(); + + $nights = $this->getNights(); + $tier = $this->getTier(); + + $base_price = $this->getBasePrice( $tier ); + $weekend_surcharge = $this->getWeekendSurcharge(); + + // If no base price is set, return 0. + if ( $base_price <= 0 ) { + return 0.0; + } + + $total = 0.0; + + // Calculate based on tier. + switch ( $tier ) { + case PricingTier::LONG_TERM: + // Monthly pricing. + $months = ceil( $nights / 30 ); + $total = $base_price * $months; + $this->breakdown['months'] = $months; + $this->breakdown['monthly_rate'] = $base_price; + break; + + case PricingTier::MID_TERM: + // Weekly pricing. + $weeks = ceil( $nights / 7 ); + $total = $base_price * $weeks; + $this->breakdown['weeks'] = $weeks; + $this->breakdown['weekly_rate'] = $base_price; + break; + + case PricingTier::SHORT_TERM: + default: + // Nightly pricing with seasonal adjustments and weekend surcharges. + $current = $this->check_in; + $breakdown_nights = array(); + + for ( $i = 0; $i < $nights; $i++ ) { + $night_price = $base_price; + $modifiers = array(); + + // Apply seasonal pricing. + $season = Season::forDate( $current ); + if ( $season ) { + $night_price *= $season->modifier; + $modifiers['season'] = array( + 'name' => $season->name, + 'modifier' => $season->modifier, + ); + } + + // Apply weekend surcharge. + if ( $weekend_surcharge > 0 && self::isWeekend( $current ) ) { + $night_price += $weekend_surcharge; + $modifiers['weekend'] = $weekend_surcharge; + } + + $breakdown_nights[] = array( + 'date' => $current->format( 'Y-m-d' ), + 'base' => $base_price, + 'final' => $night_price, + 'modifiers' => $modifiers, + ); + + $total += $night_price; + $current = $current->modify( '+1 day' ); + } + + $this->breakdown['nights'] = $breakdown_nights; + $this->breakdown['nightly_rate'] = $base_price; + break; + } + + $this->breakdown['tier'] = $tier->value; + $this->breakdown['total'] = $total; + + /** + * Filter the calculated price. + * + * @param float $total Calculated total price. + * @param int $room_id Room post ID. + * @param Calculator $calculator Calculator instance. + */ + return (float) apply_filters( 'wp_bnb_calculate_price', $total, $this->room_id, $this ); + } + + /** + * Get the price breakdown. + * + * Must be called after calculate(). + * + * @return array + */ + public function getBreakdown(): array { + return $this->breakdown; + } + + /** + * Format a price with currency. + * + * @param float $amount Amount to format. + * @param string $currency Currency code. Default from settings. + * @return string + */ + public static function formatPrice( float $amount, string $currency = '' ): string { + if ( empty( $currency ) ) { + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + } + + $symbols = array( + 'CHF' => 'CHF', + 'EUR' => "\u{20AC}", + 'USD' => '$', + 'GBP' => "\u{00A3}", + ); + + $symbol = $symbols[ $currency ] ?? $currency; + $formatted = number_format( $amount, 2, '.', "'" ); + + // CHF uses suffix, others use prefix. + if ( 'CHF' === $currency ) { + return $formatted . ' ' . $symbol; + } + + return $symbol . ' ' . $formatted; + } + + /** + * Get room pricing summary. + * + * @param int $room_id Room post ID. + * @return array + */ + public static function getRoomPricing( int $room_id ): array { + $pricing = array(); + + foreach ( PricingTier::cases() as $tier ) { + $meta_key = self::META_PREFIX . $tier->value; + $price = get_post_meta( $room_id, $meta_key, true ); + + $pricing[ $tier->value ] = array( + 'label' => $tier->label(), + 'unit' => $tier->unit(), + 'price' => $price ? (float) $price : null, + ); + } + + $pricing['weekend_surcharge'] = array( + 'label' => __( 'Weekend Surcharge', 'wp-bnb' ), + 'price' => (float) get_post_meta( $room_id, self::META_PREFIX . 'weekend_surcharge', true ), + ); + + return $pricing; + } + + /** + * Save room pricing. + * + * @param int $room_id Room post ID. + * @param array $pricing Pricing data with tier keys. + * @return void + */ + public static function saveRoomPricing( int $room_id, array $pricing ): void { + foreach ( PricingTier::cases() as $tier ) { + if ( isset( $pricing[ $tier->value ] ) ) { + update_post_meta( + $room_id, + self::META_PREFIX . $tier->value, + floatval( $pricing[ $tier->value ] ) + ); + } + } + + if ( isset( $pricing['weekend_surcharge'] ) ) { + update_post_meta( + $room_id, + self::META_PREFIX . 'weekend_surcharge', + floatval( $pricing['weekend_surcharge'] ) + ); + } + } + + /** + * Get the check-in date. + * + * @return \DateTimeImmutable + */ + public function getCheckIn(): \DateTimeImmutable { + return $this->check_in; + } + + /** + * Get the check-out date. + * + * @return \DateTimeImmutable + */ + public function getCheckOut(): \DateTimeImmutable { + return $this->check_out; + } + + /** + * Get the room ID. + * + * @return int + */ + public function getRoomId(): int { + return $this->room_id; + } +} diff --git a/src/Pricing/PricingTier.php b/src/Pricing/PricingTier.php new file mode 100644 index 0000000..9d0ffa6 --- /dev/null +++ b/src/Pricing/PricingTier.php @@ -0,0 +1,159 @@ + __( 'Short-term (Nightly)', 'wp-bnb' ), + self::MID_TERM => __( 'Mid-term (Weekly)', 'wp-bnb' ), + self::LONG_TERM => __( 'Long-term (Monthly)', 'wp-bnb' ), + }; + } + + /** + * Get the unit label for this tier. + * + * @return string + */ + public function unit(): string { + return match ( $this ) { + self::SHORT_TERM => __( 'per night', 'wp-bnb' ), + self::MID_TERM => __( 'per week', 'wp-bnb' ), + self::LONG_TERM => __( 'per month', 'wp-bnb' ), + }; + } + + /** + * Get the unit label for this tier (singular). + * + * @return string + */ + public function unitSingular(): string { + return match ( $this ) { + self::SHORT_TERM => __( 'night', 'wp-bnb' ), + self::MID_TERM => __( 'week', 'wp-bnb' ), + self::LONG_TERM => __( 'month', 'wp-bnb' ), + }; + } + + /** + * Get the unit label for this tier (plural). + * + * @return string + */ + public function unitPlural(): string { + return match ( $this ) { + self::SHORT_TERM => __( 'nights', 'wp-bnb' ), + self::MID_TERM => __( 'weeks', 'wp-bnb' ), + self::LONG_TERM => __( 'months', 'wp-bnb' ), + }; + } + + /** + * Get the default minimum nights for this tier. + * + * @return int + */ + public function defaultMinNights(): int { + return match ( $this ) { + self::SHORT_TERM => 1, + self::MID_TERM => 7, + self::LONG_TERM => 28, + }; + } + + /** + * Get the default maximum nights for this tier. + * + * @return int|null Null means unlimited. + */ + public function defaultMaxNights(): ?int { + return match ( $this ) { + self::SHORT_TERM => 6, + self::MID_TERM => 27, + self::LONG_TERM => null, + }; + } + + /** + * Determine the pricing tier based on number of nights. + * + * @param int $nights Number of nights. + * @param int|null $short_term_max Maximum nights for short-term. Default 6. + * @param int|null $mid_term_max Maximum nights for mid-term. Default 27. + * @return self + */ + public static function fromNights( int $nights, ?int $short_term_max = null, ?int $mid_term_max = null ): self { + $short_term_max = $short_term_max ?? (int) get_option( 'wp_bnb_short_term_max_nights', 6 ); + $mid_term_max = $mid_term_max ?? (int) get_option( 'wp_bnb_mid_term_max_nights', 27 ); + + if ( $nights <= $short_term_max ) { + return self::SHORT_TERM; + } + + if ( $nights <= $mid_term_max ) { + return self::MID_TERM; + } + + return self::LONG_TERM; + } + + /** + * Get all tiers. + * + * @return array + */ + public static function all(): array { + return self::cases(); + } + + /** + * Get tier options for select fields. + * + * @return array + */ + public static function options(): array { + $options = array(); + foreach ( self::cases() as $tier ) { + $options[ $tier->value ] = $tier->label(); + } + return $options; + } +} diff --git a/src/Pricing/Season.php b/src/Pricing/Season.php new file mode 100644 index 0000000..64daf39 --- /dev/null +++ b/src/Pricing/Season.php @@ -0,0 +1,319 @@ +id = $data['id'] ?? wp_generate_uuid4(); + $this->name = $data['name'] ?? ''; + $this->start_date = $data['start_date'] ?? ''; + $this->end_date = $data['end_date'] ?? ''; + $this->modifier = isset( $data['modifier'] ) ? (float) $data['modifier'] : 1.0; + $this->priority = isset( $data['priority'] ) ? (int) $data['priority'] : 0; + $this->active = isset( $data['active'] ) ? (bool) $data['active'] : true; + } + + /** + * Convert to array. + * + * @return array + */ + public function toArray(): array { + return array( + 'id' => $this->id, + 'name' => $this->name, + 'start_date' => $this->start_date, + 'end_date' => $this->end_date, + 'modifier' => $this->modifier, + 'priority' => $this->priority, + 'active' => $this->active, + ); + } + + /** + * Check if a date falls within this season. + * + * @param \DateTimeInterface $date Date to check. + * @param int|null $year Year context (for spanning seasons like winter). + * @return bool + */ + public function containsDate( \DateTimeInterface $date, ?int $year = null ): bool { + if ( ! $this->active ) { + return false; + } + + $year = $year ?? (int) $date->format( 'Y' ); + + // Parse start and end as month-day. + $start_parts = explode( '-', $this->start_date ); + $end_parts = explode( '-', $this->end_date ); + + if ( count( $start_parts ) !== 2 || count( $end_parts ) !== 2 ) { + return false; + } + + $start_month = (int) $start_parts[0]; + $start_day = (int) $start_parts[1]; + $end_month = (int) $end_parts[0]; + $end_day = (int) $end_parts[1]; + + $check_month = (int) $date->format( 'm' ); + $check_day = (int) $date->format( 'd' ); + + // Create comparable values (month * 100 + day). + $check_value = $check_month * 100 + $check_day; + $start_value = $start_month * 100 + $start_day; + $end_value = $end_month * 100 + $end_day; + + // Handle year-spanning seasons (e.g., December to February). + if ( $start_value > $end_value ) { + // Season spans year boundary. + return $check_value >= $start_value || $check_value <= $end_value; + } + + // Normal season within same year. + return $check_value >= $start_value && $check_value <= $end_value; + } + + /** + * Get the display label for the modifier. + * + * @return string + */ + public function getModifierLabel(): string { + if ( $this->modifier === 1.0 ) { + return __( 'Normal', 'wp-bnb' ); + } + + $percentage = ( $this->modifier - 1 ) * 100; + if ( $percentage > 0 ) { + /* translators: %s: percentage increase */ + return sprintf( __( '+%s%%', 'wp-bnb' ), number_format( $percentage, 0 ) ); + } + + /* translators: %s: percentage decrease */ + return sprintf( __( '%s%%', 'wp-bnb' ), number_format( $percentage, 0 ) ); + } + + /** + * Get all seasons. + * + * @return array + */ + public static function all(): array { + $data = get_option( self::OPTION_NAME, array() ); + $seasons = array(); + + foreach ( $data as $season_data ) { + $seasons[] = new self( $season_data ); + } + + // Sort by priority (descending). + usort( $seasons, fn( Season $a, Season $b ) => $b->priority <=> $a->priority ); + + return $seasons; + } + + /** + * Get all active seasons. + * + * @return array + */ + public static function allActive(): array { + return array_filter( self::all(), fn( Season $s ) => $s->active ); + } + + /** + * Find a season by ID. + * + * @param string $id Season ID. + * @return self|null + */ + public static function find( string $id ): ?self { + foreach ( self::all() as $season ) { + if ( $season->id === $id ) { + return $season; + } + } + return null; + } + + /** + * Get the applicable season for a date. + * + * Returns the highest priority active season that contains the date. + * + * @param \DateTimeInterface $date Date to check. + * @return self|null + */ + public static function forDate( \DateTimeInterface $date ): ?self { + $seasons = self::allActive(); + + foreach ( $seasons as $season ) { + if ( $season->containsDate( $date ) ) { + return $season; + } + } + + return null; + } + + /** + * Save a season. + * + * @param self $season Season to save. + * @return bool + */ + public static function save( self $season ): bool { + $data = get_option( self::OPTION_NAME, array() ); + $exists = false; + + foreach ( $data as $index => $existing ) { + if ( $existing['id'] === $season->id ) { + $data[ $index ] = $season->toArray(); + $exists = true; + break; + } + } + + if ( ! $exists ) { + $data[] = $season->toArray(); + } + + return update_option( self::OPTION_NAME, $data ); + } + + /** + * Delete a season. + * + * @param string $id Season ID to delete. + * @return bool + */ + public static function delete( string $id ): bool { + $data = get_option( self::OPTION_NAME, array() ); + $data = array_filter( $data, fn( $s ) => $s['id'] !== $id ); + return update_option( self::OPTION_NAME, array_values( $data ) ); + } + + /** + * Create default seasons. + * + * @return void + */ + public static function createDefaults(): void { + $existing = get_option( self::OPTION_NAME, array() ); + if ( ! empty( $existing ) ) { + return; + } + + $defaults = array( + new self( + array( + 'name' => __( 'High Season', 'wp-bnb' ), + 'start_date' => '06-15', + 'end_date' => '09-15', + 'modifier' => 1.25, + 'priority' => 10, + 'active' => true, + ) + ), + new self( + array( + 'name' => __( 'Winter Holidays', 'wp-bnb' ), + 'start_date' => '12-20', + 'end_date' => '01-06', + 'modifier' => 1.30, + 'priority' => 20, + 'active' => true, + ) + ), + new self( + array( + 'name' => __( 'Low Season', 'wp-bnb' ), + 'start_date' => '11-01', + 'end_date' => '03-31', + 'modifier' => 0.85, + 'priority' => 5, + 'active' => true, + ) + ), + ); + + $data = array_map( fn( Season $s ) => $s->toArray(), $defaults ); + update_option( self::OPTION_NAME, $data ); + } +} diff --git a/wp-bnb.php b/wp-bnb.php index 46d335c..b95b4c5 100644 --- a/wp-bnb.php +++ b/wp-bnb.php @@ -3,7 +3,7 @@ * Plugin Name: WP BnB Management * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. - * Version: 0.1.0 + * Version: 0.2.0 * Requires at least: 6.0 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Plugin version constant - MUST match Version in header above. -define( 'WP_BNB_VERSION', '0.1.0' ); +define( 'WP_BNB_VERSION', '0.2.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );