Skip to content

Show pending memberships more clearly on the account page - #3714

Open
kimcoleman wants to merge 3 commits into
strangerstudios:devfrom
kimcoleman:account-pending-order-ux
Open

Show pending memberships more clearly on the account page#3714
kimcoleman wants to merge 3 commits into
strangerstudios:devfrom
kimcoleman:account-pending-order-ux

Conversation

@kimcoleman

@kimcoleman kimcoleman commented Jun 29, 2026

Copy link
Copy Markdown
Member

What

Surfaces memberships that are waiting on payment on the Membership Account page, so members can see the status of an order that hasn't completed yet (e.g. Pay by Check, async gateway confirmations, or a failed recurring payment).

  • Held level + pending order (failed recurring payment): the existing level card shows a "Your latest payment for this membership is past due" message. Members can complete payment via the existing Update Billing Info link, or a View Order link if billing updates aren't available.
  • Pending order, no level yet (first payment not yet confirmed): a new card is shown for that level with a "We are waiting for your payment to be completed" message and a View Order link.

Both surfaces reuse the existing pmpro_membership_account_after_level_card_content action and pmpro_member_action_links filter, so the message and links route through a single helper, pmpro_get_pending_order_for_user_level().

Screenshot 2026-06-29 at 7 52 41 PM Screenshot 2026-06-29 at 12 45 40 PM

Not surfacing stale pending orders

A pending order is only treated as live when:

  1. It is the user's most recent order for that level — a newer order in another status (e.g. paying again by a different method) means the pending order is stale.
  2. The user hasn't since started a different level in the same single-selection group after the order was created. Holding another plan in a pick-one group means they chose it instead; a pending order created after they already held the other level (a genuine upgrade) is left alone.

Known limitation

A pending order that's simply abandoned (e.g. a Pay by Check checkout where the member never pays and never buys anything else) will keep showing its card indefinitely. There's no data signal that distinguishes "abandoned" from "check is in the mail," so any age-out is a product/policy decision — flagging for discussion rather than guessing.

🤖 Generated with Claude Code

kimcoleman and others added 3 commits June 16, 2026 07:54
Only treat a pending order as live if it is the user's most recent order
for that level, and suppress it once the user has started a different
level in the same single-selection group after the order was created
(they chose another plan). Covers the card list, the card message, and
the action link, since all three route through
pmpro_get_pending_order_for_user_level().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@flintfromthebasement flintfromthebasement left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR: #3714 — Show pending memberships more clearly on the account page
kimcoleman → dev | 2 files, +212 -3 lines
#3714

Summary

Clean, well-reasoned feature. The staleness logic in pmpro_get_pending_order_for_user_level() is solid — "most recent order for this level must be pending, and no sibling group level was started after it" — and the static cache is correctly keyed by user_id:level_id. The shortcode restructuring from if/else to two independent blocks is the right move. Two minor concerns below; nothing blocking.

Issues

Minor includes/functions.php ~line 5506 — $held_level->startdate >= $recent_order->timestamp compares two values with different timezone provenance. startdate comes from UNIX_TIMESTAMP(CONVERT_TZ(startdate, '+00:00', @@global.time_zone)) — the MySQL global timezone determines what UNIX_TIMESTAMP sees. $recent_order->timestamp is strtotime( $dbobj->timestamp ) in the MemberOrder constructor — PHP interprets the raw MySQL DATETIME using the PHP default timezone. If @@global.time_zone and date_default_timezone_set() differ (not uncommon on shared hosts), the comparison can be off by hours. A level started 30 seconds after a pending order was created could be incorrectly treated as not superseding it. Low probability on a well-configured install, but worth a docblock note: // Both timestamps are Unix; assumes MySQL @@global.time_zone matches the PHP default timezone.

Minor pmpro_membership_account_after_level_card_content and pmpro_member_action_links now also fire for pending-only level cards. Core handled this correctly — pmpro_display_member_account_level_message() got the pmpro_hasMembershipLevel() guard. But any third-party code hooked to these actions that assumes the user holds that level will silently output wrong content on pending-only cards. Call this out explicitly in the changelog as a hook behavior change so Add On / custom-code authors know to add their own guard.

Questions

If PMPro deactivates a membership after a failed recurring payment (level moves to cancelled, user drops out of $mylevels), the user gets a pending-only card via $pending_order_levels. pmpro_hasMembershipLevel() returns false, so the message reads "We are waiting for your payment to be completed. Your membership will be activated once the payment has been confirmed." That reads like a first-time purchase, not a lapsed renewal. Is this scenario reachable in your supported gateway configurations (i.e., does any gateway immediately cancel rather than holding the level active while retrying)? If yes, the copy distinction matters.

Looks Good

The two-condition staleness check is exactly right — stale-if-newer-order-exists catches the "paid again by another method" case, stale-if-group-sibling-started-after catches the "switched plans" case. The invoice link being suppressed when update-billing is already present avoids a confusing duplicate path. All output is properly escaped (esc_html, esc_url, esc_attr, wp_kses). Property access on MemberOrder objects via __get correctly surfaces status, timestamp, and membership_id despite their private declarations.

@dparker1005 dparker1005 added this to the 3.9 milestone Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants