diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0088dd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,230 @@ +# PMPro Donations — Agent Instructions + +> Paid Memberships Pro add-on for accepting donations at checkout. This file is the primary instruction reference for any AI agent working in this repo. + +## What This Is + +PMPro Donations adds donation functionality to the Paid Memberships Pro checkout flow. Members can add a one-time donation on top of their membership fee, or donate without a membership via "donation-only" levels. + +- **Plugin Slug**: `pmpro-donations` +- **Current Version**: 2.2 +- **Requires**: Paid Memberships Pro (core plugin) +- **WordPress Tested Up To**: 6.8 +- **PHP**: 7.4+ (supports up to 8.5) +- **Text Domain**: `pmpro-donations` +- **License**: GPL-2.0+ +- **Repo**: https://github.com/strangerstudios/pmpro-donations + +## Repo Layout + +``` +pmpro-donations/ +├── pmpro-donations.php # Main plugin file (bootstrap, includes, meta links) +├── includes/ +│ ├── common.php # Shared utilities (get settings, get price components) +│ ├── checkout.php # Checkout flow: form display, validation, price modification +│ ├── donation-only-level.php # Donation-only level logic (keep member's existing level) +│ ├── level-settings.php # Admin: level edit page donation settings UI +│ └── admin.php # Admin: order editing, CSV export column +├── languages/ +│ ├── pmpro-donations.pot # Translation template +│ ├── pmpro-donations.po # Translation source +│ ├── pmpro-donations.mo # Compiled translations +│ └── gettext.sh # Translation extraction script +├── readme.txt # WordPress.org plugin readme +├── readme.md # GitHub readme +├── AGENTS.md # This file +├── PLAN.md # Current roadmap and next steps +└── .github/ # GitHub templates and workflows +``` + +**Key fact**: This is a lightweight plugin (~600 lines of PHP across 5 files). No standalone JavaScript or CSS files — all JS is inline in PHP, all styling uses PMPro core CSS classes. + +## Architecture + +### Checkout Flow (Execution Order) + +When a user checks out on a level with donations enabled: + +| # | Hook | Function | File | Purpose | +|---|------|----------|------|---------| +| 1 | `pmpro_checkout_preheader_before_get_level_at_checkout` (pri 1) | `pmprodon_init_dropdown_values()` | checkout.php | Copy dropdown selection to `$_REQUEST['donation']` | +| 2 | `pmpro_checkout_preheader_before_get_level_at_checkout` (pri 10) | `pmprodon_ppe_add_donation_to_request()` | checkout.php | PayPal Express: restore donation from order meta on return | +| 3 | `pmpro_checkout_preheader` | `pmprodon_pmpro_checkout_preheader()` | checkout.php | Force SSL for donation levels | +| 4 | `pmpro_checkout_before_form` | `pmprodon_hook_pmpro_level_cost_text()` | checkout.php | Add cost text filter | +| 5 | `pmpro_checkout_after_user_fields` | `pmprodon_pmpro_checkout_after_user_fields()` | checkout.php | **Render donation form UI** (dropdown or text input) | +| 6 | `pmpro_checkout_level` (pri 99) | `pmprodon_pmpro_checkout_level()` | checkout.php | **Add donation to `$level->initial_payment`** | +| 7 | `pmpro_checkout_after_level_cost` | `pmprodon_unhook_pmpro_level_cost_text()` | checkout.php | Remove cost text filter | +| 8 | `pmpro_registration_checks` | `pmprodon_pmpro_registration_checks()` | checkout.php | **Validate** donation (min, max, negative) | +| 9 | `pmpro_checkout_before_change_membership_level` (pri 1) | `pmprodon_pmpro_checkout_before_change_membership_level()` | donation-only-level.php | Donation-only: prevent level switching | +| 10 | `pmpro_after_checkout` | `pmprodon_store_donation_amount_in_order_meta()` | checkout.php | **Save donation to order meta** | +| 11 | `pmpro_after_checkout` | `pmprodon_pmpro_after_checkout()` | donation-only-level.php | Donation-only: restore member's previous level | + +### Data Storage + +**Donation Settings** (per level): +```php +// Option: pmprodon_{level_id} +get_option( 'pmprodon_' . $level_id ); +// Returns: +array( + 'donations' => 0|1, // Enable donations + 'donations_only' => 0|1, // Donation-only level + 'min_price' => '5.00', // Minimum donation + 'max_price' => '500.00', // Maximum donation + 'dropdown_prices' => '10,25,50,other', // Comma-separated + 'text' => '

Help text HTML

', + 'confirmation_message' => '

Thank you HTML

