Skip to content

JOIN-144 Add GMTU membership lapsing override#8

Open
conatus wants to merge 8 commits intomainfrom
feature/join-132-update-manchester-restriction-copy
Open

JOIN-144 Add GMTU membership lapsing override#8
conatus wants to merge 8 commits intomainfrom
feature/join-132-update-manchester-restriction-copy

Conversation

@conatus
Copy link
Copy Markdown
Member

@conatus conatus commented Apr 2, 2026

Summary

  • Adds GMTU-specific membership standing classification to override the parent plugin's default Stripe-driven lapsing behaviour
  • Only lapses a member when they have missed 7 or more completed calendar months of GMTU payments — not on the first Stripe payment failure
  • Blocks automatic unlapsing for sticky-lapsed members; they must rejoin explicitly via the join form
  • Documents the full hook lifecycle and lapsing rules in README.md

How it works

Three new source files, all wired into join-gmtu.php:

File Purpose
src/MembershipStanding.php Pure classifier — counts missed completed months, applies 0–2/3/4–6/7+ bands
src/StickyLapsedStore.php Persists sticky-lapsed flag in wp_options (SHA-256 keyed, autoload off)
src/StripePaymentHistory.php Fetches GMTU-scoped Stripe charges (metadata id=join-gmtu, Action Network Connect app)
src/LapsingOverride.php Hooks ck_join_flow_should_lapse_member, ck_join_flow_should_unlapse_member, and ck_join_flow_success

Test plan

  • 203 tests pass locally (composer test)
  • 31 unit tests cover all standing classification threshold boundaries, year boundaries, sticky-lapsed override, and new-member exception
  • 11 unit tests cover sticky-lapsed storage (key format, case-insensitivity, read/write/delete)
  • 17 integration tests cover all lapse/unlapse/success hook scenarios including API error fall-through and non-GMTU pass-through
  • Reverse step confirmed: deliberately broken thresholds caused 4 test failures; restored correctly

🤖 Generated with Claude Code

conatus added 4 commits April 2, 2026 15:50
Implements classify_membership_standing() and helpers in
MembershipStanding.php. Uses completed calendar months since last
successful payment, with sticky-lapsed state and new-member exception,
matching the standing bands documented in the GMTU lapsing spec.

31 unit tests covering all threshold boundaries, year boundaries,
sticky-lapsed override, new-member exception, and multiple payment
history scenarios. Reverse step confirmed tests detect threshold errors.

Also adds README section documenting the hook lifecycle and lapsing logic.
Stores the sticky-lapsed flag per-member in wp_options, keyed by
gmtu_sticky_lapsed_ + SHA-256(lowercased email). Value is JSON with
email, timestamp, and trigger for audit purposes. Autoload is disabled
since flags are only read during webhook processing.

11 unit tests covering key format, case-insensitivity, read/write/delete
operations, and autoload setting.
LapsingOverride.php hooks into ck_join_flow_should_lapse_member and
ck_join_flow_should_unlapse_member, applying GMTU's standing rules
instead of lapsing/unlapsing immediately on Stripe webhook events.
Only lapses at 7+ missed months; suppresses for Good/Early Arrears/Lapsing.
Sets sticky-lapsed flag on lapse; clears it on explicit rejoin via success hook.
Falls through to parent default on Stripe API error or missing GMTU history.

StripePaymentHistory.php fetches GMTU-scoped Stripe charges (metadata.id
= join-gmtu, application = Action Network Connect app) and returns
deduplicated UTC month keys for standing classification.

register_lapsing_override() accepts an optional fetcher callable so tests
can inject a fake without depending on patchwork namespaced function interception.

17 integration tests covering all lapse/unlapse/success scenarios.
Add require_once for MembershipStanding, StickyLapsedStore,
StripePaymentHistory, and LapsingOverride. Call register_lapsing_override()
at startup. Update hook lifecycle comment to document hooks 5-9.
Copy link
Copy Markdown
Member Author

@conatus conatus left a comment

Choose a reason for hiding this comment

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

Needs some improvements.