' +) +``` + +**Donation Amount** (per order): +```php +// Primary (v2.0+): Order meta +update_pmpro_membership_order_meta( $order->id, 'donation_amount', $amount ); +get_pmpro_membership_order_meta( $order->id, 'donation_amount', true ); + +// Legacy (v1.x): Order notes — auto-migrated on first access +// Pattern: "Donation: 25.00" in $order->notes +``` + +### Key Utility Functions + +| Function | File | Purpose | +|----------|------|---------| +| `pmprodon_get_level_settings( $level_id )` | common.php | Get donation settings for a level (returns array with defaults) | +| `pmprodon_is_donations_only( $level_id )` | common.php | Check if level is donation-only | +| `pmprodon_get_price_components( $order )` | common.php | Split order total into `['price' => float, 'donation' => float]` | + +### Filters for Customization + +| Filter | Purpose | +|--------|---------| +| `pmpro_donations_get_price_components` | Customize how order total is split into price + donation | +| `pmpro_donations_invoice_bullets` | Customize invoice bullet points | + +### Email Integration + +- **Variable**: `!!donation!!` — replaced with formatted donation amount in all checkout emails +- **Auto-injection**: If email template doesn't use `!!donation!!`, donation info is injected before the "Invoice" section +- **Hook**: `pmpro_email_data` and `pmpro_email_filter` + +### Donation-Only Levels + +Special levels where existing members can donate without losing their current membership: + +1. Admin enables "Donations-Only Level" checkbox in level settings +2. At checkout, plugin prevents cancellation of existing subscriptions (`pmpro_cancel_previous_subscriptions` → `__return_false`) +3. After checkout, the donation-only level row is removed from `pmpro_memberships_users` +4. User keeps their original level; donation is recorded on the order + +### Inline JavaScript + +All JS lives in two places: + +**checkout.php** (lines 124-204): +- `pmprodon_toggleOther()` — show/hide custom amount input when dropdown changes +- `pmprodon_checkForFree()` — show/hide billing fields based on donation amount and gateway +- Event listeners on `#donation_dropdown`, `#donation`, and `input[name=gateway]` + +**level-settings.php** (lines 85-104): +- `toggleDonFields()` — show/hide admin settings based on "Enable Donations" checkbox + +### CSS Classes Used (from PMPro core) + +The plugin uses PMPro's built-in CSS classes — no custom stylesheet: +- `pmpro_form_fieldset`, `pmpro_card`, `pmpro_card_content` +- `pmpro_form_field-donation`, `pmpro_form_input-select`, `pmpro_form_input-text` +- `pmpro_alter_price` — triggers PMPro's price change detection (v2.0+) + +## Development Setup + +### Local WordPress Environment + +This plugin requires an active WordPress installation with Paid Memberships Pro: + +```bash +# Clone into wp-content/plugins/ +cd /path/to/wordpress/wp-content/plugins/ +git clone https://github.com/strangerstudios/pmpro-donations.git + +# Or symlink from repos dir +ln -s ~/repos/strangerstudios/pmpro-donations /path/to/wordpress/wp-content/plugins/pmpro-donations + +# Activate via WP CLI +wp plugin activate pmpro-donations +``` + +### Testing Donations + +1. Create a membership level in PMPro (Memberships > Settings > Levels) +2. Edit the level, scroll to "Donation Settings" +3. Check "Enable Donations", set min/max/dropdown values +4. Visit the checkout page for that level +5. For donation-only testing: also check "Donations-Only Level" + +### Branching + +- **`dev`** — Main development branch (default). PRs target this. +- **Feature branches** — Named descriptively (e.g., `keep-level`, `payments-free-level`) +- **No `main`/`master`** — This repo uses `dev` as the default branch + +## Coding Conventions + +### WordPress Coding Standards + +This plugin follows [WordPress PHP Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/): + +- **Indentation**: Tabs (not spaces) +- **Naming**: `snake_case` for functions and variables, prefixed with `pmprodon_` +- **Strings**: Single quotes for simple strings, double quotes when interpolation needed +- **Sanitization**: All `$_REQUEST`/`$_GET`/`$_POST` values sanitized with `sanitize_text_field()` and `preg_replace()` for numeric values +- **Escaping**: Output escaped with `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()` +- **i18n**: All user-facing strings wrapped in `__()`, `_e()`, `esc_html_e()`, etc. with text domain `pmpro-donations` +- **DocBlocks**: PHPDoc on all functions with `@since`, `@param`, `@return` + +### Function Prefix + +All functions use the `pmprodon_` prefix: +```php +pmprodon_get_level_settings() +pmprodon_pmpro_checkout_level() +pmprodon_is_donations_only() +``` + +### Hook Naming + +When hooking into PMPro, the function name mirrors the hook: +```php +// Hook: pmpro_after_checkout → Function: pmprodon_pmpro_after_checkout +add_action( 'pmpro_after_checkout', 'pmprodon_pmpro_after_checkout' ); +``` + +### No External Dependencies + +The plugin has zero composer or npm dependencies. No build step. PHP only. + +## Gateway Considerations + +Different payment gateways behave differently with donations: + +| Gateway | Behavior | Special Handling | +|---------|----------|-----------------| +| **Stripe** | Works normally — donation added to total | None | +| **PayPal Express** | Donation lost on redirect return | `pmprodon_ppe_add_donation_to_request()` restores from order meta | +| **Pay by Check** | Known bug: donation amount not saved | Issue #86 — needs fix | +| **PayPal Standard** | No billing fields needed | JS hides billing fieldset | + +Gateways that don't require billing info (handled in checkout JS): +```javascript +var no_billing_gateways = ['paypalexpress', 'twocheckout', 'check', 'paypalstandard']; +``` + +## Key Issue Patterns + +Common areas where bugs appear: + +1. **Donation-only levels + level groups** — The interaction between donation-only levels and PMPro's level group system (which controls how many levels a user can hold) is complex and fragile. +2. **Gateway-specific behavior** — Each payment gateway handles the checkout flow differently, especially around redirects (PayPal) and free levels. +3. **Price display vs. actual charge** — The plugin modifies `$level->initial_payment` at priority 99 but needs to show the original price in the cost text, requiring careful hook/unhook timing. +4. **Data format consistency** — Donation amounts should always be stored as clean numeric values. Watch for whitespace and string formatting issues. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..503f352 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,381 @@ +# PMPro Donations — Roadmap (v2.3) + +> Target version: **2.3** +> Branch: `dev-2.3` (create from `dev`) +> Last updated: 2026-02-23 + +This plan covers all open issues, ideas meeting items, and open PRs. Work is organized into phases. Each ticket includes acceptance criteria for HomurAI ingestion. + +--- + +## Phase 0: PR Review & Merge + +Merge existing open PRs into `dev-2.3` after review. These have been open since early-mid 2025 with no review comments. + +### TICKET-001: Merge PR #90 — PHP 8.5 deprecation fix +- **Type**: Bug Fix +- **PR**: #90 by @dwanjuki (2026-01-09) +- **Branch**: `fix-php-8.5-notices` → `dev` +- **Changes**: Replace `(double)` casts with `(float)` in `checkout.php` (3 occurrences) +- **Risk**: Low — trivial cast rename, no behavior change +- **Action**: Merge as-is after confirming no conflicts with `dev` +- **Acceptance Criteria**: + - [ ] No PHP deprecation notices on PHP 8.5+ + - [ ] All `(double)` casts replaced with `(float)` throughout the plugin + - [ ] Checkout validation still works (min/max amounts, price formatting) + +### TICKET-002: Merge PR #84 — Payment gateways for free donation levels +- **Type**: Bug Fix +- **PR**: #84 by @ipokkel (2025-04-16) +- **Resolves**: Issue #81 (PayPal Express not shown for free levels with donations) +- **Branch**: `payments-free-level` → `dev` +- **Changes**: + - New function `pmprodon_enable_payments_for_free_level_donations()` hooked to `pmpro_is_level_free` + - If a free level has donations enabled (with min_price > 0 or valid dropdown values), returns `false` so gateways render + - Adds validation: if donation is required on free level, donation must be > 0 +- **Review Notes**: + - Logic is sound but the edge case where `dropdown_prices` contains only "other" needs testing — should still allow $0 donation in that case + - The additional validation (`intval($level->initial_payment) === 0 && donation <= 0`) should only apply when `min_price > 0`, otherwise users should be able to donate $0 on a free level +- **Acceptance Criteria**: + - [ ] PayPal Express and other gateways appear on checkout for free levels with donations enabled + - [ ] Billing fields shown when donation amount > 0 + - [ ] Free level with donations but no min_price still allows $0 checkout + - [ ] Free level with min_price > 0 requires a valid donation amount + +### TICKET-003: Merge PR #85 — Restore level after donation-only checkout +- **Type**: Bug Fix +- **PR**: #85 by @ipokkel (2025-04-18) +- **Resolves**: Issue #72 (member level changes to donation level), Issue #91 (users keep donation level) +- **Branch**: `keep-level` → `dev` +- **Changes**: Complete rewrite of `donation-only-level.php` + - Stores user's previous levels in user meta before checkout + - After checkout, finds which level was in the same group as donation level + - Cancels donation-only level via `pmpro_cancelMembershipLevel()` + - Restores original level via `pmpro_changeMembershipLevel()` with full level data +- **Review Notes**: + - The old approach (DELETE SQL query) was fragile. New approach using PMPro API functions is much better. + - Need to verify: does `pmpro_cancelMembershipLevel()` trigger unwanted side effects (emails, hooks)? + - The stored `pmprodon_previous_levels` user meta should be cleaned up even on failure paths + - Test with: single level group, multiple level groups, no level groups, recurring subscription levels +- **Acceptance Criteria**: + - [ ] User with Gold membership donates via donation-only level → keeps Gold membership + - [ ] Donation-only level in same level group as user's level → user keeps original level + - [ ] Donation-only level in separate level group → user keeps original level + - [ ] Recurring subscription not cancelled at gateway after donation-only checkout + - [ ] No orphaned `pmprodon_previous_levels` user meta after checkout + +### TICKET-004: Review PR #83 — Donations report page +- **Type**: Feature +- **PR**: #83 by @vbuster01 (2025-04-04) +- **Branch**: `dev` → `dev` (⚠️ same branch name — needs rebase) +- **Changes**: New file `pmpro-donations-report.php` — adds a donations report to PMPro Reports dashboard with filtering and CSV export +- **Review Notes — Issues to Fix Before Merge**: + - File should be in `includes/` directory, not root (follow existing structure) + - Functions use `MY_pmpro_` prefix — must be renamed to `pmprodon_` prefix per conventions + - Missing `$wpdb->prepare()` on some query parts — security review needed + - Uses `date()` instead of `wp_date()` or `gmdate()` — WordPress standards + - CSV export uses `$_GET` without nonce verification — needs `wp_verify_nonce()` + - File is not `require_once`'d from main plugin file — needs integration + - Report should use PMPro's existing report page patterns and styles + - Hard-coded `'pmpro'` text domain should be `'pmpro-donations'` +- **Acceptance Criteria**: + - [ ] Report widget appears on PMPro Reports dashboard + - [ ] Report page shows total donations with month/year filtering + - [ ] Individual donations listed with member details + - [ ] CSV export works with proper nonce verification + - [ ] All functions use `pmprodon_` prefix + - [ ] File located in `includes/reports.php` + - [ ] Proper escaping and sanitization on all output and input + - [ ] Text domain is `pmpro-donations` throughout + +--- + +## Phase 1: Bug Fixes + +### TICKET-005: Fix donation amount not saved with Pay by Check gateway +- **Type**: Bug Fix +- **Issue**: #86 +- **Priority**: High +- **Description**: When using Pay by Check gateway, donation amount is stored as 0 in order meta. Works correctly with Stripe and other gateways. +- **Root Cause Investigation**: The `pmprodon_store_donation_amount_in_order_meta()` function hooks `pmpro_after_checkout` and reads from `$_REQUEST['donation']`. Pay by Check may handle the checkout flow differently, clearing `$_REQUEST` before this hook fires. Compare the checkout flow for PBC vs Stripe to find where the donation value is lost. +- **Files**: `includes/checkout.php` (function `pmprodon_store_donation_amount_in_order_meta`) +- **Acceptance Criteria**: + - [ ] Donation amount correctly saved to order meta when using Pay by Check gateway + - [ ] Donation amount appears in "Additional Order Information" on admin order page + - [ ] Invoice shows correct donation breakdown + - [ ] No regression with Stripe, PayPal Express, or other gateways + +### TICKET-006: Sanitize donation amounts — trim whitespace +- **Type**: Bug Fix +- **Issue**: #89 +- **Priority**: Medium +- **Description**: Donation amounts with trailing whitespace (e.g., "10 ") are stored as strings with the space, causing them to be excluded from reports and numeric queries. +- **Fix**: Add `trim()` before storing donation amount. Apply in all storage paths: + 1. `pmprodon_store_donation_amount_in_order_meta()` in checkout.php + 2. `pmprodon_pmpro_updated_order()` in admin.php (admin order editing) + 3. `pmprodon_pmpro_checkout_level()` where donation is read from `$_REQUEST` +- **Files**: `includes/checkout.php`, `includes/admin.php` +- **Acceptance Criteria**: + - [ ] Donation amounts are trimmed before storage (no leading/trailing whitespace) + - [ ] Existing code's `preg_replace('/[^0-9\.]/', ...)` sanitization also strips whitespace — verify this catches the issue or add explicit `trim()` + - [ ] Reports correctly count donations after fix + - [ ] Admin-entered donation amounts also trimmed + +### TICKET-007: Fix donation-only level replacing user's membership +- **Type**: Bug Fix +- **Issues**: #72, #91 +- **Priority**: High +- **Description**: When a user with an existing membership checks out for a donation-only level in a level group, they end up keeping the donation level instead of their original. Two related reports: #72 (level changes to donation level) and #91 (users keep both levels). +- **Fix**: PR #85 addresses this. See TICKET-003 for merge plan. +- **Acceptance Criteria**: Same as TICKET-003 + +--- + +## Phase 2: Enhancements (from Issues) + +### TICKET-008: Donation-specific confirmation email and page +- **Type**: Enhancement +- **Issue**: #62 +- **Priority**: Medium +- **Description**: Checkout confirmation page and emails reference "membership" even for donation-only levels. Should reference "donation" instead. +- **Implementation**: + - Filter `pmpro_confirmation_message` to replace membership language with donation language when checkout is for a donation-only level + - Add a new email template (or filter existing checkout emails) for donation-only checkouts + - Filter `pmpro_email_data` to provide donation-specific subject lines and body content + - Use the existing `confirmation_message` level setting as a starting point +- **Files**: `includes/checkout.php`, `includes/donation-only-level.php` +- **Acceptance Criteria**: + - [ ] Confirmation page says "Thank you for your donation" instead of "your membership is now active" for donation-only levels + - [ ] Checkout email uses donation-appropriate language for donation-only levels + - [ ] Regular donation levels (donation + membership) still show standard membership messaging + - [ ] Site owner can customize the donation confirmation message per level (already exists) + +### TICKET-009: Donor note field at checkout +- **Type**: Feature +- **Issue**: #82 +- **Priority**: Medium +- **Description**: Add an optional "Donation Note" text field at checkout that appears on the invoice and in emails. Common use: "In memory of..." or "For the purpose of..." +- **Implementation**: + - Add new level setting: `donation_note_enabled` (checkbox, optional per level) + - Add new level setting: `donation_note_label` (custom label text, default "Donation Note") + - Render textarea below donation amount field at checkout + - Store note in order meta: key `donation_note` + - Add email variable: `!!donation_note!!` + - Display on invoice bullets and confirmation page +- **Files**: `includes/level-settings.php`, `includes/checkout.php`, `includes/admin.php` +- **Acceptance Criteria**: + - [ ] Optional per-level setting to enable donation note field + - [ ] Text field appears at checkout below donation amount when enabled + - [ ] Note stored in order meta + - [ ] Note appears on order invoice page + - [ ] `!!donation_note!!` email variable works in templates + - [ ] Note visible and editable in admin order edit page + - [ ] Note included in CSV export + +### TICKET-010: Admin donation field when adding orders +- **Type**: Enhancement +- **Issue**: #5 +- **Priority**: Low +- **Description**: When admin creates an order via "Add New Order" or adds a member via "Add Member", there's no field to specify a donation amount. Currently only works through front-end checkout with payment gateways. +- **Implementation**: + - Hook into `pmpro_after_order_settings` on the add-new-order admin page (may already exist for editing — check `admin.php`) + - Show donation amount field when the selected level has donations enabled + - Save donation to order meta on order creation +- **Files**: `includes/admin.php` +- **Acceptance Criteria**: + - [ ] Donation amount field appears when creating new orders in admin + - [ ] Field only shows for levels with donations enabled + - [ ] Donation amount saved to order meta + - [ ] Works with "Add Member" admin page if applicable + +--- + +## Phase 3: Ideas Meeting Features + +### TICKET-011: Big and beautiful donation option buttons +- **Type**: Feature (UI overhaul) +- **Source**: Ideas meeting (Jason) +- **Priority**: High +- **Description**: Replace the plain dropdown with visually appealing donation amount buttons at checkout. Think charity website donation forms — large, clickable amount tiles. +- **Implementation**: + - New display mode when `dropdown_prices` are set: render as button grid instead of ` -