// No GMTU payment history at all — not a GMTU member, do not interfere.
if (empty($history['month_keys']) && $history['first_ever_payment_timestamp'] === null) {
return $should_lapse;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should also log a warning here. Remember there will be people who have not established a history form, because we never got their first payment, so we need to anticipate this.

function count_missed_completed_months(?string $last_paid_month_key, string $as_of_month_key): int
{
if ($last_paid_month_key === null) {
return 999999;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This feels inelegant and there must be a better way.

string $as_of_month_key,
bool $is_sticky_lapsed
): string {
// Sticky lapsed always wins — a later payment does not reinstate automatically.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No a new payment resets the counter.

If I don't pay:

  • January
  • Feb

Then hit March and pay, I am again at zero and in Good Standing. Any successful payment gets you back into good standing.

* @param \Stripe\Charge $charge
* @return bool
*/
function is_gmtu_charge(\Stripe\Charge $charge): bool
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was for historic Action Network payments. Not now needed.

README.md Outdated
1. Fetches the member's GMTU payment history from the Stripe Charges API.
2. Classifies their standing using the rules above.
3. Returns `true` (allow lapse) only if the member is classified as **Lapsed** (7+ missed months). Sets the sticky-lapsed flag.
4. Returns `false` (suppress lapse) for Good standing, Early arrears, or Lapsing — Stripe is acting more aggressively than GMTU rules require.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is not Stripe, it is our plugin. Also don't use em-dashes.

@conatus conatus changed the title Add GMTU membership lapsing override JOIN-144 Add GMTU membership lapsing override Apr 2, 2026
@commonknowledge commonknowledge deleted a comment from linear bot Apr 2, 2026
conatus added 4 commits April 2, 2026 17:18
… fetcher

- Rename StickyLapsedStore -> LapsedStore with is_lapsed/mark_lapsed/
  clear_lapsed and gmtu_lapsed_ option key prefix. Lapsed is the only
  kind of lapse; calling it sticky implied a non-sticky variant exists.

- Fix inelegant sentinel: count_missed_completed_months no longer accepts
  null. Null last_paid is handled explicitly in classify_membership_standing
  before the call, returning STANDING_LAPSED directly.

- Remove Action Network application ID filter from is_gmtu_charge().
  That filter was for historic Action Network payments and does not apply
  to charges created by the join-flow plugin.

- Add warning log when no GMTU payment history is found for a member,
  covering members who joined before this plugin was active.

- Fix README: remove em-dashes, replace 'Stripe' with 'parent plugin'
  in hook descriptions, rename all sticky-lapsed references to lapsed,
  update structure listing.

All 202 tests pass.
Replaces the legacy charge-metadata approach (which modelled the old
Action Network world) with the Stripe Subscriptions + Invoices model
that the parent CK Join Flow plugin actually uses.

Membership payments are now identified by:
1. Reading all configured membership plan product IDs from WordPress
   options (ck_join_flow_membership_plan_* prefix, stripe_product_id).
2. Finding Stripe subscriptions (all statuses) for each customer whose
   items belong to a configured product.
3. Collecting paid invoice timestamps (status_transitions.paid_at)
   from those subscriptions.

Removes the GMTU_METADATA_ID constant and is_gmtu_charge() helper.
Adds get_membership_product_ids(), is_gmtu_subscription(), and
fetch_paid_invoice_timestamps() in their place.
- Add stripe/stripe-php ^16.1 as dev dependency so real Stripe SDK
  objects can be constructed via constructFrom() in tests
- Add tests/stubs/Settings.php to stub the parent plugin Settings class
  (reads STRIPE_SECRET_KEY from $_ENV, matching the real fallback)
- Define ARRAY_A in test bootstrap (WordPress constant used by wpdb)
- Restructure fetch_gmtu_payment_months() and fetch_paid_invoice_timestamps()
  to accept optional injectable callables for every external call
  (product ID lookup, Customer::all, Subscription::all, Invoice::all)
  so tests drive all code paths without hitting the Stripe API
- 31 new tests covering: get_membership_product_ids, is_gmtu_subscription
  (including expanded Product object), fetch_paid_invoice_timestamps
  (including pagination and starting_after cursor), and
  fetch_gmtu_payment_months (error paths, filtering, deduplication,
  multi-customer/subscription aggregation, subscription pagination)
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.

1 participant