- - - - - + + + + + + + 0 ) { ?> + + + + + + + + + + +
+ +

+
+

+

+
+ +

+
+ + + id, 'donation_amount', $float_amount ); + } + + if ( isset( $_REQUEST['donation_note'] ) ) { + $donation_note = sanitize_textarea_field( $_REQUEST['donation_note'] ); + update_pmpro_membership_order_meta( $order->id, 'donation_note', $donation_note ); + } +} + +add_action( 'pmpro_updated_order', 'pmprodon_save_donation_amount', 10, 1 ); + +/** + * Get an array of level IDs that have donations enabled. + * + * Used by admin JS to determine which levels should show + * the donation fields on the order and Add Member pages. + * + * @since 2.3 + * + * @return int[] Array of level IDs with donations enabled. + */ +function pmprodon_get_donation_enabled_level_ids() { + $donation_level_ids = array(); + + if ( ! function_exists( 'pmpro_getAllLevels' ) ) { + return $donation_level_ids; + } + + $levels = pmpro_getAllLevels( true, true ); + if ( ! empty( $levels ) ) { + foreach ( $levels as $level ) { + $settings = pmprodon_get_level_settings( $level->id ); + if ( ! empty( $settings['donations'] ) ) { + $donation_level_ids[] = (int) $level->id; + } + } + } + + return $donation_level_ids; +} + +/** + * Display donation fields on the Add Member admin page. + * + * Hooked to 'pmpro_add_member_fields' which fires on the + * Memberships > Add Member admin page after the core fields. + * + * @since 2.3 + * + * @return void + */ +function pmprodon_pmpro_add_member_fields() { + $donation_level_ids = pmprodon_get_donation_enabled_level_ids(); + ?> +
+ + + + + + + + + + + +
+ +

+
+ +

+
+
+ + Add Member admin page. + * + * @since 2.3 + * + * @param int $user_id The user ID. + * @param object $order The order object created for the new member. + * @return void + */ +function pmprodon_pmpro_add_member_added( $user_id, $order ) { + if ( empty( $order ) || empty( $order->id ) ) { + return; + } + + if ( isset( $_REQUEST['donation_amount'] ) ) { + $raw_amount = trim( sanitize_text_field( $_REQUEST['donation_amount'] ) ); + $float_amount = is_numeric( $raw_amount ) ? floatval( $raw_amount ) : ''; update_pmpro_membership_order_meta( $order->id, 'donation_amount', $float_amount ); } + + if ( isset( $_REQUEST['donation_note'] ) ) { + $donation_note = sanitize_textarea_field( $_REQUEST['donation_note'] ); + update_pmpro_membership_order_meta( $order->id, 'donation_note', $donation_note ); + } } -add_action( 'pmpro_updated_order', 'pmprodon_save_donation_amount', 10, 1 ); \ No newline at end of file +add_action( 'pmpro_add_member_added', 'pmprodon_pmpro_add_member_added', 10, 2 ); \ No newline at end of file diff --git a/includes/checkout.php b/includes/checkout.php index 9b86b92..9636a95 100644 --- a/includes/checkout.php +++ b/includes/checkout.php @@ -18,6 +18,114 @@ function pmprodon_init_dropdown_values() { } add_action( 'pmpro_checkout_preheader_before_get_level_at_checkout', 'pmprodon_init_dropdown_values', 1 ); +/** + * Render the guest/account checkout toggle for donation-only levels + * that allow guest donations. + * + * Only shown to non-logged-in users on levels with both donations_only + * and allow_guest_donations enabled. Logged-in users see the standard + * checkout flow. + * + * @since 2.3 + */ +function pmprodon_render_guest_checkout_toggle() { + global $pmpro_level; + + // Only show for non-logged-in users. + if ( is_user_logged_in() ) { + return; + } + + // Only show for donation-only levels with guest donations enabled. + if ( empty( $pmpro_level->id ) ) { + return; + } + + $settings = pmprodon_get_level_settings( $pmpro_level->id ); + if ( empty( $settings['donations'] ) || empty( $settings['donations_only'] ) || empty( $settings['allow_guest_donations'] ) ) { + return; + } + + // Block guest checkout for recurring levels. + if ( ! empty( $pmpro_level->billing_amount ) && (float) $pmpro_level->billing_amount > 0 ) { + return; + } + + $guest_selected = ! empty( $_REQUEST['pmprodon_guest'] ) && $_REQUEST['pmprodon_guest'] === '1'; + ?> +
+
+
+ +

+
+
+
+ +
+ +
+
+
+
+
+ + ">
- disabled="disabled" class="pmprodon_button_input" /> + + + + + + +
- + } else { + // Classic dropdown mode. + ?> + - + + + + + + style="display: none;"> @@ -117,6 +255,52 @@ function pmprodon_pmpro_checkout_after_user_fields() { } ?> + id ); + if ( ! empty( $donfields_full['donation_note_enabled'] ) ) { + $donation_note_label = ! empty( $donfields_full['donation_note_label'] ) ? $donfields_full['donation_note_label'] : __( 'Donation Note', 'pmpro-donations' ); + $donation_note_value = isset( $_REQUEST['donation_note'] ) ? sanitize_textarea_field( $_REQUEST['donation_note'] ) : ''; + ?> +
+ + + + + +
+ +
+ + + + +
+ @@ -126,18 +310,40 @@ function pmprodon_pmpro_checkout_after_user_fields() { var pmpro_gateway_billing = ; var pmpro_pricing_billing = ; var pmpro_donation_billing = pmpro_pricing_billing; + var pmprodon_display_mode = ''; + var pmprodon_cover_fees_enabled = ; + var pmprodon_cover_fees_percentage = ; + var pmprodon_cover_fees_flat = ; //this script will hide show billing fields based on the price set jQuery(document).ready(function() { - //bind other field toggle to dropdown change - jQuery('#donation_dropdown').change(function() { - pmprodon_toggleOther(); - // If we changed to a non-other value, update the donation field. - if ( jQuery( '#donation_dropdown' ).val() !== 'other' ) { - jQuery( '#donation' ).val( jQuery( '#donation_dropdown' ).val() ); - } - pmprodon_checkForFree(); - }); + if ( pmprodon_display_mode === 'buttons' ) { + // Button mode: handle radio button changes. + jQuery('.pmprodon_button_input').on('change', function() { + var $label = jQuery(this).closest('.pmprodon_button'); + // Remove active class from all buttons. + jQuery('.pmprodon_button').removeClass('pmprodon_button--active'); + // Add active class to selected button. + $label.addClass('pmprodon_button--active'); + // Toggle the other input. + pmprodon_toggleOther(); + // If not 'other', update the donation field with the selected value. + if ( jQuery(this).val() !== 'other' ) { + jQuery('#donation').val( jQuery(this).val() ); + } + pmprodon_checkForFree(); + }); + } else { + // Dropdown mode: bind to select change. + jQuery('#donation_dropdown').change(function() { + pmprodon_toggleOther(); + // If we changed to a non-other value, update the donation field. + if ( jQuery( '#donation_dropdown' ).val() !== 'other' ) { + jQuery( '#donation' ).val( jQuery( '#donation_dropdown' ).val() ); + } + pmprodon_checkForFree(); + }); + } //bind check to price field var pmprodon_price_timer; @@ -152,23 +358,39 @@ function pmprodon_pmpro_checkout_after_user_fields() { }); } + //update cover fees when checkbox is toggled + jQuery('#cover_fees').on('change', function() { + pmprodon_updateCoverFees(); + }); + //check when page loads too pmprodon_toggleOther(); pmprodon_checkForFree(); }); function pmprodon_toggleOther() { - //make sure there is a dropdown to check - if(!jQuery('#donation_dropdown').length) - return; + var donation_dropdown_val; - //get val - var donation_dropdown = jQuery('#donation_dropdown').val(); + if ( pmprodon_display_mode === 'buttons' ) { + // In button mode, get the checked radio value. + var $checked = jQuery('.pmprodon_button_input:checked'); + if ( ! $checked.length ) { + return; + } + donation_dropdown_val = $checked.val(); + } else { + // In dropdown mode, get the select value. + if ( ! jQuery('#donation_dropdown').length ) { + return; + } + donation_dropdown_val = jQuery('#donation_dropdown').val(); + } - if(donation_dropdown == 'other') + if ( donation_dropdown_val == 'other' ) { jQuery('#pmprodon_donation_input').show(); - else + } else { jQuery('#pmprodon_donation_input').hide(); + } } function pmprodon_checkForFree() { @@ -200,19 +422,107 @@ function pmprodon_checkForFree() { jQuery('#pmpro_payment_information_fields').hide(); pmpro_require_billing = false; } + + // Update cover fees when donation changes. + pmprodon_updateCoverFees(); + } + + function pmprodon_updateCoverFees() { + if ( ! pmprodon_cover_fees_enabled ) { + return; + } + var $text = jQuery('#pmprodon_cover_fees_text'); + if ( ! $text.length ) { + return; + } + var donation = parseFloat(jQuery('#donation').val()) || 0; + var fee = 0; + if ( donation > 0 && pmprodon_cover_fees_percentage < 100 ) { + fee = ( donation + pmprodon_cover_fees_flat ) / ( 1 - pmprodon_cover_fees_percentage / 100 ) - donation; + fee = Math.round( fee * 100 ) / 100; + } + // Format fee for display. Use the currency symbol from the page. + var feeFormatted = fee.toFixed(2); + + var currencySymbol = ''; + var currencyPosition = ''; + var priceStr; + if ( currencyPosition === 'right' ) { + priceStr = feeFormatted + currencySymbol; + } else { + priceStr = currencySymbol + feeFormatted; + } + /* translators: %s: the calculated fee amount */ + var template = ''; + $text.text( template.replace( '{FEE}', priceStr ) ); } 0 or + * dropdown_prices containing numeric values > 0, mark the level + * as non-free so that payment gateways render on checkout. + * + * @since 2.3 + * + * @param bool $is_free Whether the level is free. + * @param object $level The level object being checked. + * @return bool Whether the level should be treated as free. + */ +function pmprodon_enable_payments_for_free_level_donations( $is_free, $level ) { + // Only act on free levels during checkout. + if ( ! $is_free || ! pmpro_is_checkout() ) { + return $is_free; + } + + // Get donation settings for this level. + $donfields = pmprodon_get_level_settings( $level->id ); + + // If donations are not enabled, leave as-is. + if ( empty( $donfields['donations'] ) ) { + return $is_free; + } + + // Check if min_price requires a donation. + if ( ! empty( $donfields['min_price'] ) && (float) $donfields['min_price'] > 0 ) { + return false; + } + + // Check if dropdown_prices contain numeric values > 0. + if ( ! empty( $donfields['dropdown_prices'] ) ) { + $prices = str_replace( ' ', '', $donfields['dropdown_prices'] ); + $prices = explode( ',', $prices ); + $has_numeric = false; + foreach ( $prices as $price ) { + if ( $price !== 'other' && (float) $price > 0 ) { + $has_numeric = true; + break; + } + } + if ( $has_numeric ) { + return false; + } + } + + return $is_free; +} +add_filter( 'pmpro_is_level_free', 'pmprodon_enable_payments_for_free_level_donations', 10, 2 ); + /** * Set price at checkout */ function pmprodon_pmpro_checkout_level( $level ) { if ( isset( $_REQUEST['donation'] ) ) { - $donation = sanitize_text_field( preg_replace( '/[^0-9\.]/', '', $_REQUEST['donation'] ) ); + $donation = sanitize_text_field( preg_replace( '/[^0-9\.]/', '', trim( $_REQUEST['donation'] ) ) ); } else { return $level; } @@ -226,6 +536,35 @@ function pmprodon_pmpro_checkout_level( $level ) { $level->initial_payment = $level->initial_payment + $donation; } + // Store the sanitized donation amount in a global so it is available + // later in the checkout flow, even if $_REQUEST is cleared by the gateway + // (e.g., Pay by Check). This fires at pmpro_checkout_level priority 99, + // before pmpro_after_checkout where the value is saved to order meta. + global $pmprodon_donation_amount; + $pmprodon_donation_amount = $donation; + + // Calculate and add cover fee if the checkbox is checked. + global $pmprodon_cover_fee_amount; + $pmprodon_cover_fee_amount = 0; + if ( ! empty( $_REQUEST['cover_fees'] ) ) { + $donfields_settings = pmprodon_get_level_settings( $level->id ); + if ( ! empty( $donfields_settings['cover_fees_enabled'] ) ) { + $fee_percentage = (float) $donfields_settings['cover_fees_percentage']; + $fee_flat = (float) $donfields_settings['cover_fees_flat']; + $cover_fee = pmprodon_calculate_cover_fee( (float) $donation, $fee_percentage, $fee_flat ); + if ( $cover_fee > 0 ) { + $level->initial_payment = $level->initial_payment + $cover_fee; + $pmprodon_cover_fee_amount = $cover_fee; + } + } + } + + // Store the donation note in a global for the same reason. + global $pmprodon_donation_note; + if ( isset( $_REQUEST['donation_note'] ) ) { + $pmprodon_donation_note = sanitize_textarea_field( $_REQUEST['donation_note'] ); + } + return $level; } add_filter( 'pmpro_checkout_level', 'pmprodon_pmpro_checkout_level', 99 ); @@ -254,17 +593,24 @@ function pmprodon_pmpro_registration_checks( $continue ) { $donation = sanitize_text_field( preg_replace( '/[^0-9\.]/', '', $_REQUEST['donation'] ) ); // check that the donation falls between the min and max - if ( (double) $donation < 0 || ( ! empty( $donfields['min_price'] ) && (double) $donation < (double) $donfields['min_price'] ) ) { + if ( (float) $donation < 0 || ( ! empty( $donfields['min_price'] ) && (float) $donation < (float) $donfields['min_price'] ) ) { $pmpro_msg = sprintf( __( 'The lowest accepted donation is %s. Please enter a new amount.', 'pmpro-donations' ), pmpro_formatPrice( $donfields['min_price'] ) ); $pmpro_msgt = 'pmpro_error'; $continue = false; - } elseif ( ! empty( $donfields['max_price'] ) && (double) $donation > (double) $donfields['max_price'] ) { + } elseif ( ! empty( $donfields['max_price'] ) && (float) $donation > (float) $donfields['max_price'] ) { $pmpro_msg = sprintf( __( 'The highest accepted donation is %s. Please enter a new amount.', 'pmpro-donations' ), pmpro_formatPrice( $donfields['max_price'] ) ); $pmpro_msgt = 'pmpro_error'; $continue = false; } + // Free levels with min_price > 0 require a donation amount. + if ( $continue && ! empty( $donfields['min_price'] ) && (float) $donfields['min_price'] > 0 && intval( $level->initial_payment ) === 0 && (float) $donation <= 0 ) { + $pmpro_msg = sprintf( __( 'A donation of at least %s is required. Please enter a donation amount.', 'pmpro-donations' ), pmpro_formatPrice( $donfields['min_price'] ) ); + $pmpro_msgt = 'pmpro_error'; + $continue = false; + } + // all good! } } @@ -273,6 +619,88 @@ function pmprodon_pmpro_registration_checks( $continue ) { } add_filter( 'pmpro_registration_checks', 'pmprodon_pmpro_registration_checks' ); +/** + * Filter checkout page text for donation-only levels. + * + * Replaces membership-oriented language with donation-appropriate language + * when the current checkout level is donation-only. This filter is hooked + * on pmpro_checkout_before_form and unhooked on pmpro_checkout_after_form + * to limit its scope to the checkout form rendering only. + * + * @since 2.3 + * + * @param string $translated_text The translated text. + * @param string $text The original (untranslated) text. + * @param string $domain The text domain. + * @return string The modified or original text. + */ +function pmprodon_filter_donation_only_checkout_text( $translated_text, $text, $domain ) { + // Only filter PMPro core strings. + if ( $domain !== 'paid-memberships-pro' ) { + return $translated_text; + } + + global $pmpro_level; + + // Check if this is a donation-only level. + if ( empty( $pmpro_level ) || ! pmprodon_is_donations_only( $pmpro_level->id ) ) { + return $translated_text; + } + + // Map of PMPro core strings to donation-appropriate replacements. + switch ( $text ) { + case 'Membership Level': + return __( 'Donation', 'pmpro-donations' ); + + case 'You have selected the %s membership level.': + /* translators: %s: the level name */ + return __( 'You are making a donation.', 'pmpro-donations' ); + + case 'Submit and Check Out': + return __( 'Donate Now', 'pmpro-donations' ); + + case 'Submit and Confirm': + return __( 'Donate Now', 'pmpro-donations' ); + + case 'The price for membership is %s now': + return ''; + + case 'The price for membership is %s now and then %s per %s for %d more %s.': + return ''; + + case 'Membership expires after %d %s.': + return ''; + + case 'Change': + return ''; + } + + return $translated_text; +} + +/** + * Hook the donation-only checkout text filter before the checkout form. + * + * @since 2.3 + */ +function pmprodon_hook_donation_only_checkout_text() { + global $pmpro_level; + if ( ! empty( $pmpro_level ) && pmprodon_is_donations_only( $pmpro_level->id ) ) { + add_filter( 'gettext', 'pmprodon_filter_donation_only_checkout_text', 10, 3 ); + } +} +add_action( 'pmpro_checkout_before_form', 'pmprodon_hook_donation_only_checkout_text' ); + +/** + * Unhook the donation-only checkout text filter after the checkout form. + * + * @since 2.3 + */ +function pmprodon_unhook_donation_only_checkout_text() { + remove_filter( 'gettext', 'pmprodon_filter_donation_only_checkout_text', 10, 3 ); +} +add_action( 'pmpro_checkout_after_form', 'pmprodon_unhook_donation_only_checkout_text' ); + /** * Override level cost text on checkout page */ @@ -337,6 +765,19 @@ function pmprodon_pmpro_invoice_bullets_bottom( $order ) { 'membership_cost' => '' . __( 'Membership Cost', 'pmpro-donations' ) . ": " . pmpro_formatPrice( $components['price'] ), 'donation' => '' . __( 'Donation', 'pmpro-donations' ) . ": " . pmpro_formatPrice( $components['donation'] ) ); + + // Add cover fee if present. + $fee_covered = get_pmpro_membership_order_meta( $order->id, 'donation_fee_covered', true ); + if ( ! empty( $fee_covered ) && (float) $fee_covered > 0 ) { + $bullets['fee_covered'] = '' . esc_html__( 'Processing Fee Covered', 'pmpro-donations' ) . ': ' . pmpro_formatPrice( $fee_covered ); + } + + // Add donation note if present. + $donation_note = get_pmpro_membership_order_meta( $order->id, 'donation_note', true ); + if ( ! empty( $donation_note ) ) { + $bullets['donation_note'] = '' . esc_html__( 'Donation Note', 'pmpro-donations' ) . ': ' . esc_html( $donation_note ); + } + $bullets = apply_filters( 'pmpro_donations_invoice_bullets', $bullets, $order ); foreach ( $bullets as $bullet ) { echo '
  • ' . wp_kses_post( $bullet ) . '
  • '; @@ -345,6 +786,20 @@ function pmprodon_pmpro_invoice_bullets_bottom( $order ) { } add_filter( 'pmpro_invoice_bullets_bottom', 'pmprodon_pmpro_invoice_bullets_bottom' ); +/** + * Add donation-related email template variables. + * + * Provides !!donation!!, !!donation_note!!, and !!donation_fee_covered!! + * email template variables. For donation-only levels, also overrides the + * email subject to use donation-appropriate language. + * + * @since 2.0 + * @since 2.3 Override email subject for donation-only level checkouts. + * + * @param array $data The email template data. + * @param object $email The email object. + * @return array The modified email template data. + */ function pmprodon_pmpro_email_data( $data, $email ) { $order_id = empty( $email->data['order_id'] ) ? false : $email->data['order_id']; if ( ! empty( $order_id ) ) { @@ -352,9 +807,31 @@ function pmprodon_pmpro_email_data( $data, $email ) { $components = pmprodon_get_price_components( $order ); if ( ! empty( $components['donation'] ) ) { - $data['donation'] = pmpro_formatPrice( $components['donation'] ); + $data['donation'] = pmpro_formatPrice( $components['donation'] ); } else { - $data['donation'] = pmpro_formatPrice( 0 ); + $data['donation'] = pmpro_formatPrice( 0 ); + } + + // Add donation note email variable. + $donation_note = get_pmpro_membership_order_meta( $order->id, 'donation_note', true ); + $data['donation_note'] = ! empty( $donation_note ) ? esc_html( $donation_note ) : ''; + + // Add cover fee email variable. + $fee_covered = get_pmpro_membership_order_meta( $order->id, 'donation_fee_covered', true ); + $data['donation_fee_covered'] = ! empty( $fee_covered ) && (float) $fee_covered > 0 ? pmpro_formatPrice( $fee_covered ) : ''; + + // Override subject for donation-only level checkout emails. + if ( strpos( $email->template, 'checkout' ) !== false && ! empty( $order->membership_id ) && pmprodon_is_donations_only( $order->membership_id ) ) { + if ( strpos( $email->template, 'admin' ) !== false ) { + $data['subject'] = sprintf( + /* translators: %s: the site name */ + __( 'Donation received at %s', 'pmpro-donations' ), + get_bloginfo( 'name' ) + ); + } else { + $data['subject'] = __( 'Thank you for your donation', 'pmpro-donations' ); + } + $email->subject = $data['subject']; } } return $data; @@ -362,26 +839,97 @@ function pmprodon_pmpro_email_data( $data, $email ) { add_filter( 'pmpro_email_data', 'pmprodon_pmpro_email_data', 10, 2 ); /** - * Show order components in confirmation email. + * Filter checkout confirmation emails for donation levels. + * + * For all donation levels: injects donation amount, cover fee, and + * donation note before the Invoice section (when not using template variables). + * + * For donation-only levels: replaces membership language in the email body + * with donation-appropriate language (e.g. "membership" → "donation", + * "your membership account is now active" → "your donation has been received"). + * + * When a level has an email_template_override setting, the email template + * name is swapped before any other processing. Admin emails get the + * override name with '_admin' appended. This is compatible with the + * pmpro-email-templates add-on for full template editing. + * + * @since 2.0 + * @since 2.3 Replace membership language for donation-only level emails. + * @since 2.3 Add per-level email template override support. + * + * @param object $email The email object. + * @return object The modified email object. */ function pmprodon_pmpro_email_filter( $email ) { - global $wpdb; + // Only update confirmation emails. + if ( strpos( $email->template, 'checkout' ) === false ) { + return $email; + } - // only update confirmation emails which aren't using !!donation!! email variable - if ( strpos( $email->template, 'checkout' ) !== false && strpos( $email->body, '!!donation!!' ) === false ) { - // get the user_id from the email - $order_id = ( empty( $email->data ) || empty( $email->data['order_id'] ) ) ? false : $email->data['order_id']; - if ( ! empty( $order_id ) ) { - $order = new MemberOrder( $order_id ); - $components = pmprodon_get_price_components( $order ); + $order_id = ( empty( $email->data ) || empty( $email->data['order_id'] ) ) ? false : $email->data['order_id']; + if ( empty( $order_id ) ) { + return $email; + } - // add to bottom of email - if ( ! empty( $components['donation'] ) ) { - $email->body = preg_replace( '/\\s*' . __( 'Invoice', 'pmpro-donations' ) . '/', '

    ' . __( 'Donation Amount:', 'pmpro-donations' ) . '' . pmpro_formatPrice( $components['donation'] ) . '

    ' . __( 'Invoice', 'pmpro-donations' ), $email->body ); + $order = new MemberOrder( $order_id ); + + // Swap the email template if the level has a custom override. + if ( ! empty( $order->membership_id ) ) { + $level_settings = pmprodon_get_level_settings( $order->membership_id ); + if ( ! empty( $level_settings['email_template_override'] ) ) { + $override = sanitize_text_field( $level_settings['email_template_override'] ); + if ( strpos( $email->template, 'admin' ) !== false ) { + $email->template = $override . '_admin'; + } else { + $email->template = $override; } } } + $components = pmprodon_get_price_components( $order ); + + // Inject donation info before Invoice section (when not using !!donation!! variable). + if ( ! empty( $components['donation'] ) && strpos( $email->body, '!!donation!!' ) === false ) { + $donation_info = __( 'Donation Amount:', 'pmpro-donations' ) . ' ' . pmpro_formatPrice( $components['donation'] ); + + // Append cover fee if present and not already using !!donation_fee_covered!! variable. + if ( strpos( $email->body, '!!donation_fee_covered!!' ) === false ) { + $fee_covered = get_pmpro_membership_order_meta( $order->id, 'donation_fee_covered', true ); + if ( ! empty( $fee_covered ) && (float) $fee_covered > 0 ) { + $donation_info .= '

    ' . esc_html__( 'Processing Fee Covered:', 'pmpro-donations' ) . ' ' . pmpro_formatPrice( $fee_covered ); + } + } + + // Append donation note if present and not already using !!donation_note!! variable. + if ( strpos( $email->body, '!!donation_note!!' ) === false ) { + $donation_note = get_pmpro_membership_order_meta( $order->id, 'donation_note', true ); + if ( ! empty( $donation_note ) ) { + $donation_info .= '

    ' . esc_html__( 'Donation Note:', 'pmpro-donations' ) . ' ' . esc_html( $donation_note ); + } + } + + $email->body = preg_replace( '/\\s*' . __( 'Invoice', 'pmpro-donations' ) . '/', '

    ' . $donation_info . '

    ' . __( 'Invoice', 'pmpro-donations' ), $email->body ); + } + + // Replace membership language with donation language for donation-only levels. + if ( ! empty( $order->membership_id ) && pmprodon_is_donations_only( $order->membership_id ) ) { + // Replace common membership phrases with donation equivalents. + // Use __() on search keys so replacements work on translated PMPro sites. + $replacements = array( + __( 'Your membership account is now active.', 'paid-memberships-pro' ) => __( 'Your donation has been received.', 'pmpro-donations' ), + __( 'Thank you for your membership', 'paid-memberships-pro' ) => __( 'Thank you for your donation', 'pmpro-donations' ), + __( 'membership level has been changed', 'paid-memberships-pro' ) => __( 'donation has been processed', 'pmpro-donations' ), + __( 'has changed their membership level', 'paid-memberships-pro' ) => __( 'has made a donation', 'pmpro-donations' ), + __( 'your membership confirmation', 'paid-memberships-pro' ) => __( 'your donation confirmation', 'pmpro-donations' ), + __( 'Your membership confirmation', 'paid-memberships-pro' ) => __( 'Your donation confirmation', 'pmpro-donations' ), + __( 'Membership Level:', 'paid-memberships-pro' ) => __( 'Donation:', 'pmpro-donations' ), + ); + + foreach ( $replacements as $search => $replace ) { + $email->body = str_replace( $search, $replace, $email->body ); + } + } + return $email; } add_filter( 'pmpro_email_filter', 'pmprodon_pmpro_email_filter', 10, 2 ); @@ -454,64 +1002,577 @@ function pmprodon_ppe_add_donation_to_request() { if ( ! empty( $donation['donation'] ) && empty( $_REQUEST['donation'] ) ) { $_REQUEST['donation'] = $donation['donation']; } + + // Restore the donation note from order meta for PayPal Express. + $donation_note = get_pmpro_membership_order_meta( $order->id, 'donation_note', true ); + if ( ! empty( $donation_note ) && empty( $_REQUEST['donation_note'] ) ) { + $_REQUEST['donation_note'] = $donation_note; + } + + // Restore the cover fees flag from order meta for PayPal Express. + $fee_covered = get_pmpro_membership_order_meta( $order->id, 'donation_fee_covered', true ); + if ( ! empty( $fee_covered ) && (float) $fee_covered > 0 && empty( $_REQUEST['cover_fees'] ) ) { + $_REQUEST['cover_fees'] = '1'; + } } add_action( 'pmpro_checkout_preheader_before_get_level_at_checkout', 'pmprodon_ppe_add_donation_to_request' ); /** * Add donation amount to order meta. * + * Reads from the global $pmprodon_donation_amount set during + * pmprodon_pmpro_checkout_level() as the primary source. Falls back + * to $_REQUEST['donation'] for edge cases or non-standard checkout flows. + * * @since 2.0 + * @since 2.3 Use global donation amount to fix Pay by Check gateway (Issue #86). + * Apply sanitization to prevent whitespace-corrupted values (Issue #89). * - * @param int $user_id The user ID. - * @param object The order object. + * @param int $user_id The user ID. + * @param object $order The order object. */ function pmprodon_store_donation_amount_in_order_meta( $user_id, $order ) { - if ( isset( $_REQUEST['donation'] ) ) { - update_pmpro_membership_order_meta( $order->id, 'donation_amount', sanitize_text_field( $_REQUEST['donation'] ) ); + global $pmprodon_donation_amount; + + // Primary source: global set during pmpro_checkout_level (already sanitized). + if ( isset( $pmprodon_donation_amount ) ) { + $donation = $pmprodon_donation_amount; + } elseif ( isset( $_REQUEST['donation'] ) ) { + // Fallback: read from $_REQUEST with full sanitization. + $donation = sanitize_text_field( preg_replace( '/[^0-9\.]/', '', trim( $_REQUEST['donation'] ) ) ); + } else { + return; + } + + update_pmpro_membership_order_meta( $order->id, 'donation_amount', $donation ); + + // Track the last donation date for reminder emails. + if ( (float) $donation > 0 && $user_id > 0 ) { + update_user_meta( $user_id, 'pmprodon_last_donation_date', current_time( 'timestamp' ) ); + } + + // Store the cover fee amount in order meta. + global $pmprodon_cover_fee_amount; + if ( ! empty( $pmprodon_cover_fee_amount ) && $pmprodon_cover_fee_amount > 0 ) { + update_pmpro_membership_order_meta( $order->id, 'donation_fee_covered', $pmprodon_cover_fee_amount ); + } + + // Store the donation note in order meta. + global $pmprodon_donation_note; + if ( isset( $pmprodon_donation_note ) ) { + $donation_note = $pmprodon_donation_note; + } elseif ( isset( $_REQUEST['donation_note'] ) ) { + $donation_note = sanitize_textarea_field( $_REQUEST['donation_note'] ); + } else { + $donation_note = ''; + } + if ( ! empty( $donation_note ) ) { + update_pmpro_membership_order_meta( $order->id, 'donation_note', $donation_note ); } } add_action( 'pmpro_after_checkout', 'pmprodon_store_donation_amount_in_order_meta', 10, 2 ); /** - * Function to add the donation confirmation message to the confirmation page. + * Filter the confirmation page message for donation levels. * - * Note: This does not modify the confirmation message in the email. This would - * need to be implemented separately. + * For donation-only levels, replaces the default PMPro confirmation + * message with a donation-specific thank-you message. The custom + * confirmation_message level setting is appended after. + * + * For regular donation levels (donation + membership), appends the + * custom confirmation_message after the default PMPro message. * * @since 2.0 + * @since 2.3 Replace entire message for donation-only levels. * * @param string $message The confirmation message. * @param object $invoice The MemberOrder object. - * @return string $message The confirmation message. + * @return string The modified confirmation message. */ function pmprodon_pmpro_confirmation_message( $message, $invoice ) { - //Get the level ID from the MemberOrder object. + // Get the level ID from the MemberOrder object. if ( $invoice ) { $level_id = $invoice->membership_id; - //If for some reason we can't find the level ID, try to get it from the URL. - } else if ( isset ( $_REQUEST['pmpro_level'] ) ) { - $level_id = $_REQUEST['pmpro_level']; - // Backwards compatibility for PMPro 2.x - } else if ( isset ( $_REQUEST['level'] ) ) { - $level_id = $_REQUEST['level']; - //Bail if we can't find the level ID. + } elseif ( isset( $_REQUEST['pmpro_level'] ) ) { + $level_id = intval( $_REQUEST['pmpro_level'] ); + } elseif ( isset( $_REQUEST['level'] ) ) { + $level_id = intval( $_REQUEST['level'] ); } else { return $message; } - //Bail if not a donation level or donations are not enabled or there is no confirmation message. + // Bail if donations are not enabled for this level. $settings = pmprodon_get_level_settings( $level_id ); - if( ! $settings['donations'] || empty( $settings['confirmation_message'] ) ) { + if ( ! $settings['donations'] ) { return $message; } - //Bail if no donation amount. + // For donation-only levels, replace the entire confirmation message. + if ( pmprodon_is_donations_only( $level_id ) ) { + $donation_message = ''; + + // Build the donation thank-you message. + if ( $invoice ) { + $components = pmprodon_get_price_components( $invoice ); + if ( ! empty( $components['donation'] ) ) { + $donation_message = '

    ' . sprintf( + /* translators: %s: the formatted donation amount */ + esc_html__( 'Thank you for your donation of %s.', 'pmpro-donations' ), + pmpro_formatPrice( $components['donation'] ) + ) . '

    '; + } else { + $donation_message = '

    ' . esc_html__( 'Thank you for your donation.', 'pmpro-donations' ) . '

    '; + } + } else { + $donation_message = '

    ' . esc_html__( 'Thank you for your donation.', 'pmpro-donations' ) . '

    '; + } + + // Append the custom confirmation_message setting if configured. + if ( ! empty( $settings['confirmation_message'] ) ) { + $donation_message .= wpautop( wp_kses_post( $settings['confirmation_message'] ) ); + } + + return $donation_message; + } + + // For regular donation levels, append the custom confirmation_message. + if ( empty( $settings['confirmation_message'] ) ) { + return $message; + } + + // Bail if no donation amount. $components = pmprodon_get_price_components( $invoice ); if ( empty( $components['donation'] ) ) { return $message; } - // Show the donation confirmation message. return $message . wpautop( wp_kses_post( $settings['confirmation_message'] ) ); } add_filter( 'pmpro_confirmation_message', 'pmprodon_pmpro_confirmation_message', 10, 2 ); + +/** + * Bypass PMPro registration checks for guest donor checkouts. + * + * When a guest checkout is in progress, the username and password fields + * are auto-generated by JavaScript. This filter skips PMPro's standard + * username/password validation so that auto-generated credentials are accepted. + * + * @since 2.3 + * + * @param bool $continue Whether registration checks should continue. + * @return bool + */ +function pmprodon_guest_registration_checks( $continue ) { + if ( ! $continue ) { + return $continue; + } + + if ( ! pmprodon_is_guest_checkout() ) { + return $continue; + } + + // For guest checkout, validate that we have an email address. + if ( empty( $_REQUEST['bemail'] ) ) { + global $pmpro_msg, $pmpro_msgt; + $pmpro_msg = __( 'Please enter your email address.', 'pmpro-donations' ); + $pmpro_msgt = 'pmpro_error'; + return false; + } + + // Validate email format. + $email = sanitize_email( $_REQUEST['bemail'] ); + if ( ! is_email( $email ) ) { + global $pmpro_msg, $pmpro_msgt; + $pmpro_msg = __( 'Please enter a valid email address.', 'pmpro-donations' ); + $pmpro_msgt = 'pmpro_error'; + return false; + } + + // Check if the email belongs to an existing user. + $existing_user = get_user_by( 'email', $email ); + if ( $existing_user ) { + // Allow if the existing user is already a guest donor (repeat donation). + $is_existing_guest = get_user_meta( $existing_user->ID, 'pmprodon_is_guest_donor', true ); + if ( empty( $is_existing_guest ) ) { + global $pmpro_msg, $pmpro_msgt; + $pmpro_msg = __( 'An account with this email address already exists. Please log in to donate, or use a different email address.', 'pmpro-donations' ); + $pmpro_msgt = 'pmpro_error'; + return false; + } + } + + return $continue; +} +add_filter( 'pmpro_registration_checks', 'pmprodon_guest_registration_checks', 5 ); + +/** + * Block guest checkout for levels with recurring billing. + * + * Runs at pmpro_registration_checks to prevent guest donors from + * checking out for subscription-based levels. + * + * @since 2.3 + * + * @param bool $continue Whether registration checks should continue. + * @return bool + */ +function pmprodon_block_guest_recurring( $continue ) { + if ( ! $continue ) { + return $continue; + } + + // Only check for guest checkout attempts. + if ( is_user_logged_in() || empty( $_REQUEST['pmprodon_guest'] ) || $_REQUEST['pmprodon_guest'] !== '1' ) { + return $continue; + } + + $level = pmpro_getLevelAtCheckout(); + if ( ! empty( $level->billing_amount ) && (float) $level->billing_amount > 0 ) { + global $pmpro_msg, $pmpro_msgt; + $pmpro_msg = __( 'Guest checkout is only available for one-time donations. Please create an account for recurring donations.', 'pmpro-donations' ); + $pmpro_msgt = 'pmpro_error'; + return false; + } + + return $continue; +} +add_filter( 'pmpro_registration_checks', 'pmprodon_block_guest_recurring', 4 ); + +/** + * Generate guest donor credentials before user creation. + * + * Intercepts the checkout process for guest donors by setting a random + * username and password in $_REQUEST if not already set. This ensures + * PMPro's user creation flow works normally with non-loginable credentials. + * + * @since 2.3 + */ +function pmprodon_prepare_guest_user_data() { + if ( ! pmprodon_is_guest_checkout() ) { + return; + } + + // Store a global flag so we know this is a guest checkout. + global $pmprodon_is_guest_checkout; + $pmprodon_is_guest_checkout = true; + + // Generate a unique username if not already auto-generated by JS. + if ( empty( $_REQUEST['username'] ) || strpos( $_REQUEST['username'], 'guest_donor_' ) !== 0 ) { + $_REQUEST['username'] = 'guest_donor_' . substr( md5( uniqid( wp_rand(), true ) ), 0, 12 ); + } + + // Generate a strong random password. + if ( empty( $_REQUEST['password'] ) || strlen( $_REQUEST['password'] ) < 20 ) { + $random_password = wp_generate_password( 32, true, true ); + $_REQUEST['password'] = $random_password; + $_REQUEST['password2'] = $random_password; + } +} +add_action( 'pmpro_checkout_preheader', 'pmprodon_prepare_guest_user_data', 1 ); + +/** + * Configure a newly registered user as a guest donor. + * + * Sets the `pmprodon_is_guest_donor` user meta flag, removes all roles + * (preventing login), and stores the email for reference. + * + * @since 2.3 + * + * @param int $user_id The newly registered user ID. + */ +function pmprodon_setup_guest_donor_user( $user_id ) { + global $pmprodon_is_guest_checkout; + + if ( empty( $pmprodon_is_guest_checkout ) ) { + return; + } + + // Mark user as a guest donor. + update_user_meta( $user_id, 'pmprodon_is_guest_donor', 1 ); + update_user_meta( $user_id, 'pmprodon_guest_created', current_time( 'timestamp' ) ); + + // Remove all roles so the user cannot log in. + $user = new WP_User( $user_id ); + $user->set_role( '' ); +} +add_action( 'user_register', 'pmprodon_setup_guest_donor_user' ); + +/** + * Store confirmation key in order meta for guest donors. + * + * Generates a unique confirmation key and saves it to order meta so + * that the guest donor can access their confirmation/invoice page + * without logging in. + * + * @since 2.3 + * + * @param int $user_id The user ID. + * @param object $order The order object. + */ +function pmprodon_store_guest_confirmation_key( $user_id, $order ) { + // Only store a key for guest donors. + $is_guest = get_user_meta( $user_id, 'pmprodon_is_guest_donor', true ); + if ( empty( $is_guest ) ) { + return; + } + + $confirmation_key = pmprodon_generate_confirmation_key(); + $key_created_at = current_time( 'timestamp' ); + update_pmpro_membership_order_meta( $order->id, 'pmprodon_confirmation_key', $confirmation_key ); + update_pmpro_membership_order_meta( $order->id, 'pmprodon_confirmation_key_created', $key_created_at ); + + // Store the guest flag on the order meta for easy lookup. + update_pmpro_membership_order_meta( $order->id, 'pmprodon_is_guest_order', 1 ); + + // Store the key in a global so it can be used in the email. + global $pmprodon_confirmation_key; + $pmprodon_confirmation_key = $confirmation_key; +} +add_action( 'pmpro_after_checkout', 'pmprodon_store_guest_confirmation_key', 11, 2 ); + +/** + * Filter checkout confirmation emails for guest donors. + * + * Removes login credentials (username, password, login URL) from the + * checkout email body since guest donors don't have loginable accounts. + * Adds a confirmation key URL for accessing the invoice. + * + * @since 2.3 + * + * @param object $email The email object. + * @return object The modified email object. + */ +function pmprodon_filter_guest_donor_email( $email ) { + // Only filter checkout emails. + if ( strpos( $email->template, 'checkout' ) === false ) { + return $email; + } + + // Check if this is a guest donor order. + $order_id = ( empty( $email->data ) || empty( $email->data['order_id'] ) ) ? false : $email->data['order_id']; + if ( empty( $order_id ) ) { + return $email; + } + + $is_guest_order = get_pmpro_membership_order_meta( $order_id, 'pmprodon_is_guest_order', true ); + if ( empty( $is_guest_order ) ) { + return $email; + } + + // Strip login-related content from the email body. + // Remove lines containing username, password, and login URL. + $patterns = array( + '/^.*' . preg_quote( '!!username!!', '/' ) . '.*$/m', + '/^.*' . preg_quote( '!!password!!', '/' ) . '.*$/m', + '/^.*' . preg_quote( '!!login_url!!', '/' ) . '.*$/m', + '/^.*' . preg_quote( '!!login_link!!', '/' ) . '.*$/m', + ); + $email->body = preg_replace( $patterns, '', $email->body ); + + // Clean up consecutive blank lines left after stripping. + $email->body = preg_replace( '/(\r?\n){3,}/', "\n\n", $email->body ); + + // Add confirmation key URL if available. + $confirmation_key = get_pmpro_membership_order_meta( $order_id, 'pmprodon_confirmation_key', true ); + if ( ! empty( $confirmation_key ) ) { + $confirmation_url = add_query_arg( 'pmprodon_key', $confirmation_key, pmpro_url( 'confirmation' ) ); + $confirmation_link = '

    ' . sprintf( + /* translators: %s: the confirmation page URL */ + esc_html__( 'View your donation confirmation: %s', 'pmpro-donations' ), + '' . esc_html( $confirmation_url ) . '' + ) . '

    '; + + // Insert the confirmation link before the Invoice section, or at the end. + if ( strpos( $email->body, __( 'Invoice', 'pmpro-donations' ) ) !== false ) { + $email->body = preg_replace( + '/\\s*' . preg_quote( __( 'Invoice', 'pmpro-donations' ), '/' ) . '/', + $confirmation_link . '

    ' . __( 'Invoice', 'pmpro-donations' ), + $email->body + ); + } else { + $email->body .= $confirmation_link; + } + } + + return $email; +} +add_filter( 'pmpro_email_filter', 'pmprodon_filter_guest_donor_email', 5 ); + +/** + * Handle confirmation key access for guest donors. + * + * Allows unauthenticated visitors to view their donation confirmation + * page by passing a valid `pmprodon_key` query parameter. The key is + * validated against order meta. If valid, the matching order is loaded + * into the global context for the confirmation template. + * + * @since 2.3 + */ +function pmprodon_handle_confirmation_key_access() { + // Only act on the confirmation page. + if ( ! function_exists( 'pmpro_is_page' ) || ! pmpro_is_page( 'confirmation' ) ) { + return; + } + + // Only needed for non-logged-in users. + if ( is_user_logged_in() ) { + return; + } + + // Check for confirmation key. + if ( empty( $_REQUEST['pmprodon_key'] ) ) { + return; + } + + $key = sanitize_text_field( $_REQUEST['pmprodon_key'] ); + if ( strlen( $key ) !== 32 ) { + return; + } + + // Look up the order with this confirmation key. + global $wpdb; + $order_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT pmpro_membership_order_id FROM {$wpdb->prefix}pmpro_membership_ordermeta WHERE meta_key = 'pmprodon_confirmation_key' AND meta_value = %s LIMIT 1", + $key + ) + ); + + if ( empty( $order_id ) ) { + return; + } + + // Verify this is a guest order. + $is_guest = get_pmpro_membership_order_meta( $order_id, 'pmprodon_is_guest_order', true ); + if ( empty( $is_guest ) ) { + return; + } + + // Enforce expiration for confirmation-key access. + $key_created_at = intval( get_pmpro_membership_order_meta( $order_id, 'pmprodon_confirmation_key_created', true ) ); + $key_lifetime = apply_filters( 'pmprodon_confirmation_key_lifetime', 30 * DAY_IN_SECONDS ); + if ( empty( $key_created_at ) ) { + $order_for_timestamp = new MemberOrder( $order_id ); + $key_created_at = $order_for_timestamp->getTimestamp(); + } + if ( empty( $key_created_at ) || ( current_time( 'timestamp' ) - intval( $key_created_at ) ) > $key_lifetime ) { + return; + } + + // Load the order and set up the global for the confirmation template. + $order = new MemberOrder( $order_id ); + if ( empty( $order->id ) ) { + return; + } + + // Set the current user temporarily so PMPro's confirmation page works. + global $current_user, $pmpro_invoice; + $pmpro_invoice = $order; + + // Temporarily log in as the guest user for this page view only. + wp_set_current_user( $order->user_id ); +} +add_action( 'template_redirect', 'pmprodon_handle_confirmation_key_access', 1 ); + +/** + * Clean up stale guest-donor user accounts. + * + * Guest users are temporary operational accounts used to process checkout. + * This daily cleanup keeps them from persisting indefinitely. + * + * @since 2.3 + */ +function pmprodon_cleanup_stale_guest_donor_users() { + if ( ! function_exists( 'get_users' ) ) { + return; + } + + $retention_seconds = apply_filters( 'pmprodon_guest_user_retention', 45 * DAY_IN_SECONDS ); + $cutoff = current_time( 'timestamp' ) - intval( $retention_seconds ); + + $per_page = 200; + do { + $guest_ids = get_users( + array( + 'fields' => 'ID', + 'number' => $per_page, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => 'pmprodon_is_guest_donor', + 'value' => 1, + ), + array( + 'key' => 'pmprodon_guest_created', + 'value' => $cutoff, + 'compare' => '<=', + 'type' => 'NUMERIC', + ), + ), + ) + ); + + if ( empty( $guest_ids ) ) { + break; + } + + foreach ( $guest_ids as $guest_id ) { + if ( ! function_exists( 'wp_delete_user' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + wp_delete_user( intval( $guest_id ) ); + } + + } while ( count( $guest_ids ) === $per_page ); +} +add_action( 'pmprodon_donation_reminders_cron', 'pmprodon_cleanup_stale_guest_donor_users', 20 ); + +/** + * Restore guest checkout state for PayPal Express redirect flow. + * + * When PayPal Express redirects back to the site, this function + * restores the guest checkout flag from the order meta so the + * checkout can complete as a guest donation. + * + * @since 2.3 + */ +function pmprodon_ppe_restore_guest_state() { + // Check if the "review" or "confirm" request variables are set. + if ( empty( $_REQUEST['review'] ) && empty( $_REQUEST['confirm'] ) ) { + return; + } + + // Check if we have a PPE token that we are reviewing. + if ( empty( $_REQUEST['token'] ) ) { + return; + } + $token = sanitize_text_field( $_REQUEST['token'] ); + + // Make sure that the MemberOrder class is loaded. + if ( ! class_exists( 'MemberOrder' ) ) { + return; + } + + // Check if we have an order with this token. + $order = new MemberOrder(); + $order->getMemberOrderByPayPalToken( $token ); + if ( empty( $order->id ) ) { + return; + } + + // Check if this is a guest order. + $is_guest = get_pmpro_membership_order_meta( $order->id, 'pmprodon_is_guest_order', true ); + if ( empty( $is_guest ) ) { + return; + } + + // Restore the guest checkout flag. + if ( empty( $_REQUEST['pmprodon_guest'] ) ) { + $_REQUEST['pmprodon_guest'] = '1'; + } + + // Set the global flag. + global $pmprodon_is_guest_checkout; + $pmprodon_is_guest_checkout = true; +} +add_action( 'pmpro_checkout_preheader_before_get_level_at_checkout', 'pmprodon_ppe_restore_guest_state', 5 ); diff --git a/includes/common.php b/includes/common.php index 504d843..fcd5c6f 100644 --- a/includes/common.php +++ b/includes/common.php @@ -52,13 +52,22 @@ function pmprodon_getPriceComponents( $order ) { */ function pmprodon_get_level_settings( $level_id ) { $default_settings = array( - 'donations' => 0, - 'donations_only' => 0, - 'min_price' => '', - 'max_price' => '', - 'dropdown_prices' => '', - 'text' => '', - 'confirmation_message' => '', + 'donations' => 0, + 'donations_only' => 0, + 'allow_guest_donations' => 0, + 'min_price' => '', + 'max_price' => '', + 'dropdown_prices' => '', + 'display_mode' => 'dropdown', + 'text' => '', + 'confirmation_message' => '', + 'donation_note_enabled' => 0, + 'donation_note_label' => '', + 'cover_fees_enabled' => 0, + 'cover_fees_percentage' => '2.9', + 'cover_fees_flat' => '0.30', + 'reminder_interval' => 'none', + 'email_template_override' => '', ); if ( $level_id > 0 ) { @@ -77,3 +86,83 @@ function pmprodon_is_donations_only( $level_id ) { $settings = pmprodon_get_level_settings( $level_id ); return $settings['donations'] && $settings['donations_only']; } + +/** + * Calculate the cover fee amount for a given donation. + * + * Uses the pass-through formula so the organization receives the + * full intended donation after the gateway deducts its fees: + * fee = ( donation + flat ) / ( 1 - percentage / 100 ) - donation + * + * @since 2.3 + * + * @param float $donation The donation amount. + * @param float $percentage The gateway fee percentage (e.g. 2.9). + * @param float $flat The gateway flat fee (e.g. 0.30). + * @return float The calculated cover fee, rounded to 2 decimal places. + */ +function pmprodon_calculate_cover_fee( $donation, $percentage, $flat ) { + $donation = (float) $donation; + $percentage = (float) $percentage; + $flat = (float) $flat; + + if ( $donation <= 0 || $percentage >= 100 ) { + return 0.00; + } + + $fee = ( $donation + $flat ) / ( 1 - $percentage / 100 ) - $donation; + + return round( $fee, 2 ); +} + +/** + * Check if the current checkout is a guest donation checkout. + * + * Returns true when the user is not logged in, the level allows guest + * donations, the level is donation-only, and the guest flag is set + * in the request. + * + * @since 2.3 + * + * @return bool Whether this is a guest donation checkout. + */ +function pmprodon_is_guest_checkout() { + // Logged-in users never use guest checkout. + if ( is_user_logged_in() ) { + return false; + } + + // Check for the guest flag in the request. + if ( empty( $_REQUEST['pmprodon_guest'] ) || $_REQUEST['pmprodon_guest'] !== '1' ) { + return false; + } + + // Get the level at checkout. + if ( ! function_exists( 'pmpro_getLevelAtCheckout' ) ) { + return false; + } + + $level = pmpro_getLevelAtCheckout(); + if ( empty( $level->id ) ) { + return false; + } + + // Level must be donation-only with guest donations enabled. + $settings = pmprodon_get_level_settings( $level->id ); + if ( empty( $settings['donations'] ) || empty( $settings['donations_only'] ) || empty( $settings['allow_guest_donations'] ) ) { + return false; + } + + return true; +} + +/** + * Generate a unique confirmation key for guest donor order access. + * + * @since 2.3 + * + * @return string A 32-character random alphanumeric string. + */ +function pmprodon_generate_confirmation_key() { + return wp_generate_password( 32, false ); +} diff --git a/includes/donation-only-level.php b/includes/donation-only-level.php index f464f08..c00dc35 100644 --- a/includes/donation-only-level.php +++ b/includes/donation-only-level.php @@ -8,14 +8,29 @@ /** * Set existing member flag before checkout. + * Store user's previous levels and prevent cancellation when checking out for a donation-only level. + * + * @since 2.3 + * + * @param int $user_id The user ID. + * @param object $morder The membership order object. */ function pmprodon_pmpro_checkout_before_change_membership_level( $user_id, $morder ) { global $pmprodon_existing_member_flag, $pmpro_level; - if ( pmpro_hasMembershipLevel() - && pmpro_is_checkout() - && ! empty( $pmpro_level ) - && pmprodon_is_donations_only( $pmpro_level->id ) ) { + // Skip level preservation for guest donors — they have no existing levels. + $is_guest = get_user_meta( $user_id, 'pmprodon_is_guest_donor', true ); + if ( ! empty( $is_guest ) ) { + return; + } + + if ( pmpro_hasMembershipLevel() && pmpro_is_checkout() && ! empty( $pmpro_level ) && pmprodon_is_donations_only( $pmpro_level->id ) ) { + // Store the user's current level info before it gets changed. + $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); + if ( ! empty( $current_levels ) ) { + update_user_meta( $user_id, 'pmprodon_previous_levels', $current_levels ); + } + add_filter( 'pmpro_cancel_previous_subscriptions', '__return_false' ); add_filter( 'pmpro_deactivate_old_levels', '__return_false' ); $pmprodon_existing_member_flag = true; @@ -25,30 +40,193 @@ function pmprodon_pmpro_checkout_before_change_membership_level( $user_id, $mord /** * Give existing users their old level back after checkout. + * Uses PMPro API to detect donation-only level assignment, then restores + * the correct previous level respecting level groups. + * + * For non-members (new users with no prior levels), cancels the donation-only + * level so it does not persist on the account page. + * + * @since 2.3 + * + * @param int $user_id The user ID. */ function pmprodon_pmpro_after_checkout( $user_id ) { - global $wpdb, $pmprodon_existing_member_flag, $pmpro_level; + global $pmprodon_existing_member_flag; + + // Skip level restoration for guest donors — they have no previous levels. + $is_guest = get_user_meta( $user_id, 'pmprodon_is_guest_donor', true ); + if ( ! empty( $is_guest ) ) { + // Guest donors should not retain a donation-only level either. + pmprodon_cancel_donation_only_level_for_user( $user_id ); + return; + } + // Handle existing members — restore their previous level. if ( isset( $pmprodon_existing_member_flag ) ) { - // Remove last row added to members_users table. - $sqlQuery = "DELETE FROM $wpdb->pmpro_memberships_users WHERE user_id = '" . esc_sql( $user_id ) . "' AND membership_id = '" . esc_sql( $pmpro_level->id ) . "' ORDER BY id DESC LIMIT 1"; - $wpdb->query( $sqlQuery ); + // Get user's current levels. + $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); + + // Check if they now have a donation-only level. + $donation_level_id = null; + $has_donation_level = false; + + foreach ( $current_levels as $level ) { + if ( pmprodon_is_donations_only( $level->id ) ) { + $donation_level_id = $level->id; + $has_donation_level = true; + break; + } + } + + // If user has a donation-only level, restore their previous level. + if ( $has_donation_level ) { + // Get their previous levels that we stored. + $previous_levels = get_user_meta( $user_id, 'pmprodon_previous_levels', true ); + + if ( ! empty( $previous_levels ) ) { + // Determine which level we should restore. + $level_to_restore = null; + + // Find a level from the same group as the donation level. + $donation_group_id = null; + if ( function_exists( 'pmpro_get_group_id_for_level' ) ) { + $donation_group_id = pmpro_get_group_id_for_level( $donation_level_id ); + } + + if ( $donation_group_id ) { + // Find a previous level in the same group. + foreach ( $previous_levels as $prev_level ) { + $prev_level_group_id = pmpro_get_group_id_for_level( $prev_level->id ); + if ( $prev_level_group_id === $donation_group_id ) { + $level_to_restore = $prev_level; + break; + } + } + } + + // If no level found in the same group, use the first previous level. + if ( ! $level_to_restore ) { + $level_to_restore = $previous_levels[0]; + } + + // Cancel the donation-only level properly. + // Suppress cancellation emails during level swap. + add_filter( 'pmpro_send_cancel_admin_email', '__return_false' ); + add_filter( 'pmpro_email_filter', 'pmprodon_suppress_cancellation_email', 1 ); + + pmpro_cancelMembershipLevel( $donation_level_id, $user_id, 'inactive' ); + + $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); + if ( ! pmprodon_user_has_level( $current_levels, $level_to_restore->id ) ) { + // Create a custom level array for restoration. + $custom_level = array( + 'user_id' => $user_id, + 'membership_id' => $level_to_restore->id, + 'code_id' => $level_to_restore->code_id, + 'initial_payment' => $level_to_restore->initial_payment, + 'billing_amount' => $level_to_restore->billing_amount, + 'cycle_number' => $level_to_restore->cycle_number, + 'cycle_period' => $level_to_restore->cycle_period, + 'billing_limit' => $level_to_restore->billing_limit, + 'trial_amount' => $level_to_restore->trial_amount, + 'trial_limit' => $level_to_restore->trial_limit, + 'startdate' => $level_to_restore->startdate, + 'enddate' => $level_to_restore->enddate, + ); + + // Restore the original level. + $restored = pmpro_changeMembershipLevel( $custom_level, $user_id ); + if ( ! $restored ) { + error_log( sprintf( 'PMPro Donations: Failed to restore level %d for user %d after donation-only checkout.', $level_to_restore->id, $user_id ) ); + } + } + + // Re-enable cancellation emails. + remove_filter( 'pmpro_send_cancel_admin_email', '__return_false' ); + remove_filter( 'pmpro_email_filter', 'pmprodon_suppress_cancellation_email', 1 ); + } + } + + // Always clean up stored meta when existing member flag was set. + delete_user_meta( $user_id, 'pmprodon_previous_levels' ); // Reset user. global $all_membership_levels; unset( $all_membership_levels[ $user_id ] ); pmpro_set_current_user(); + + return; } + + // Handle non-members — cancel the donation-only level so it does not + // persist on the account page. This user had no prior membership. + pmprodon_cancel_donation_only_level_for_user( $user_id ); } add_action( 'pmpro_after_checkout', 'pmprodon_pmpro_after_checkout' ); +/** + * Cancel any donation-only level for a user. + * + * Used after checkout for non-members and guest donors so that the + * donation-only level does not persist on the account page. + * + * @since 2.3 + * + * @param int $user_id The user ID. + */ +function pmprodon_cancel_donation_only_level_for_user( $user_id ) { + $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); + if ( empty( $current_levels ) ) { + return; + } + + foreach ( $current_levels as $level ) { + if ( pmprodon_is_donations_only( $level->id ) ) { + add_filter( 'pmpro_send_cancel_admin_email', '__return_false' ); + add_filter( 'pmpro_email_filter', 'pmprodon_suppress_cancellation_email', 1 ); + pmpro_cancelMembershipLevel( $level->id, $user_id, 'inactive' ); + remove_filter( 'pmpro_send_cancel_admin_email', '__return_false' ); + remove_filter( 'pmpro_email_filter', 'pmprodon_suppress_cancellation_email', 1 ); + + // Reset user cache. + global $all_membership_levels; + unset( $all_membership_levels[ $user_id ] ); + pmpro_set_current_user(); + break; + } + } +} + +/** + * Check whether a level ID is present in a user's current levels. + * + * @since 2.3 + * + * @param array $levels Array of level objects. + * @param int $level_id Level ID to check. + * @return bool + */ +function pmprodon_user_has_level( $levels, $level_id ) { + if ( empty( $levels ) ) { + return false; + } + + foreach ( $levels as $level ) { + if ( ! empty( $level->id ) && intval( $level->id ) === intval( $level_id ) ) { + return true; + } + } + + return false; +} + /** * On the edit level page, we never want to prevent a user from selecting a donation-only level. * * @since 1.1.2 * - * @param bool $return - * @param object $level + * @param bool $return Whether the level is expiring soon. + * @param object $level The level object. * @return bool */ function pmprodon_pmpro_is_level_expiring_soon( $return, $level ) { @@ -59,3 +237,56 @@ function pmprodon_pmpro_is_level_expiring_soon( $return, $level ) { return $return; } add_filter( 'pmpro_is_level_expiring_soon', 'pmprodon_pmpro_is_level_expiring_soon', 10, 2 ); + +/** + * Filter the text that says a level will be removed at checkout. + * Suppresses the misleading 'Your current membership level will be removed' message + * when checking out for a donation-only level. + * + * @since 2.3 + * + * @param string $translated_text The translated text. + * @param string $text The original text. + * @param string $domain The text domain. + * @return string The modified or original text. + */ +function pmprodon_filter_checkout_level_change_text( $translated_text, $text, $domain ) { + // Only proceed if we're on the checkout page. + if ( ! pmpro_is_checkout() ) { + return $translated_text; + } + + // Target only the specific message about level removal. + if ( $domain === 'paid-memberships-pro' && + $text === 'Your current membership level of %s will be removed when you complete your purchase.' ) { + + global $pmpro_level; + + // Check if this is a donation-only level. + if ( ! empty( $pmpro_level ) && pmprodon_is_donations_only( $pmpro_level->id ) ) { + return ''; + } + } + + return $translated_text; +} +add_filter( 'gettext', 'pmprodon_filter_checkout_level_change_text', 10, 3 ); + +/** + * Suppress cancellation emails during donation-only level swaps. + * + * Hooked temporarily at priority 1 on pmpro_email_filter to block + * cancellation emails that would confuse donors during the + * cancel-and-restore flow. + * + * @since 2.3 + * + * @param object $email The email object. + * @return object The email object, with send disabled if it's a cancellation email. + */ +function pmprodon_suppress_cancellation_email( $email ) { + if ( strpos( $email->template, 'cancel' ) !== false ) { + $email->send = false; + } + return $email; +} diff --git a/includes/level-settings.php b/includes/level-settings.php index c375622..ef00af2 100644 --- a/includes/level-settings.php +++ b/includes/level-settings.php @@ -6,13 +6,22 @@ function pmprodon_pmpro_membership_level_after_other_settings() { global $pmpro_currency_symbol; $level_id = intval( $_REQUEST['edit'] ); $donfields = pmprodon_get_level_settings( $level_id ); - $donations = ( ! isset( $donfields['donations'] ) ) ? 0 : $donfields['donations']; - $donations_only = ( ! isset( $donfields['donations_only'] ) ) ? 0 : $donfields['donations_only']; - $min_price = ( ! isset( $donfields['min_price'] ) ) ? '' : $donfields['min_price']; + $donations = ( ! isset( $donfields['donations'] ) ) ? 0 : $donfields['donations']; + $donations_only = ( ! isset( $donfields['donations_only'] ) ) ? 0 : $donfields['donations_only']; + $allow_guest_donations = ( ! isset( $donfields['allow_guest_donations'] ) ) ? 0 : $donfields['allow_guest_donations']; + $min_price = ( ! isset( $donfields['min_price'] ) ) ? '' : $donfields['min_price']; $max_price = ( ! isset( $donfields['max_price'] ) ) ? '' : $donfields['max_price']; $donations_text = ( ! isset( $donfields['text'] ) ) ? '' : $donfields['text']; - $confirmation_message = ( ! isset( $donfields['confirmation_message'] ) ) ? '' : $donfields['confirmation_message']; - $dropdown_prices = ( ! isset( $donfields['dropdown_prices'] ) ) ? '' : $donfields['dropdown_prices']; + $confirmation_message = ( ! isset( $donfields['confirmation_message'] ) ) ? '' : $donfields['confirmation_message']; + $dropdown_prices = ( ! isset( $donfields['dropdown_prices'] ) ) ? '' : $donfields['dropdown_prices']; + $display_mode = ( ! isset( $donfields['display_mode'] ) ) ? 'dropdown' : $donfields['display_mode']; + $donation_note_enabled = ( ! isset( $donfields['donation_note_enabled'] ) ) ? 0 : $donfields['donation_note_enabled']; + $donation_note_label = ( ! isset( $donfields['donation_note_label'] ) ) ? '' : $donfields['donation_note_label']; + $cover_fees_enabled = ( ! isset( $donfields['cover_fees_enabled'] ) ) ? 0 : $donfields['cover_fees_enabled']; + $cover_fees_percentage = ( ! isset( $donfields['cover_fees_percentage'] ) ) ? '2.9' : $donfields['cover_fees_percentage']; + $cover_fees_flat = ( ! isset( $donfields['cover_fees_flat'] ) ) ? '0.30' : $donfields['cover_fees_flat']; + $reminder_interval = ( ! isset( $donfields['reminder_interval'] ) ) ? 'none' : $donfields['reminder_interval']; + $email_template_override = ( ! isset( $donfields['email_template_override'] ) ) ? '' : $donfields['email_template_override']; if ( ! empty( $donations ) ) { $section_visibility = 'visible'; $section_activated = 'true'; @@ -45,6 +54,12 @@ function pmprodon_pmpro_membership_level_after_other_settings() { /> + style="display: none;"> + + + /> + + @@ -63,6 +78,15 @@ function pmprodon_pmpro_membership_level_after_other_settings() {
    + style="display: none;"> + + + +    + +
    + + @@ -77,6 +101,58 @@ function pmprodon_pmpro_membership_level_after_other_settings() {
    + + + + /> + + + style="display: none;"> + + + +
    + + + + + + /> + + + style="display: none;"> + + + % +
    + + + style="display: none;"> + + + +
    + + + + + + +
    + + + + + + +
    + + @@ -84,19 +160,78 @@ function pmprodon_pmpro_membership_level_after_other_settings() {