From a271e53d43f0e9048b46d685357382a84723eded Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 15:50:22 +0100 Subject: [PATCH 1/9] Add pure GMTU membership standing classifier with tests 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. --- README.md | 87 +++++++++++++ src/MembershipStanding.php | 106 +++++++++++++++ tests/MembershipStandingTest.php | 213 +++++++++++++++++++++++++++++++ tests/bootstrap.php | 1 + 4 files changed, 407 insertions(+) create mode 100644 src/MembershipStanding.php create mode 100644 tests/MembershipStandingTest.php diff --git a/README.md b/README.md index 4fca8b1..cf17fcc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,90 @@ This add-on to the main [CK Join Flow plugin](https://github.com/commonknowledge - **Branch tagging** — Adds the assigned branch name as a tag when members are synced to external services (Mailchimp, Zetkin, etc.). - **Email notifications** — Sends admin and branch-specific notification emails when a new member registers. - **Postcode lookup caching** — Caches postcodes.io API responses as WordPress transients (7-day TTL) to reduce external API calls. +- **Membership lapsing override** — Applies GMTU's own standing rules instead of lapsing members immediately on Stripe payment failure. + +## Hook lifecycle + +The parent plugin fires hooks at each stage of member registration and membership management. This plugin hooks into them in the following order: + +| # | Hook | File | What we do | +|---|------|------|------------| +| 1 | `ck_join_flow_postcode_validation` (filter) | `PostcodeValidation.php` | Check outcode against branch map; return error if out of area | +| 2 | `ck_join_flow_step_response` (filter) | `PostcodeValidation.php` | Second-line validation on form step submission | +| 3 | `ck_join_flow_pre_handle_join` (filter) | `BranchAssignment.php` | Look up postcode outcode, find branch, inject into `$data["branch"]` | +| 4 | `ck_join_flow_add_tags` (filter) | `Tagging.php` | Append branch name to tags sent to external services | +| 5 | `ck_join_flow_success` (action, priority 5) | `LapsingOverride.php` | Clear sticky-lapsed flag when a member explicitly rejoins | +| 6 | `ck_join_flow_success` (action, priority 10) | `Notifications.php` | Send admin notification email | +| 7 | `ck_join_flow_success` (action, priority 20) | `Notifications.php` | Send branch-specific notification email | +| 8 | `ck_join_flow_should_lapse_member` (filter) | `LapsingOverride.php` | Override lapse decision using GMTU standing rules (see below) | +| 9 | `ck_join_flow_should_unlapse_member` (filter) | `LapsingOverride.php` | Override unlapse decision using GMTU standing rules (see below) | + +## Membership lapsing override + +### Why this exists + +Stripe fires webhook events whenever a payment fails or a subscription is cancelled. The parent plugin responds to these by marking the member as lapsed in all configured integrations. For GMTU, this is too aggressive — a single missed payment does not mean a member has lapsed under GMTU's rules. + +This plugin intercepts the parent plugin's lapsing decisions and applies GMTU's own standing classification instead. + +### Standing classification rules + +Membership standing is classified by counting **completed calendar months** since the member's last successful GMTU payment. The current in-progress month is always excluded from this count. + +| Missed completed months | Status | +|------------------------|--------| +| 0–2 | Good standing | +| 3 | Early arrears | +| 4–6 | Lapsing | +| 7 or more | **Lapsed** | + +Additional rules: + +- **Only GMTU payments count.** Payments are identified by Stripe charge metadata (`id = "join-gmtu"`) and the Action Network Stripe Connect application ID. Other charges on the same Stripe customer are ignored. +- **Failed and refunded payments do not count** as paid months. +- **Sticky lapsed state.** Once a member reaches Lapsed status, a later payment does not automatically reinstate them. They must rejoin via the join form. This state is stored persistently in the WordPress database (see below). +- **New member exception.** If someone makes their very first successful GMTU payment in the current month, they are treated as Good standing immediately. + +### How the override hooks work + +**`ck_join_flow_should_lapse_member`** + +Called by the parent plugin when Stripe signals that a member should be lapsed. This plugin: + +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. +5. If the member has no GMTU payment history at all, passes through to the parent plugin default (does not interfere with non-GMTU charges). +6. Falls through to the parent plugin default on Stripe API errors, to avoid accidental lapsing due to a transient network failure. + +**`ck_join_flow_should_unlapse_member`** + +Called by the parent plugin when Stripe signals that a member should be unlapsed (e.g. after a successful payment). This plugin: + +1. Fetches the member's GMTU payment history and classifies their standing. +2. Returns `true` (allow unlapse) only if the member is **Good standing** and does not have the sticky-lapsed flag set. +3. Returns `false` (suppress unlapse) if the member is sticky-lapsed — they must rejoin explicitly via the join form. +4. Returns `false` if the member is in Early arrears or Lapsing — one payment is not enough to restore Good standing. +5. Falls through to the parent plugin default on Stripe API errors. + +**`ck_join_flow_success` (priority 5)** + +When a member completes the join form successfully, the sticky-lapsed flag is cleared. This is what allows a previously-lapsed member to regain Good standing — but only after going through the full join flow again. + +### Example + +Suppose today is 15 August. The last completed month is July. + +| Last payment | Missed months | Status | Lapse webhook outcome | +|---|---|---|---| +| April | May, Jun, Jul (3) | Early arrears | Suppressed | +| January | Feb, Mar, Apr, May, Jun, Jul (6) | Lapsing | Suppressed | +| December (prior year) | Jan–Jul (7) | Lapsed | Allowed; sticky-lapsed flag set | + +### Sticky lapsed storage + +The sticky-lapsed flag is stored in WordPress `wp_options`, keyed by `gmtu_sticky_lapsed_` followed by the SHA-256 hash of the member's lowercased email address. The stored value is a JSON object recording the email, timestamp, and webhook trigger, for audit purposes. The flag is cleared automatically when the member completes a new join form submission. ## Structure @@ -24,6 +108,9 @@ src/ BranchAssignment.php # Assigns branch to member data based on postcode Tagging.php # Adds branch as tag in external services Notifications.php # Registers success notification hooks + MembershipStanding.php # Pure GMTU standing classifier (no I/O, fully unit-tested) + StickyLapsedStore.php # Persists sticky-lapsed state in wp_options + LapsingOverride.php # Hooks into parent lapsing filters using the above two ``` ## Configuration diff --git a/src/MembershipStanding.php b/src/MembershipStanding.php new file mode 100644 index 0000000..791ef80 --- /dev/null +++ b/src/MembershipStanding.php @@ -0,0 +1,106 @@ + $k < $as_of_month_key); + if (empty($prior)) { + return null; + } + return max($prior); +} + +/** + * Classify a member's GMTU membership standing. + * + * @param string[] $paid_month_keys Sorted 'YYYY-MM' strings for months with a successful payment. + * @param string $as_of_month_key Current month 'YYYY-MM' (the in-progress month, excluded). + * @param bool $is_sticky_lapsed Whether the member has previously been marked as lapsed. + * @return string One of the STANDING_* constants. + */ +function classify_membership_standing( + array $paid_month_keys, + string $as_of_month_key, + bool $is_sticky_lapsed +): string { + // Sticky lapsed always wins — a later payment does not reinstate automatically. + if ($is_sticky_lapsed) { + return STANDING_LAPSED; + } + + $last_paid = last_paid_month_before($paid_month_keys, $as_of_month_key); + + // New member exception: first-ever payment is in the current month and there + // are no prior payments → treat as Good standing immediately. + if ($last_paid === null && in_array($as_of_month_key, $paid_month_keys, true)) { + return STANDING_GOOD; + } + + $missed = count_missed_completed_months($last_paid, $as_of_month_key); + + if ($missed <= 2) { + return STANDING_GOOD; + } + if ($missed === 3) { + return STANDING_EARLY_ARREARS; + } + if ($missed <= 6) { + return STANDING_LAPSING; + } + return STANDING_LAPSED; +} diff --git a/tests/MembershipStandingTest.php b/tests/MembershipStandingTest.php new file mode 100644 index 0000000..0260944 --- /dev/null +++ b/tests/MembershipStandingTest.php @@ -0,0 +1,213 @@ +assertSame(2026 * 12 + 4, month_key_to_index('2026-04')); + } + + public function test_month_key_to_index_january() + { + $this->assertSame(2026 * 12 + 1, month_key_to_index('2026-01')); + } + + public function test_month_key_to_index_december() + { + $this->assertSame(2025 * 12 + 12, month_key_to_index('2025-12')); + } + + // --- count_missed_completed_months --- + + public function test_count_missed_zero_when_paid_last_month() + { + // As-of April, last paid March: last completed month is March, missed = 0 + $this->assertSame(0, count_missed_completed_months('2026-03', '2026-04')); + } + + public function test_count_missed_one_when_paid_two_months_ago() + { + // As-of April, last paid February: missed March = 1 + $this->assertSame(1, count_missed_completed_months('2026-02', '2026-04')); + } + + public function test_count_missed_two_when_paid_three_months_ago() + { + // As-of April, last paid January: missed Feb, Mar = 2 + $this->assertSame(2, count_missed_completed_months('2026-01', '2026-04')); + } + + public function test_count_missed_three() + { + // As-of April, last paid December: missed Jan, Feb, Mar = 3 + $this->assertSame(3, count_missed_completed_months('2025-12', '2026-04')); + } + + public function test_count_missed_six() + { + // As-of April, last paid September: missed Oct, Nov, Dec, Jan, Feb, Mar = 6 + $this->assertSame(6, count_missed_completed_months('2025-09', '2026-04')); + } + + public function test_count_missed_seven() + { + // As-of April, last paid August: missed Sep..Mar = 7 + $this->assertSame(7, count_missed_completed_months('2025-08', '2026-04')); + } + + public function test_count_missed_year_boundary() + { + // As-of March 2026, last paid November 2025: missed Dec, Jan, Feb = 3 + $this->assertSame(3, count_missed_completed_months('2025-11', '2026-03')); + } + + public function test_count_missed_null_last_paid_returns_large_number() + { + $result = count_missed_completed_months(null, '2026-04'); + $this->assertGreaterThan(1000, $result); + } + + // --- last_paid_month_before --- + + public function test_last_paid_before_current_month() + { + $this->assertSame('2026-03', last_paid_month_before(['2026-02', '2026-03'], '2026-04')); + } + + public function test_last_paid_excludes_current_month() + { + // Current month payment should NOT be returned as last_paid + $this->assertSame('2026-03', last_paid_month_before(['2026-03', '2026-04'], '2026-04')); + } + + public function test_last_paid_returns_null_when_no_prior_payments() + { + // Only current month payment + $this->assertNull(last_paid_month_before(['2026-04'], '2026-04')); + } + + public function test_last_paid_returns_null_for_empty_history() + { + $this->assertNull(last_paid_month_before([], '2026-04')); + } + + public function test_last_paid_uses_most_recent_prior_month() + { + $this->assertSame('2026-02', last_paid_month_before(['2025-06', '2026-02'], '2026-04')); + } + + // --- classify_membership_standing --- + + public function test_good_standing_paid_last_month() + { + $result = classify_membership_standing(['2026-03'], '2026-04', false); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_good_standing_paid_two_months_ago() + { + // Missed 1 completed month + $result = classify_membership_standing(['2026-02'], '2026-04', false); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_good_standing_paid_three_months_ago() + { + // Missed 2 completed months (Jan, Feb) — still good + $result = classify_membership_standing(['2026-01'], '2026-04', false); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_early_arrears_three_missed_months() + { + // Missed Jan, Feb, Mar = 3 + $result = classify_membership_standing(['2025-12'], '2026-04', false); + $this->assertSame(STANDING_EARLY_ARREARS, $result); + } + + public function test_lapsing_four_missed_months() + { + $result = classify_membership_standing(['2025-11'], '2026-04', false); + $this->assertSame(STANDING_LAPSING, $result); + } + + public function test_lapsing_six_missed_months() + { + $result = classify_membership_standing(['2025-09'], '2026-04', false); + $this->assertSame(STANDING_LAPSING, $result); + } + + public function test_lapsed_seven_missed_months() + { + $result = classify_membership_standing(['2025-08'], '2026-04', false); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_lapsed_twelve_missed_months() + { + $result = classify_membership_standing(['2025-03'], '2026-04', false); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_lapsed_no_payment_history_at_all() + { + $result = classify_membership_standing([], '2026-04', false); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_new_member_first_payment_this_month_is_good() + { + // No prior payments, first payment is in the current month + $result = classify_membership_standing(['2026-04'], '2026-04', false); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_sticky_lapsed_overrides_good_payment() + { + // Even with a recent payment, sticky-lapsed wins + $result = classify_membership_standing(['2026-03'], '2026-04', true); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_sticky_lapsed_overrides_current_month_payment() + { + $result = classify_membership_standing(['2026-04'], '2026-04', true); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_year_boundary_three_missed() + { + // As-of March 2026, last paid November 2025: missed Dec, Jan, Feb = 3 + $result = classify_membership_standing(['2025-11'], '2026-03', false); + $this->assertSame(STANDING_EARLY_ARREARS, $result); + } + + public function test_multiple_payments_uses_most_recent_for_missed_count() + { + // Most recent prior payment is Feb, missed only March = 1 → good + $result = classify_membership_standing(['2025-06', '2026-02'], '2026-04', false); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_payment_in_current_month_does_not_override_existing_lapse_determination() + { + // Last payment before current month was July 2025 — 8 missed months → lapsed + // Even if there's also a payment in current month, last_paid_before is still July + // This covers the case where a sticky-lapsed member paid again without rejoining + $result = classify_membership_standing(['2025-07', '2026-04'], '2026-04', true); + $this->assertSame(STANDING_LAPSED, $result); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2a02c69..58e541c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -26,3 +26,4 @@ require_once dirname(__DIR__) . '/src/BranchAssignment.php'; require_once dirname(__DIR__) . '/src/Tagging.php'; require_once dirname(__DIR__) . '/src/Notifications.php'; +require_once dirname(__DIR__) . '/src/MembershipStanding.php'; From 28d98ff70c42c05769dbae88f3db73f70c1beea9 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 16:06:21 +0100 Subject: [PATCH 2/9] Add sticky-lapsed persistent storage with tests 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. --- src/StickyLapsedStore.php | 81 ++++++++++++++++++ tests/StickyLapsedStoreTest.php | 140 ++++++++++++++++++++++++++++++++ tests/bootstrap.php | 1 + 3 files changed, 222 insertions(+) create mode 100644 src/StickyLapsedStore.php create mode 100644 tests/StickyLapsedStoreTest.php diff --git a/src/StickyLapsedStore.php b/src/StickyLapsedStore.php new file mode 100644 index 0000000..efc6f92 --- /dev/null +++ b/src/StickyLapsedStore.php @@ -0,0 +1,81 @@ + $email, + 'lapsed_at' => $lapsed_at, + 'trigger' => $trigger, + ]); + + // Autoload false — only looked up on-demand during webhook processing. + update_option(sticky_lapsed_option_key($email), $value, false); +} + +/** + * Clear the sticky-lapsed flag for a member. + * + * Called when a member explicitly rejoins via the join form, allowing them + * to return to Good standing. + * + * @param string $email Member email address. + * @return void + */ +function clear_sticky_lapsed(string $email): void +{ + delete_option(sticky_lapsed_option_key($email)); +} diff --git a/tests/StickyLapsedStoreTest.php b/tests/StickyLapsedStoreTest.php new file mode 100644 index 0000000..f6f1023 --- /dev/null +++ b/tests/StickyLapsedStoreTest.php @@ -0,0 +1,140 @@ +assertStringStartsWith('gmtu_sticky_lapsed_', $key); + } + + public function test_option_key_is_deterministic() + { + $this->assertSame( + sticky_lapsed_option_key('test@example.com'), + sticky_lapsed_option_key('test@example.com') + ); + } + + public function test_option_key_is_case_insensitive() + { + $this->assertSame( + sticky_lapsed_option_key('Test@Example.COM'), + sticky_lapsed_option_key('test@example.com') + ); + } + + public function test_option_key_suffix_is_sha256_of_lowercased_email() + { + $email = 'test@example.com'; + $expected = 'gmtu_sticky_lapsed_' . hash('sha256', strtolower(trim($email))); + $this->assertSame($expected, sticky_lapsed_option_key($email)); + } + + public function test_different_emails_produce_different_keys() + { + $this->assertNotSame( + sticky_lapsed_option_key('alice@example.com'), + sticky_lapsed_option_key('bob@example.com') + ); + } + + // --- is_sticky_lapsed --- + + public function test_is_sticky_lapsed_returns_false_when_option_missing() + { + Functions\expect('get_option') + ->once() + ->with(sticky_lapsed_option_key('member@example.com'), false) + ->andReturn(false); + + $this->assertFalse(is_sticky_lapsed('member@example.com')); + } + + public function test_is_sticky_lapsed_returns_true_when_option_exists() + { + $value = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-04-01T00:00:00Z']); + Functions\expect('get_option') + ->once() + ->andReturn($value); + + $this->assertTrue(is_sticky_lapsed('member@example.com')); + } + + // --- mark_sticky_lapsed --- + + public function test_mark_sticky_lapsed_calls_update_option_with_correct_key() + { + $email = 'member@example.com'; + $key = sticky_lapsed_option_key($email); + + Functions\expect('update_option') + ->once() + ->with($key, \Mockery::type('string'), false) + ->andReturn(true); + + mark_sticky_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); + $this->addToAssertionCount(1); // Brain Monkey ->once() expectation counts as the assertion + } + + public function test_mark_sticky_lapsed_stores_json_with_email() + { + $email = 'member@example.com'; + $stored = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$stored) { + $stored = json_decode($value, true); + return true; + }); + + mark_sticky_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); + + $this->assertSame($email, $stored['email']); + $this->assertArrayHasKey('lapsed_at', $stored); + $this->assertSame('invoice_payment_failed', $stored['trigger']); + } + + public function test_mark_sticky_lapsed_sets_autoload_false() + { + $autoloadValue = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$autoloadValue) { + $autoloadValue = $autoload; + return true; + }); + + mark_sticky_lapsed('member@example.com', 'test', '2026-04-01T00:00:00Z'); + + $this->assertFalse($autoloadValue); + } + + // --- clear_sticky_lapsed --- + + public function test_clear_sticky_lapsed_calls_delete_option_with_correct_key() + { + $email = 'member@example.com'; + $key = sticky_lapsed_option_key($email); + + Functions\expect('delete_option') + ->once() + ->with($key) + ->andReturn(true); + + clear_sticky_lapsed($email); + $this->addToAssertionCount(1); // Brain Monkey ->once() expectation counts as the assertion + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 58e541c..e43a0c5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -27,3 +27,4 @@ require_once dirname(__DIR__) . '/src/Tagging.php'; require_once dirname(__DIR__) . '/src/Notifications.php'; require_once dirname(__DIR__) . '/src/MembershipStanding.php'; +require_once dirname(__DIR__) . '/src/StickyLapsedStore.php'; From 0742c96b16696862d5b6391e6ba953072914b188 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 16:50:56 +0100 Subject: [PATCH 3/9] Add lapsing override hooks and Stripe payment history fetcher with tests 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. --- src/LapsingOverride.php | 114 ++++++++++++ src/StripePaymentHistory.php | 134 ++++++++++++++ tests/LapsingOverrideTest.php | 322 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 2 + 4 files changed, 572 insertions(+) create mode 100644 src/LapsingOverride.php create mode 100644 src/StripePaymentHistory.php create mode 100644 tests/LapsingOverrideTest.php diff --git a/src/LapsingOverride.php b/src/LapsingOverride.php new file mode 100644 index 0000000..9ce5225 --- /dev/null +++ b/src/LapsingOverride.php @@ -0,0 +1,114 @@ + [], 'first_ever_payment_timestamp' => null, 'error' => 'Stripe secret key not configured']; + } + + \Stripe\Stripe::setApiKey($stripe_key); + + // A single email address may have multiple Stripe customer records. + $customers = \Stripe\Customer::all(['email' => $email, 'limit' => 10]); + + $timestamps = []; + + foreach ($customers->data as $customer) { + $has_more = true; + $params = ['customer' => $customer->id, 'limit' => 100]; + + while ($has_more) { + $charges = \Stripe\Charge::all($params); + $has_more = $charges->has_more; + + foreach ($charges->data as $charge) { + // Skip last item fetched (used as cursor for next page) + if ($has_more) { + $params['starting_after'] = $charge->id; + } + + if (!is_gmtu_charge($charge)) { + continue; + } + + $timestamps[] = $charge->created; + } + } + } + + if (empty($timestamps)) { + return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => null]; + } + + sort($timestamps); + $month_keys = array_values(array_unique( + array_map(fn(int $ts) => gmdate('Y-m', $ts), $timestamps) + )); + + return [ + 'month_keys' => $month_keys, + 'first_ever_payment_timestamp' => $timestamps[0], + 'error' => null, + ]; + } catch (\Stripe\Exception\ApiErrorException $e) { + return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => $e->getMessage()]; + } catch (\Throwable $e) { + return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => $e->getMessage()]; + } +} + +/** + * Determine whether a Stripe charge counts as a GMTU membership payment. + * + * A charge qualifies if: + * - status is 'succeeded' + * - metadata.id equals 'join-gmtu' + * - application equals the Action Network Stripe Connect app ID + * - it has not been refunded + * + * @param \Stripe\Charge $charge + * @return bool + */ +function is_gmtu_charge(\Stripe\Charge $charge): bool +{ + if ($charge->status !== 'succeeded') { + return false; + } + if (($charge->metadata->id ?? null) !== GMTU_METADATA_ID) { + return false; + } + if ($charge->application !== GMTU_APPLICATION_ID) { + return false; + } + if ($charge->refunded || $charge->amount_refunded > 0) { + return false; + } + return true; +} diff --git a/tests/LapsingOverrideTest.php b/tests/LapsingOverrideTest.php new file mode 100644 index 0000000..3aed5ee --- /dev/null +++ b/tests/LapsingOverrideTest.php @@ -0,0 +1,322 @@ +mockLogger(); + } + + // ------------------------------------------------------------------------- + // Helper: capture all three callbacks registered by register_lapsing_override + // ------------------------------------------------------------------------- + + /** + * Register hooks with an injectable fetcher and capture all three callbacks. + * + * @param callable|null $fetcher Fake fetcher to inject; null = default real fetcher. + * @return array{0: callable, 1: callable, 2: callable} + */ + private function registerAndCaptureCallbacks(?callable $fetcher = null): array + { + $lapseCallback = null; + $unlapseCallback = null; + $successCallback = null; + + Functions\expect('add_filter') + ->twice() + ->andReturnUsing(function ($hook, $cb) use (&$lapseCallback, &$unlapseCallback) { + if ($hook === 'ck_join_flow_should_lapse_member') { + $lapseCallback = $cb; + } elseif ($hook === 'ck_join_flow_should_unlapse_member') { + $unlapseCallback = $cb; + } + return true; + }); + + Functions\expect('add_action') + ->once() + ->andReturnUsing(function ($hook, $cb) use (&$successCallback) { + if ($hook === 'ck_join_flow_success') { + $successCallback = $cb; + } + return true; + }); + + register_lapsing_override($fetcher); + + return [$lapseCallback, $unlapseCallback, $successCallback]; + } + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + + public function test_registers_lapse_filter() + { + $registered = []; + + Functions\expect('add_filter') + ->twice() + ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered) { + $registered[] = $hook; + return true; + }); + Functions\expect('add_action')->once()->andReturn(true); + + register_lapsing_override(); + + $this->assertContains('ck_join_flow_should_lapse_member', $registered); + } + + public function test_registers_unlapse_filter() + { + $registered = []; + + Functions\expect('add_filter') + ->twice() + ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered) { + $registered[] = $hook; + return true; + }); + Functions\expect('add_action')->once()->andReturn(true); + + register_lapsing_override(); + + $this->assertContains('ck_join_flow_should_unlapse_member', $registered); + } + + public function test_registers_success_action() + { + $registered = []; + + Functions\expect('add_filter')->twice()->andReturn(true); + Functions\expect('add_action') + ->once() + ->andReturnUsing(function ($hook, $cb, $priority) use (&$registered) { + $registered[] = $hook; + return true; + }); + + register_lapsing_override(); + + $this->assertContains('ck_join_flow_success', $registered); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_should_lapse_member + // ------------------------------------------------------------------------- + + public function test_lapse_passes_through_for_non_stripe_provider() + { + [$lapse] = $this->registerAndCaptureCallbacks(); + + $result = $lapse(true, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertTrue($result); + + $result = $lapse(false, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_good_standing() + { + // Last payment was last month — 0 missed months → Good + $lastMonth = $this->monthOffset(-1); + Functions\expect('get_option')->andReturn(false); // not sticky-lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_early_arrears() + { + // 3 missed months → Early arrears + $threeMonthsAgo = $this->monthOffset(-4); + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$threeMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_lapsing() + { + // 5 missed months → Lapsing + $fiveMonthsAgo = $this->monthOffset(-6); + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$fiveMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_allowed_and_sticky_flag_set_for_lapsed() + { + // 8 missed months → Lapsed + $eightMonthsAgo = $this->monthOffset(-9); + Functions\expect('get_option')->andReturn(false); + Functions\expect('update_option')->once()->andReturn(true); // mark_sticky_lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$eightMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + + public function test_lapse_falls_through_on_stripe_api_error() + { + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcherWithError('Connection timeout')); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); // original default returned + + $result2 = $lapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result2); + } + + public function test_lapse_falls_through_when_no_gmtu_history() + { + // Empty history, no error → not a GMTU member → pass through + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + $result = $lapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + + $result2 = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result2); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_should_unlapse_member + // ------------------------------------------------------------------------- + + public function test_unlapse_passes_through_for_non_stripe_provider() + { + [, $unlapse] = $this->registerAndCaptureCallbacks(); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertTrue($result); + } + + public function test_unlapse_allowed_for_good_standing_non_sticky() + { + $lastMonth = $this->monthOffset(-1); + Functions\expect('get_option')->andReturn(false); // not sticky-lapsed + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $unlapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($result); + } + + public function test_unlapse_suppressed_for_sticky_lapsed() + { + $lastMonth = $this->monthOffset(-1); + $sticky = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($sticky); // sticky-lapsed + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($result); + } + + public function test_unlapse_suppressed_for_early_arrears() + { + $threeMonthsAgo = $this->monthOffset(-4); + Functions\expect('get_option')->andReturn(false); + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$threeMonthsAgo])); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($result); + } + + public function test_unlapse_falls_through_on_stripe_api_error() + { + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcherWithError('Timeout')); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_success + // ------------------------------------------------------------------------- + + public function test_success_hook_clears_sticky_lapsed_flag() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + $email = 'member@example.com'; + $sticky = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($sticky); + Functions\expect('delete_option')->once()->andReturn(true); + + $success(['email' => $email]); + $this->addToAssertionCount(1); // delete_option ->once() is the assertion + } + + public function test_success_hook_does_nothing_when_not_sticky_lapsed() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + Functions\expect('get_option')->andReturn(false); + Functions\expect('delete_option')->never(); + + $success(['email' => 'member@example.com']); + $this->addToAssertionCount(1); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Return a 'YYYY-MM' string offset from the current month. + * e.g. monthOffset(-1) = last month, monthOffset(-7) = 7 months ago. + */ + private function monthOffset(int $offset): string + { + $ts = mktime(0, 0, 0, (int)gmdate('n') + $offset, 1, (int)gmdate('Y')); + return gmdate('Y-m', $ts); + } + + /** + * Return a fake fetcher callable that always returns the given month keys. + */ + private function fakeFetcher(array $monthKeys): callable + { + return function (string $email) use ($monthKeys): array { + return [ + 'month_keys' => $monthKeys, + 'first_ever_payment_timestamp' => empty($monthKeys) ? null : 1, + 'error' => null, + ]; + }; + } + + /** + * Return a fake fetcher callable that always returns an error. + */ + private function fakeFetcherWithError(string $errorMessage): callable + { + return function (string $email) use ($errorMessage): array { + return [ + 'month_keys' => [], + 'first_ever_payment_timestamp' => null, + 'error' => $errorMessage, + ]; + }; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e43a0c5..c50e286 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -28,3 +28,5 @@ require_once dirname(__DIR__) . '/src/Notifications.php'; require_once dirname(__DIR__) . '/src/MembershipStanding.php'; require_once dirname(__DIR__) . '/src/StickyLapsedStore.php'; +require_once dirname(__DIR__) . '/src/StripePaymentHistory.php'; +require_once dirname(__DIR__) . '/src/LapsingOverride.php'; From b60dd3e8f617bb83edadc1458eec07b7fc5b6f38 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 16:51:30 +0100 Subject: [PATCH 4/9] Wire lapsing override into plugin entry point Add require_once for MembershipStanding, StickyLapsedStore, StripePaymentHistory, and LapsingOverride. Call register_lapsing_override() at startup. Update hook lifecycle comment to document hooks 5-9. --- join-gmtu.php | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/join-gmtu.php b/join-gmtu.php index a07181f..93226ea 100644 --- a/join-gmtu.php +++ b/join-gmtu.php @@ -40,11 +40,28 @@ * - Receives: $addTags, $data, $service * - We append the branch name to the tags array. * - * 5. ck_join_flow_success (action, Notifications.php) + * 5. ck_join_flow_success (action, LapsingOverride.php, priority 5) * - Fired after successful registration. * - Receives: $data - * - Priority 10: sends admin notification email. - * - Priority 20: sends branch-specific notification email. + * - Clears the sticky-lapsed flag so a rejoining member regains Good standing. + * + * 6. ck_join_flow_success (action, Notifications.php, priority 10) + * - Sends admin notification email. + * + * 7. ck_join_flow_success (action, Notifications.php, priority 20) + * - Sends branch-specific notification email. + * + * 8. ck_join_flow_should_lapse_member (filter, LapsingOverride.php) + * - Fired when Stripe signals a member should be lapsed. + * - Receives: $should_lapse (bool), $email, $context + * - Returns true only when GMTU standing is Lapsed (7+ missed months). + * - Suppresses lapse for Good / Early Arrears / Lapsing standing. + * + * 9. ck_join_flow_should_unlapse_member (filter, LapsingOverride.php) + * - Fired when Stripe signals a member should be unlapsed. + * - Receives: $should_unlapse (bool), $email, $context + * - Returns true only when standing is Good and sticky-lapsed flag is not set. + * - Suppresses unlapse for sticky-lapsed members (must rejoin explicitly). */ // Load required files @@ -57,6 +74,10 @@ require_once __DIR__ . '/src/BranchAssignment.php'; require_once __DIR__ . '/src/Tagging.php'; require_once __DIR__ . '/src/Notifications.php'; +require_once __DIR__ . '/src/MembershipStanding.php'; +require_once __DIR__ . '/src/StickyLapsedStore.php'; +require_once __DIR__ . '/src/StripePaymentHistory.php'; +require_once __DIR__ . '/src/LapsingOverride.php'; // Configuration $config = [ @@ -75,3 +96,4 @@ register_branch_assignment(); register_tagging(); register_notifications($config); +register_lapsing_override(); From 6e698bca0cebeb70eda777e81048a8fa8704ee0a Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 17:18:31 +0100 Subject: [PATCH 5/9] Address PR review: rename sticky lapsed to lapsed, fix classifier and 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. --- README.md | 30 +++---- join-gmtu.php | 2 +- src/LapsedStore.php | 82 ++++++++++++++++++ src/LapsingOverride.php | 52 ++++++------ src/MembershipStanding.php | 42 ++++++---- src/StickyLapsedStore.php | 81 ------------------ src/StripePaymentHistory.php | 33 ++------ tests/LapsedStoreTest.php | 140 +++++++++++++++++++++++++++++++ tests/LapsingOverrideTest.php | 34 ++++---- tests/MembershipStandingTest.php | 63 +++++++------- tests/StickyLapsedStoreTest.php | 140 ------------------------------- tests/bootstrap.php | 2 +- 12 files changed, 348 insertions(+), 353 deletions(-) create mode 100644 src/LapsedStore.php delete mode 100644 src/StickyLapsedStore.php create mode 100644 tests/LapsedStoreTest.php delete mode 100644 tests/StickyLapsedStoreTest.php diff --git a/README.md b/README.md index cf17fcc..63eabbc 100644 --- a/README.md +++ b/README.md @@ -48,37 +48,37 @@ Membership standing is classified by counting **completed calendar months** sinc Additional rules: -- **Only GMTU payments count.** Payments are identified by Stripe charge metadata (`id = "join-gmtu"`) and the Action Network Stripe Connect application ID. Other charges on the same Stripe customer are ignored. +- **Only GMTU payments count.** Payments are identified by Stripe charge metadata (`id = "join-gmtu"`). Other charges on the same Stripe customer are ignored. - **Failed and refunded payments do not count** as paid months. -- **Sticky lapsed state.** Once a member reaches Lapsed status, a later payment does not automatically reinstate them. They must rejoin via the join form. This state is stored persistently in the WordPress database (see below). +- **Lapsed is permanent.** Once a member reaches Lapsed status, a later payment does not automatically reinstate them. They must rejoin via the join form. This state is stored persistently in the WordPress database (see below). - **New member exception.** If someone makes their very first successful GMTU payment in the current month, they are treated as Good standing immediately. ### How the override hooks work **`ck_join_flow_should_lapse_member`** -Called by the parent plugin when Stripe signals that a member should be lapsed. This plugin: +Called by the parent plugin when a Stripe payment event signals that a member should be lapsed. This plugin: 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. -5. If the member has no GMTU payment history at all, passes through to the parent plugin default (does not interfere with non-GMTU charges). +3. Returns `true` (allow lapse) only if the member is classified as **Lapsed** (7+ missed months). Records the lapsed flag. +4. Returns `false` (suppress lapse) for Good standing, Early arrears, or Lapsing -- the parent plugin is acting more aggressively than GMTU rules require. +5. If the member has no GMTU payment history at all, logs a warning and passes through to the parent plugin default. 6. Falls through to the parent plugin default on Stripe API errors, to avoid accidental lapsing due to a transient network failure. **`ck_join_flow_should_unlapse_member`** -Called by the parent plugin when Stripe signals that a member should be unlapsed (e.g. after a successful payment). This plugin: +Called by the parent plugin when a Stripe payment event signals that a member should be unlapsed (e.g. after a successful payment). This plugin: 1. Fetches the member's GMTU payment history and classifies their standing. -2. Returns `true` (allow unlapse) only if the member is **Good standing** and does not have the sticky-lapsed flag set. -3. Returns `false` (suppress unlapse) if the member is sticky-lapsed — they must rejoin explicitly via the join form. -4. Returns `false` if the member is in Early arrears or Lapsing — one payment is not enough to restore Good standing. +2. Returns `true` (allow unlapse) only if the member is **Good standing** and is not flagged as lapsed. +3. Returns `false` (suppress unlapse) if the member is lapsed -- they must rejoin explicitly via the join form. +4. Returns `false` if the member is in Early arrears or Lapsing -- one payment is not enough to restore Good standing. 5. Falls through to the parent plugin default on Stripe API errors. **`ck_join_flow_success` (priority 5)** -When a member completes the join form successfully, the sticky-lapsed flag is cleared. This is what allows a previously-lapsed member to regain Good standing — but only after going through the full join flow again. +When a member completes the join form successfully, the lapsed flag is cleared. This is what allows a previously-lapsed member to regain Good standing, but only after going through the full join flow again. ### Example @@ -88,11 +88,11 @@ Suppose today is 15 August. The last completed month is July. |---|---|---|---| | April | May, Jun, Jul (3) | Early arrears | Suppressed | | January | Feb, Mar, Apr, May, Jun, Jul (6) | Lapsing | Suppressed | -| December (prior year) | Jan–Jul (7) | Lapsed | Allowed; sticky-lapsed flag set | +| December (prior year) | Jan through Jul (7) | Lapsed | Allowed; lapsed flag recorded | -### Sticky lapsed storage +### Lapsed flag storage -The sticky-lapsed flag is stored in WordPress `wp_options`, keyed by `gmtu_sticky_lapsed_` followed by the SHA-256 hash of the member's lowercased email address. The stored value is a JSON object recording the email, timestamp, and webhook trigger, for audit purposes. The flag is cleared automatically when the member completes a new join form submission. +The lapsed flag is stored in WordPress `wp_options`, keyed by `gmtu_lapsed_` followed by the SHA-256 hash of the member's lowercased email address. The stored value is a JSON object recording the email, timestamp, and webhook trigger, for audit purposes. The flag is cleared automatically when the member completes a new join form submission. ## Structure @@ -109,7 +109,7 @@ src/ Tagging.php # Adds branch as tag in external services Notifications.php # Registers success notification hooks MembershipStanding.php # Pure GMTU standing classifier (no I/O, fully unit-tested) - StickyLapsedStore.php # Persists sticky-lapsed state in wp_options + LapsedStore.php # Persists lapsed flag in wp_options LapsingOverride.php # Hooks into parent lapsing filters using the above two ``` diff --git a/join-gmtu.php b/join-gmtu.php index 93226ea..f76f91f 100644 --- a/join-gmtu.php +++ b/join-gmtu.php @@ -75,7 +75,7 @@ require_once __DIR__ . '/src/Tagging.php'; require_once __DIR__ . '/src/Notifications.php'; require_once __DIR__ . '/src/MembershipStanding.php'; -require_once __DIR__ . '/src/StickyLapsedStore.php'; +require_once __DIR__ . '/src/LapsedStore.php'; require_once __DIR__ . '/src/StripePaymentHistory.php'; require_once __DIR__ . '/src/LapsingOverride.php'; diff --git a/src/LapsedStore.php b/src/LapsedStore.php new file mode 100644 index 0000000..3b63476 --- /dev/null +++ b/src/LapsedStore.php @@ -0,0 +1,82 @@ + $email, + 'lapsed_at' => $lapsed_at, + 'trigger' => $trigger, + ]); + + // Autoload false -- only looked up on-demand during webhook processing. + update_option(lapsed_option_key($email), $value, false); +} + +/** + * Clear the lapsed flag for a member. + * + * Called when a member explicitly rejoins via the join form, allowing them + * to return to Good standing. + * + * @param string $email Member email address. + * @return void + */ +function clear_lapsed(string $email): void +{ + delete_option(lapsed_option_key($email)); +} diff --git a/src/LapsingOverride.php b/src/LapsingOverride.php index 9ce5225..69df3f6 100644 --- a/src/LapsingOverride.php +++ b/src/LapsingOverride.php @@ -3,11 +3,15 @@ * GMTU membership lapsing override. * * Intercepts the parent plugin's lapsing decisions and applies GMTU's - * standing rules in place of Stripe's default behaviour. + * standing rules. The parent plugin fires lapse/unlapse events in response + * to Stripe webhooks, but its default behaviour is more aggressive than + * GMTU requires -- GMTU only lapses a member after 7 or more missed months. * - * Standing is classified by counting completed calendar months since the - * member's last successful GMTU payment (see MembershipStanding.php). - * Stripe's default fires too aggressively — GMTU only lapses at 7+ months. + * Members who miss fewer than 7 completed months are not lapsed. Any + * successful payment within that window resets them to Good standing. + * + * Once a member reaches 7+ missed months they are lapsed. A later payment + * does not reinstate them automatically -- they must rejoin via the join form. * * @package CommonKnowledge\JoinBlock\Organisation\GMTU */ @@ -24,7 +28,7 @@ * * @param callable|null $fetcher Optional override for fetch_gmtu_payment_months(). * Used in tests to inject a fake fetcher. Defaults to - * the real Stripe-backed implementation. + * the real implementation. * @return void */ function register_lapsing_override(?callable $fetcher = null): void @@ -42,24 +46,24 @@ function register_lapsing_override(?callable $fetcher = null): void $history = $fetch($email); if ($history['error'] !== null) { - log_warning("GMTU lapsing override: Stripe API error for $email ({$history['error']}). Passing through to default."); + log_warning("GMTU lapsing override: payment history error for $email ({$history['error']}). Passing through to default."); return $should_lapse; } - // No GMTU payment history at all — not a GMTU member, do not interfere. - if (empty($history['month_keys']) && $history['first_ever_payment_timestamp'] === null) { + if (empty($history['month_keys'])) { + log_warning("GMTU lapsing override: no GMTU payment history found for $email. Member may have joined before this plugin was active. Passing through to default."); return $should_lapse; } - $sticky = is_sticky_lapsed($email); + $lapsed = is_lapsed($email); $now_utc = gmdate('Y-m'); - $standing = classify_membership_standing($history['month_keys'], $now_utc, $sticky); + $standing = classify_membership_standing($history['month_keys'], $now_utc, $lapsed); if ($standing === STANDING_LAPSED) { $trigger = $context['trigger'] ?? 'unknown'; $lapsed_at = gmdate('Y-m-d\TH:i:s\Z'); - mark_sticky_lapsed($email, $trigger, $lapsed_at); - log_info("GMTU lapsing override: $email is LAPSED ($trigger). Allowing lapse and setting sticky flag."); + mark_lapsed($email, $trigger, $lapsed_at); + log_info("GMTU lapsing override: $email is LAPSED ($trigger). Allowing lapse."); return true; } @@ -78,37 +82,37 @@ function register_lapsing_override(?callable $fetcher = null): void $history = $fetch($email); if ($history['error'] !== null) { - log_warning("GMTU lapsing override: Stripe API error for $email ({$history['error']}). Passing through to default."); + log_warning("GMTU lapsing override: payment history error for $email ({$history['error']}). Passing through to default."); return $should_unlapse; } - // No GMTU history — not a GMTU member, do not interfere. - if (empty($history['month_keys']) && $history['first_ever_payment_timestamp'] === null) { + if (empty($history['month_keys'])) { + log_warning("GMTU lapsing override: no GMTU payment history found for $email. Member may have joined before this plugin was active. Passing through to default."); return $should_unlapse; } - $sticky = is_sticky_lapsed($email); + $lapsed = is_lapsed($email); $now_utc = gmdate('Y-m'); - $standing = classify_membership_standing($history['month_keys'], $now_utc, $sticky); + $standing = classify_membership_standing($history['month_keys'], $now_utc, $lapsed); - if ($standing === STANDING_GOOD && !$sticky) { - log_info("GMTU lapsing override: $email is GOOD (not sticky). Allowing unlapse."); + if ($standing === STANDING_GOOD && !$lapsed) { + log_info("GMTU lapsing override: $email is GOOD. Allowing unlapse."); return true; } - log_info("GMTU lapsing override: $email is $standing (sticky=$sticky). Suppressing unlapse."); + log_info("GMTU lapsing override: $email is $standing (lapsed=$lapsed). Suppressing unlapse."); return false; }, 10, 3); // ------------------------------------------------------------------ - // Action: clear sticky-lapsed flag on explicit rejoin. + // Action: clear lapsed flag on explicit rejoin. // Priority 5 runs before the notification hooks (10, 20). // ------------------------------------------------------------------ add_action('ck_join_flow_success', function (array $data): void { $email = $data['email'] ?? null; - if ($email && is_sticky_lapsed($email)) { - clear_sticky_lapsed($email); - log_info("GMTU lapsing override: Cleared sticky-lapsed flag for $email on successful rejoin."); + if ($email && is_lapsed($email)) { + clear_lapsed($email); + log_info("GMTU lapsing override: Cleared lapsed flag for $email on successful rejoin."); } }, 5, 1); } diff --git a/src/MembershipStanding.php b/src/MembershipStanding.php index 791ef80..6b6209e 100644 --- a/src/MembershipStanding.php +++ b/src/MembershipStanding.php @@ -2,7 +2,7 @@ /** * GMTU membership standing classifier. * - * Pure functions — no I/O, no WordPress calls, fully unit-testable. + * Pure functions -- no I/O, no WordPress calls, fully unit-testable. * Implements GMTU's lapsing rules based on completed calendar months. * * @package CommonKnowledge\JoinBlock\Organisation\GMTU @@ -10,10 +10,10 @@ namespace CommonKnowledge\JoinBlock\Organisation\GMTU; -const STANDING_GOOD = 'good'; +const STANDING_GOOD = 'good'; const STANDING_EARLY_ARREARS = 'early_arrears'; -const STANDING_LAPSING = 'lapsing'; -const STANDING_LAPSED = 'lapsed'; +const STANDING_LAPSING = 'lapsing'; +const STANDING_LAPSED = 'lapsed'; /** * Convert a 'YYYY-MM' month key to a monotonic integer index for arithmetic. @@ -33,16 +33,12 @@ function month_key_to_index(string $month_key): int * The current in-progress month ($as_of_month_key) is excluded from the count. * The "last completed month" is the month immediately before $as_of_month_key. * - * @param string|null $last_paid_month_key 'YYYY-MM' of last paid month, or null if none. - * @param string $as_of_month_key Current month 'YYYY-MM'. + * @param string $last_paid_month_key 'YYYY-MM' of last paid month. + * @param string $as_of_month_key Current month 'YYYY-MM'. * @return int Number of missed completed months. */ -function count_missed_completed_months(?string $last_paid_month_key, string $as_of_month_key): int +function count_missed_completed_months(string $last_paid_month_key, string $as_of_month_key): int { - if ($last_paid_month_key === null) { - return 999999; - } - $last_completed_index = month_key_to_index($as_of_month_key) - 1; $last_paid_index = month_key_to_index($last_paid_month_key); @@ -68,29 +64,41 @@ function last_paid_month_before(array $paid_month_keys, string $as_of_month_key) /** * Classify a member's GMTU membership standing. * - * @param string[] $paid_month_keys Sorted 'YYYY-MM' strings for months with a successful payment. + * Standing is based on the number of completed calendar months since the + * member's last successful payment. The current in-progress month is excluded. + * + * If the member is flagged as lapsed (7+ missed months in a prior assessment), + * that state takes precedence -- a later payment does not reinstate them + * automatically. They must rejoin via the join form. + * + * @param string[] $paid_month_keys 'YYYY-MM' strings for months with a successful payment. * @param string $as_of_month_key Current month 'YYYY-MM' (the in-progress month, excluded). - * @param bool $is_sticky_lapsed Whether the member has previously been marked as lapsed. + * @param bool $is_lapsed Whether the member has previously been marked as lapsed. * @return string One of the STANDING_* constants. */ function classify_membership_standing( array $paid_month_keys, string $as_of_month_key, - bool $is_sticky_lapsed + bool $is_lapsed = false ): string { - // Sticky lapsed always wins — a later payment does not reinstate automatically. - if ($is_sticky_lapsed) { + // Lapsed always wins -- a later payment does not reinstate automatically. + if ($is_lapsed) { return STANDING_LAPSED; } $last_paid = last_paid_month_before($paid_month_keys, $as_of_month_key); // New member exception: first-ever payment is in the current month and there - // are no prior payments → treat as Good standing immediately. + // are no prior payments -- treat as Good standing immediately. if ($last_paid === null && in_array($as_of_month_key, $paid_month_keys, true)) { return STANDING_GOOD; } + // No payment history at all (before or during current month). + if ($last_paid === null) { + return STANDING_LAPSED; + } + $missed = count_missed_completed_months($last_paid, $as_of_month_key); if ($missed <= 2) { diff --git a/src/StickyLapsedStore.php b/src/StickyLapsedStore.php deleted file mode 100644 index efc6f92..0000000 --- a/src/StickyLapsedStore.php +++ /dev/null @@ -1,81 +0,0 @@ - $email, - 'lapsed_at' => $lapsed_at, - 'trigger' => $trigger, - ]); - - // Autoload false — only looked up on-demand during webhook processing. - update_option(sticky_lapsed_option_key($email), $value, false); -} - -/** - * Clear the sticky-lapsed flag for a member. - * - * Called when a member explicitly rejoins via the join form, allowing them - * to return to Good standing. - * - * @param string $email Member email address. - * @return void - */ -function clear_sticky_lapsed(string $email): void -{ - delete_option(sticky_lapsed_option_key($email)); -} diff --git a/src/StripePaymentHistory.php b/src/StripePaymentHistory.php index 80228c6..4005b27 100644 --- a/src/StripePaymentHistory.php +++ b/src/StripePaymentHistory.php @@ -5,7 +5,6 @@ * Fetches only charges that are identifiable as GMTU membership payments: * - status: succeeded * - metadata.id = 'join-gmtu' - * - application = GMTU_APPLICATION_ID (Action Network Stripe Connect app) * - not refunded * * Returns a list of 'YYYY-MM' month keys (UTC) for months that had at least @@ -16,12 +15,6 @@ namespace CommonKnowledge\JoinBlock\Organisation\GMTU; -/** - * Stripe Connect application ID for Action Network (GMTU's integration). - * Only charges created through this app are counted as GMTU payments. - */ -const GMTU_APPLICATION_ID = 'ca_A2Dv6C8pMeDm6Q0YSXlmSgtdL5tvArgN'; - /** * Metadata key that identifies a charge as a GMTU membership payment. */ @@ -39,18 +32,14 @@ * accidentally lapsing a member. * * @param string $email Member email address. - * @return array{ - * month_keys: string[], - * first_ever_payment_timestamp: int|null, - * error: string|null - * } + * @return array{month_keys: string[], error: string|null} */ function fetch_gmtu_payment_months(string $email): array { try { $stripe_key = \CommonKnowledge\JoinBlock\Settings::get('STRIPE_SECRET_KEY'); if (empty($stripe_key)) { - return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => 'Stripe secret key not configured']; + return ['month_keys' => [], 'error' => 'Stripe secret key not configured']; } \Stripe\Stripe::setApiKey($stripe_key); @@ -69,7 +58,6 @@ function fetch_gmtu_payment_months(string $email): array $has_more = $charges->has_more; foreach ($charges->data as $charge) { - // Skip last item fetched (used as cursor for next page) if ($has_more) { $params['starting_after'] = $charge->id; } @@ -84,7 +72,7 @@ function fetch_gmtu_payment_months(string $email): array } if (empty($timestamps)) { - return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => null]; + return ['month_keys' => [], 'error' => null]; } sort($timestamps); @@ -92,15 +80,12 @@ function fetch_gmtu_payment_months(string $email): array array_map(fn(int $ts) => gmdate('Y-m', $ts), $timestamps) )); - return [ - 'month_keys' => $month_keys, - 'first_ever_payment_timestamp' => $timestamps[0], - 'error' => null, - ]; + return ['month_keys' => $month_keys, 'error' => null]; + } catch (\Stripe\Exception\ApiErrorException $e) { - return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => $e->getMessage()]; + return ['month_keys' => [], 'error' => $e->getMessage()]; } catch (\Throwable $e) { - return ['month_keys' => [], 'first_ever_payment_timestamp' => null, 'error' => $e->getMessage()]; + return ['month_keys' => [], 'error' => $e->getMessage()]; } } @@ -110,7 +95,6 @@ function fetch_gmtu_payment_months(string $email): array * A charge qualifies if: * - status is 'succeeded' * - metadata.id equals 'join-gmtu' - * - application equals the Action Network Stripe Connect app ID * - it has not been refunded * * @param \Stripe\Charge $charge @@ -124,9 +108,6 @@ function is_gmtu_charge(\Stripe\Charge $charge): bool if (($charge->metadata->id ?? null) !== GMTU_METADATA_ID) { return false; } - if ($charge->application !== GMTU_APPLICATION_ID) { - return false; - } if ($charge->refunded || $charge->amount_refunded > 0) { return false; } diff --git a/tests/LapsedStoreTest.php b/tests/LapsedStoreTest.php new file mode 100644 index 0000000..08c0a35 --- /dev/null +++ b/tests/LapsedStoreTest.php @@ -0,0 +1,140 @@ +assertStringStartsWith('gmtu_lapsed_', $key); + } + + public function test_option_key_is_deterministic() + { + $this->assertSame( + lapsed_option_key('test@example.com'), + lapsed_option_key('test@example.com') + ); + } + + public function test_option_key_is_case_insensitive() + { + $this->assertSame( + lapsed_option_key('Test@Example.COM'), + lapsed_option_key('test@example.com') + ); + } + + public function test_option_key_suffix_is_sha256_of_lowercased_email() + { + $email = 'test@example.com'; + $expected = 'gmtu_lapsed_' . hash('sha256', strtolower(trim($email))); + $this->assertSame($expected, lapsed_option_key($email)); + } + + public function test_different_emails_produce_different_keys() + { + $this->assertNotSame( + lapsed_option_key('alice@example.com'), + lapsed_option_key('bob@example.com') + ); + } + + // --- is_lapsed --- + + public function test_is_lapsed_returns_false_when_option_missing() + { + Functions\expect('get_option') + ->once() + ->with(lapsed_option_key('member@example.com'), false) + ->andReturn(false); + + $this->assertFalse(is_lapsed('member@example.com')); + } + + public function test_is_lapsed_returns_true_when_option_exists() + { + $value = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-04-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option') + ->once() + ->andReturn($value); + + $this->assertTrue(is_lapsed('member@example.com')); + } + + // --- mark_lapsed --- + + public function test_mark_lapsed_calls_update_option_with_correct_key() + { + $email = 'member@example.com'; + $key = lapsed_option_key($email); + + Functions\expect('update_option') + ->once() + ->with($key, \Mockery::type('string'), false) + ->andReturn(true); + + mark_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); + $this->addToAssertionCount(1); + } + + public function test_mark_lapsed_stores_json_with_email() + { + $email = 'member@example.com'; + $stored = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$stored) { + $stored = json_decode($value, true); + return true; + }); + + mark_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); + + $this->assertSame($email, $stored['email']); + $this->assertArrayHasKey('lapsed_at', $stored); + $this->assertSame('invoice_payment_failed', $stored['trigger']); + } + + public function test_mark_lapsed_sets_autoload_false() + { + $autoloadValue = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$autoloadValue) { + $autoloadValue = $autoload; + return true; + }); + + mark_lapsed('member@example.com', 'test', '2026-04-01T00:00:00Z'); + + $this->assertFalse($autoloadValue); + } + + // --- clear_lapsed --- + + public function test_clear_lapsed_calls_delete_option_with_correct_key() + { + $email = 'member@example.com'; + $key = lapsed_option_key($email); + + Functions\expect('delete_option') + ->once() + ->with($key) + ->andReturn(true); + + clear_lapsed($email); + $this->addToAssertionCount(1); + } +} diff --git a/tests/LapsingOverrideTest.php b/tests/LapsingOverrideTest.php index 3aed5ee..f3d19c6 100644 --- a/tests/LapsingOverrideTest.php +++ b/tests/LapsingOverrideTest.php @@ -128,7 +128,7 @@ public function test_lapse_suppressed_for_good_standing() { // Last payment was last month — 0 missed months → Good $lastMonth = $this->monthOffset(-1); - Functions\expect('get_option')->andReturn(false); // not sticky-lapsed + Functions\expect('get_option')->andReturn(false); // not lapsed-lapsed [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -160,12 +160,12 @@ public function test_lapse_suppressed_for_lapsing() $this->assertFalse($result); } - public function test_lapse_allowed_and_sticky_flag_set_for_lapsed() + public function test_lapse_allowed_and_lapsed_flag_set_for_lapsed() { // 8 missed months → Lapsed $eightMonthsAgo = $this->monthOffset(-9); Functions\expect('get_option')->andReturn(false); - Functions\expect('update_option')->once()->andReturn(true); // mark_sticky_lapsed + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed_lapsed [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$eightMonthsAgo])); @@ -208,10 +208,10 @@ public function test_unlapse_passes_through_for_non_stripe_provider() $this->assertTrue($result); } - public function test_unlapse_allowed_for_good_standing_non_sticky() + public function test_unlapse_allowed_for_good_standing_non_lapsed() { $lastMonth = $this->monthOffset(-1); - Functions\expect('get_option')->andReturn(false); // not sticky-lapsed + Functions\expect('get_option')->andReturn(false); // not lapsed-lapsed [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -219,11 +219,11 @@ public function test_unlapse_allowed_for_good_standing_non_sticky() $this->assertTrue($result); } - public function test_unlapse_suppressed_for_sticky_lapsed() + public function test_unlapse_suppressed_for_lapsed_lapsed() { $lastMonth = $this->monthOffset(-1); - $sticky = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); - Functions\expect('get_option')->andReturn($sticky); // sticky-lapsed + $lapsed = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($lapsed); // lapsed-lapsed [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -254,20 +254,20 @@ public function test_unlapse_falls_through_on_stripe_api_error() // ck_join_flow_success // ------------------------------------------------------------------------- - public function test_success_hook_clears_sticky_lapsed_flag() + public function test_success_hook_clears_lapsed_lapsed_flag() { [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); $email = 'member@example.com'; - $sticky = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); - Functions\expect('get_option')->andReturn($sticky); + $lapsed = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($lapsed); Functions\expect('delete_option')->once()->andReturn(true); $success(['email' => $email]); $this->addToAssertionCount(1); // delete_option ->once() is the assertion } - public function test_success_hook_does_nothing_when_not_sticky_lapsed() + public function test_success_hook_does_nothing_when_not_lapsed_lapsed() { [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); @@ -299,9 +299,8 @@ private function fakeFetcher(array $monthKeys): callable { return function (string $email) use ($monthKeys): array { return [ - 'month_keys' => $monthKeys, - 'first_ever_payment_timestamp' => empty($monthKeys) ? null : 1, - 'error' => null, + 'month_keys' => $monthKeys, + 'error' => null, ]; }; } @@ -313,9 +312,8 @@ private function fakeFetcherWithError(string $errorMessage): callable { return function (string $email) use ($errorMessage): array { return [ - 'month_keys' => [], - 'first_ever_payment_timestamp' => null, - 'error' => $errorMessage, + 'month_keys' => [], + 'error' => $errorMessage, ]; }; } diff --git a/tests/MembershipStandingTest.php b/tests/MembershipStandingTest.php index 0260944..8350b34 100644 --- a/tests/MembershipStandingTest.php +++ b/tests/MembershipStandingTest.php @@ -74,12 +74,6 @@ public function test_count_missed_year_boundary() $this->assertSame(3, count_missed_completed_months('2025-11', '2026-03')); } - public function test_count_missed_null_last_paid_returns_large_number() - { - $result = count_missed_completed_months(null, '2026-04'); - $this->assertGreaterThan(1000, $result); - } - // --- last_paid_month_before --- public function test_last_paid_before_current_month() @@ -113,77 +107,78 @@ public function test_last_paid_uses_most_recent_prior_month() public function test_good_standing_paid_last_month() { - $result = classify_membership_standing(['2026-03'], '2026-04', false); + $result = classify_membership_standing(['2026-03'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } public function test_good_standing_paid_two_months_ago() { // Missed 1 completed month - $result = classify_membership_standing(['2026-02'], '2026-04', false); + $result = classify_membership_standing(['2026-02'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } public function test_good_standing_paid_three_months_ago() { - // Missed 2 completed months (Jan, Feb) — still good - $result = classify_membership_standing(['2026-01'], '2026-04', false); + // Missed 2 completed months (Jan, Feb) -- still good + $result = classify_membership_standing(['2026-01'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } public function test_early_arrears_three_missed_months() { // Missed Jan, Feb, Mar = 3 - $result = classify_membership_standing(['2025-12'], '2026-04', false); + $result = classify_membership_standing(['2025-12'], '2026-04'); $this->assertSame(STANDING_EARLY_ARREARS, $result); } public function test_lapsing_four_missed_months() { - $result = classify_membership_standing(['2025-11'], '2026-04', false); + $result = classify_membership_standing(['2025-11'], '2026-04'); $this->assertSame(STANDING_LAPSING, $result); } public function test_lapsing_six_missed_months() { - $result = classify_membership_standing(['2025-09'], '2026-04', false); + $result = classify_membership_standing(['2025-09'], '2026-04'); $this->assertSame(STANDING_LAPSING, $result); } public function test_lapsed_seven_missed_months() { - $result = classify_membership_standing(['2025-08'], '2026-04', false); + $result = classify_membership_standing(['2025-08'], '2026-04'); $this->assertSame(STANDING_LAPSED, $result); } public function test_lapsed_twelve_missed_months() { - $result = classify_membership_standing(['2025-03'], '2026-04', false); + $result = classify_membership_standing(['2025-03'], '2026-04'); $this->assertSame(STANDING_LAPSED, $result); } public function test_lapsed_no_payment_history_at_all() { - $result = classify_membership_standing([], '2026-04', false); + $result = classify_membership_standing([], '2026-04'); $this->assertSame(STANDING_LAPSED, $result); } public function test_new_member_first_payment_this_month_is_good() { // No prior payments, first payment is in the current month - $result = classify_membership_standing(['2026-04'], '2026-04', false); + $result = classify_membership_standing(['2026-04'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } - public function test_sticky_lapsed_overrides_good_payment() + public function test_lapsed_flag_overrides_good_payment() { - // Even with a recent payment, sticky-lapsed wins + // Even with a recent payment, lapsed flag wins -- must rejoin $result = classify_membership_standing(['2026-03'], '2026-04', true); $this->assertSame(STANDING_LAPSED, $result); } - public function test_sticky_lapsed_overrides_current_month_payment() + public function test_lapsed_flag_overrides_current_month_payment() { + // Lapsed flag wins even over a current-month payment $result = classify_membership_standing(['2026-04'], '2026-04', true); $this->assertSame(STANDING_LAPSED, $result); } @@ -191,23 +186,31 @@ public function test_sticky_lapsed_overrides_current_month_payment() public function test_year_boundary_three_missed() { // As-of March 2026, last paid November 2025: missed Dec, Jan, Feb = 3 - $result = classify_membership_standing(['2025-11'], '2026-03', false); + $result = classify_membership_standing(['2025-11'], '2026-03'); $this->assertSame(STANDING_EARLY_ARREARS, $result); } public function test_multiple_payments_uses_most_recent_for_missed_count() { - // Most recent prior payment is Feb, missed only March = 1 → good - $result = classify_membership_standing(['2025-06', '2026-02'], '2026-04', false); + // Most recent prior payment is Feb, missed only March = 1 -> good + $result = classify_membership_standing(['2025-06', '2026-02'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } - public function test_payment_in_current_month_does_not_override_existing_lapse_determination() - { - // Last payment before current month was July 2025 — 8 missed months → lapsed - // Even if there's also a payment in current month, last_paid_before is still July - // This covers the case where a sticky-lapsed member paid again without rejoining - $result = classify_membership_standing(['2025-07', '2026-04'], '2026-04', true); - $this->assertSame(STANDING_LAPSED, $result); + public function test_payment_within_six_months_resets_to_good() + { + // Missed 5 months (lapsing), then pays this month -> Good standing + // Last prior payment is from 6 months ago (lapsing band), + // but also paid this month -> current month resets via new-member-style logic + // Actually: last_paid_before is still old, so missed = 5 = lapsing. + // The current-month payment is treated as "new" only when there is no prior payment. + // With a prior payment, the missed count is based on the prior payment. + // This confirms: within the lapsing window, paying this month does NOT immediately + // reset (the completed-month count is based on prior payments). The counter resets + // next month once the current month becomes a completed month. + $fiveMonthsAgo = gmdate('Y-m', mktime(0, 0, 0, (int)gmdate('n') - 6, 1, (int)gmdate('Y'))); + $thisMonth = gmdate('Y-m'); + $result = classify_membership_standing([$fiveMonthsAgo, $thisMonth], $thisMonth); + $this->assertSame(STANDING_LAPSING, $result); } } diff --git a/tests/StickyLapsedStoreTest.php b/tests/StickyLapsedStoreTest.php deleted file mode 100644 index f6f1023..0000000 --- a/tests/StickyLapsedStoreTest.php +++ /dev/null @@ -1,140 +0,0 @@ -assertStringStartsWith('gmtu_sticky_lapsed_', $key); - } - - public function test_option_key_is_deterministic() - { - $this->assertSame( - sticky_lapsed_option_key('test@example.com'), - sticky_lapsed_option_key('test@example.com') - ); - } - - public function test_option_key_is_case_insensitive() - { - $this->assertSame( - sticky_lapsed_option_key('Test@Example.COM'), - sticky_lapsed_option_key('test@example.com') - ); - } - - public function test_option_key_suffix_is_sha256_of_lowercased_email() - { - $email = 'test@example.com'; - $expected = 'gmtu_sticky_lapsed_' . hash('sha256', strtolower(trim($email))); - $this->assertSame($expected, sticky_lapsed_option_key($email)); - } - - public function test_different_emails_produce_different_keys() - { - $this->assertNotSame( - sticky_lapsed_option_key('alice@example.com'), - sticky_lapsed_option_key('bob@example.com') - ); - } - - // --- is_sticky_lapsed --- - - public function test_is_sticky_lapsed_returns_false_when_option_missing() - { - Functions\expect('get_option') - ->once() - ->with(sticky_lapsed_option_key('member@example.com'), false) - ->andReturn(false); - - $this->assertFalse(is_sticky_lapsed('member@example.com')); - } - - public function test_is_sticky_lapsed_returns_true_when_option_exists() - { - $value = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-04-01T00:00:00Z']); - Functions\expect('get_option') - ->once() - ->andReturn($value); - - $this->assertTrue(is_sticky_lapsed('member@example.com')); - } - - // --- mark_sticky_lapsed --- - - public function test_mark_sticky_lapsed_calls_update_option_with_correct_key() - { - $email = 'member@example.com'; - $key = sticky_lapsed_option_key($email); - - Functions\expect('update_option') - ->once() - ->with($key, \Mockery::type('string'), false) - ->andReturn(true); - - mark_sticky_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); - $this->addToAssertionCount(1); // Brain Monkey ->once() expectation counts as the assertion - } - - public function test_mark_sticky_lapsed_stores_json_with_email() - { - $email = 'member@example.com'; - $stored = null; - - Functions\expect('update_option') - ->once() - ->andReturnUsing(function ($key, $value, $autoload) use (&$stored) { - $stored = json_decode($value, true); - return true; - }); - - mark_sticky_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); - - $this->assertSame($email, $stored['email']); - $this->assertArrayHasKey('lapsed_at', $stored); - $this->assertSame('invoice_payment_failed', $stored['trigger']); - } - - public function test_mark_sticky_lapsed_sets_autoload_false() - { - $autoloadValue = null; - - Functions\expect('update_option') - ->once() - ->andReturnUsing(function ($key, $value, $autoload) use (&$autoloadValue) { - $autoloadValue = $autoload; - return true; - }); - - mark_sticky_lapsed('member@example.com', 'test', '2026-04-01T00:00:00Z'); - - $this->assertFalse($autoloadValue); - } - - // --- clear_sticky_lapsed --- - - public function test_clear_sticky_lapsed_calls_delete_option_with_correct_key() - { - $email = 'member@example.com'; - $key = sticky_lapsed_option_key($email); - - Functions\expect('delete_option') - ->once() - ->with($key) - ->andReturn(true); - - clear_sticky_lapsed($email); - $this->addToAssertionCount(1); // Brain Monkey ->once() expectation counts as the assertion - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c50e286..6dd2c95 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -27,6 +27,6 @@ require_once dirname(__DIR__) . '/src/Tagging.php'; require_once dirname(__DIR__) . '/src/Notifications.php'; require_once dirname(__DIR__) . '/src/MembershipStanding.php'; -require_once dirname(__DIR__) . '/src/StickyLapsedStore.php'; +require_once dirname(__DIR__) . '/src/LapsedStore.php'; require_once dirname(__DIR__) . '/src/StripePaymentHistory.php'; require_once dirname(__DIR__) . '/src/LapsingOverride.php'; From ec70384a953ef4fae205d1b34de3db607ada5683 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 17:19:46 +0100 Subject: [PATCH 6/9] Fix doubled lapsed references in test names and comments --- tests/LapsingOverrideTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/LapsingOverrideTest.php b/tests/LapsingOverrideTest.php index f3d19c6..dfa14dc 100644 --- a/tests/LapsingOverrideTest.php +++ b/tests/LapsingOverrideTest.php @@ -128,7 +128,7 @@ public function test_lapse_suppressed_for_good_standing() { // Last payment was last month — 0 missed months → Good $lastMonth = $this->monthOffset(-1); - Functions\expect('get_option')->andReturn(false); // not lapsed-lapsed + Functions\expect('get_option')->andReturn(false); // not lapsed [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -165,7 +165,7 @@ public function test_lapse_allowed_and_lapsed_flag_set_for_lapsed() // 8 missed months → Lapsed $eightMonthsAgo = $this->monthOffset(-9); Functions\expect('get_option')->andReturn(false); - Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed_lapsed + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$eightMonthsAgo])); @@ -211,7 +211,7 @@ public function test_unlapse_passes_through_for_non_stripe_provider() public function test_unlapse_allowed_for_good_standing_non_lapsed() { $lastMonth = $this->monthOffset(-1); - Functions\expect('get_option')->andReturn(false); // not lapsed-lapsed + Functions\expect('get_option')->andReturn(false); // not lapsed [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -219,11 +219,11 @@ public function test_unlapse_allowed_for_good_standing_non_lapsed() $this->assertTrue($result); } - public function test_unlapse_suppressed_for_lapsed_lapsed() + public function test_unlapse_suppressed_for_lapsed() { $lastMonth = $this->monthOffset(-1); $lapsed = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); - Functions\expect('get_option')->andReturn($lapsed); // lapsed-lapsed + Functions\expect('get_option')->andReturn($lapsed); // lapsed [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -254,7 +254,7 @@ public function test_unlapse_falls_through_on_stripe_api_error() // ck_join_flow_success // ------------------------------------------------------------------------- - public function test_success_hook_clears_lapsed_lapsed_flag() + public function test_success_hook_clears_lapsed_flag() { [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); @@ -267,7 +267,7 @@ public function test_success_hook_clears_lapsed_lapsed_flag() $this->addToAssertionCount(1); // delete_option ->once() is the assertion } - public function test_success_hook_does_nothing_when_not_lapsed_lapsed() + public function test_success_hook_does_nothing_when_not_lapsed() { [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); From 983c4d3d5845df3d2e1d20d7e373c98a3a8baa8d Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 17:36:01 +0100 Subject: [PATCH 7/9] Redesign StripePaymentHistory to use Subscriptions and Invoices 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. --- src/StripePaymentHistory.php | 152 ++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 36 deletions(-) diff --git a/src/StripePaymentHistory.php b/src/StripePaymentHistory.php index 4005b27..342223e 100644 --- a/src/StripePaymentHistory.php +++ b/src/StripePaymentHistory.php @@ -2,13 +2,17 @@ /** * Stripe payment history fetcher for GMTU membership classification. * - * Fetches only charges that are identifiable as GMTU membership payments: - * - status: succeeded - * - metadata.id = 'join-gmtu' - * - not refunded + * Identifies GMTU membership payments by finding Stripe subscriptions whose + * items belong to a configured membership product, then reading the paid + * invoices for those subscriptions. + * + * This mirrors how the parent CK Join Flow plugin manages memberships: each + * membership plan tier has a Stripe Product, and members are subscribed to a + * Price under that Product. Plan product IDs are stored in WordPress options + * under the 'ck_join_flow_membership_plan_*' prefix. * * Returns a list of 'YYYY-MM' month keys (UTC) for months that had at least - * one qualifying charge. Used by LapsingOverride to classify member standing. + * one qualifying paid invoice. * * @package CommonKnowledge\JoinBlock\Organisation\GMTU */ @@ -16,16 +20,11 @@ namespace CommonKnowledge\JoinBlock\Organisation\GMTU; /** - * Metadata key that identifies a charge as a GMTU membership payment. - */ -const GMTU_METADATA_ID = 'join-gmtu'; - -/** - * Fetch the months in which a member made a successful GMTU payment. + * Fetch the months in which a member made a successful GMTU membership payment. * - * Queries the Stripe Charges API for all customers matching the email, - * filters to GMTU-scoped successful charges, and returns deduplicated - * 'YYYY-MM' month keys (UTC). + * Queries the Stripe Subscriptions and Invoices APIs for all customers + * matching the email, filtered to subscriptions belonging to configured + * membership products. Returns deduplicated 'YYYY-MM' month keys (UTC). * * Fails open: on Stripe API error, returns an error string so the caller * can fall through to the parent plugin's default behaviour rather than @@ -44,6 +43,11 @@ function fetch_gmtu_payment_months(string $email): array \Stripe\Stripe::setApiKey($stripe_key); + $product_ids = get_membership_product_ids(); + if (empty($product_ids)) { + return ['month_keys' => [], 'error' => 'No membership plans configured']; + } + // A single email address may have multiple Stripe customer records. $customers = \Stripe\Customer::all(['email' => $email, 'limit' => 10]); @@ -51,22 +55,25 @@ function fetch_gmtu_payment_months(string $email): array foreach ($customers->data as $customer) { $has_more = true; - $params = ['customer' => $customer->id, 'limit' => 100]; + $params = ['customer' => $customer->id, 'status' => 'all', 'limit' => 100]; while ($has_more) { - $charges = \Stripe\Charge::all($params); - $has_more = $charges->has_more; + $subscriptions = \Stripe\Subscription::all($params); + $has_more = $subscriptions->has_more; - foreach ($charges->data as $charge) { + foreach ($subscriptions->data as $subscription) { if ($has_more) { - $params['starting_after'] = $charge->id; + $params['starting_after'] = $subscription->id; } - if (!is_gmtu_charge($charge)) { + if (!is_gmtu_subscription($subscription, $product_ids)) { continue; } - $timestamps[] = $charge->created; + $timestamps = array_merge( + $timestamps, + fetch_paid_invoice_timestamps($customer->id, $subscription->id) + ); } } } @@ -90,26 +97,99 @@ function fetch_gmtu_payment_months(string $email): array } /** - * Determine whether a Stripe charge counts as a GMTU membership payment. + * Retrieve all configured membership product IDs from WordPress options. * - * A charge qualifies if: - * - status is 'succeeded' - * - metadata.id equals 'join-gmtu' - * - it has not been refunded + * Reads every option with the 'ck_join_flow_membership_plan_' prefix (stored + * by the parent plugin when plans are saved) and collects their + * 'stripe_product_id' values. * - * @param \Stripe\Charge $charge - * @return bool + * @return string[] Stripe product IDs for all configured membership plans. */ -function is_gmtu_charge(\Stripe\Charge $charge): bool +function get_membership_product_ids(): array { - if ($charge->status !== 'succeeded') { - return false; + global $wpdb; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like('ck_join_flow_membership_plan_') . '%' + ), + ARRAY_A + ); + + $product_ids = []; + foreach ($rows as $row) { + $plan = maybe_unserialize($row['option_value']); + if (is_array($plan) && !empty($plan['stripe_product_id'])) { + $product_ids[] = $plan['stripe_product_id']; + } } - if (($charge->metadata->id ?? null) !== GMTU_METADATA_ID) { - return false; + + return array_values(array_unique($product_ids)); +} + +/** + * Determine whether a Stripe subscription is a GMTU membership subscription. + * + * A subscription qualifies if at least one of its line items belongs to a + * configured membership product. + * + * @param \Stripe\Subscription $subscription + * @param string[] $product_ids Configured membership product IDs. + * @return bool + */ +function is_gmtu_subscription(\Stripe\Subscription $subscription, array $product_ids): bool +{ + foreach ($subscription->items->data as $item) { + $product = $item->price->product ?? null; + if (is_string($product) && in_array($product, $product_ids, true)) { + return true; + } + // Price may have an expanded Product object rather than a bare ID. + if ($product instanceof \Stripe\Product && in_array($product->id, $product_ids, true)) { + return true; + } } - if ($charge->refunded || $charge->amount_refunded > 0) { - return false; + return false; +} + +/** + * Fetch timestamps of paid invoices for a given subscription. + * + * Uses the Stripe Invoices API, paginating through all paid invoices for the + * specified customer and subscription. Returns the unix timestamp at which + * each invoice was paid (status_transitions->paid_at). + * + * @param string $customer_id Stripe customer ID. + * @param string $subscription_id Stripe subscription ID. + * @return int[] + */ +function fetch_paid_invoice_timestamps(string $customer_id, string $subscription_id): array +{ + $timestamps = []; + $has_more = true; + $params = [ + 'customer' => $customer_id, + 'subscription' => $subscription_id, + 'status' => 'paid', + 'limit' => 100, + ]; + + while ($has_more) { + $invoices = \Stripe\Invoice::all($params); + $has_more = $invoices->has_more; + + foreach ($invoices->data as $invoice) { + if ($has_more) { + $params['starting_after'] = $invoice->id; + } + + $paid_at = $invoice->status_transitions->paid_at ?? null; + if (is_int($paid_at) && $paid_at > 0) { + $timestamps[] = $paid_at; + } + } } - return true; + + return $timestamps; } From b78fd23f3ab475cbc32e8817a1a9d8f8c5361b2e Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Thu, 2 Apr 2026 17:44:29 +0100 Subject: [PATCH 8/9] Add StripePaymentHistory tests and make Stripe calls injectable - 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) --- composer.json | 3 +- composer.lock | 61 ++- src/StripePaymentHistory.php | 45 +- tests/StripePaymentHistoryTest.php | 663 +++++++++++++++++++++++++++++ tests/bootstrap.php | 7 + tests/stubs/Settings.php | 20 + 6 files changed, 785 insertions(+), 14 deletions(-) create mode 100644 tests/StripePaymentHistoryTest.php create mode 100644 tests/stubs/Settings.php diff --git a/composer.json b/composer.json index 57eaba6..ea9e2ce 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "phpunit/phpunit": "^9.6", "brain/monkey": "^2.6", "mockery/mockery": "^1.6", - "yoast/phpunit-polyfills": "^2.0" + "yoast/phpunit-polyfills": "^2.0", + "stripe/stripe-php": "^16.1" }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index ed835ab..136fcaf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d134a8a80e32d93d1564cca479d5f55d", + "content-hash": "5deee947e5548c55bfc8cd1791b9ff27", "packages": [], "packages-dev": [ { @@ -2005,6 +2005,65 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v16.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v16.6.0" + }, + "time": "2025-02-24T22:35:29+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/src/StripePaymentHistory.php b/src/StripePaymentHistory.php index 342223e..6c67472 100644 --- a/src/StripePaymentHistory.php +++ b/src/StripePaymentHistory.php @@ -30,11 +30,28 @@ * can fall through to the parent plugin's default behaviour rather than * accidentally lapsing a member. * - * @param string $email Member email address. + * The four optional callable parameters exist for testing. In production + * all four default to the real Stripe API and WordPress option calls. + * + * @param string $email Member email address. + * @param callable|null $product_ids_getter fn(): string[] — returns configured product IDs. + * @param callable|null $customer_lister fn(array $params): object — wraps Stripe Customer::all. + * @param callable|null $subscription_lister fn(array $params): object — wraps Stripe Subscription::all. + * @param callable|null $invoice_lister fn(array $params): object — wraps Stripe Invoice::all. * @return array{month_keys: string[], error: string|null} */ -function fetch_gmtu_payment_months(string $email): array -{ +function fetch_gmtu_payment_months( + string $email, + ?callable $product_ids_getter = null, + ?callable $customer_lister = null, + ?callable $subscription_lister = null, + ?callable $invoice_lister = null +): array { + $get_product_ids = $product_ids_getter ?? __NAMESPACE__ . '\get_membership_product_ids'; + $list_customers = $customer_lister ?? fn($p) => \Stripe\Customer::all($p); + $list_subscriptions = $subscription_lister ?? fn($p) => \Stripe\Subscription::all($p); + $list_invoices = $invoice_lister ?? fn($p) => \Stripe\Invoice::all($p); + try { $stripe_key = \CommonKnowledge\JoinBlock\Settings::get('STRIPE_SECRET_KEY'); if (empty($stripe_key)) { @@ -43,13 +60,13 @@ function fetch_gmtu_payment_months(string $email): array \Stripe\Stripe::setApiKey($stripe_key); - $product_ids = get_membership_product_ids(); + $product_ids = $get_product_ids(); if (empty($product_ids)) { return ['month_keys' => [], 'error' => 'No membership plans configured']; } // A single email address may have multiple Stripe customer records. - $customers = \Stripe\Customer::all(['email' => $email, 'limit' => 10]); + $customers = $list_customers(['email' => $email, 'limit' => 10]); $timestamps = []; @@ -58,7 +75,7 @@ function fetch_gmtu_payment_months(string $email): array $params = ['customer' => $customer->id, 'status' => 'all', 'limit' => 100]; while ($has_more) { - $subscriptions = \Stripe\Subscription::all($params); + $subscriptions = $list_subscriptions($params); $has_more = $subscriptions->has_more; foreach ($subscriptions->data as $subscription) { @@ -72,7 +89,7 @@ function fetch_gmtu_payment_months(string $email): array $timestamps = array_merge( $timestamps, - fetch_paid_invoice_timestamps($customer->id, $subscription->id) + fetch_paid_invoice_timestamps($customer->id, $subscription->id, $list_invoices) ); } } @@ -160,12 +177,16 @@ function is_gmtu_subscription(\Stripe\Subscription $subscription, array $product * specified customer and subscription. Returns the unix timestamp at which * each invoice was paid (status_transitions->paid_at). * - * @param string $customer_id Stripe customer ID. - * @param string $subscription_id Stripe subscription ID. + * @param string $customer_id Stripe customer ID. + * @param string $subscription_id Stripe subscription ID. + * @param callable $invoice_lister fn(array $params): object — wraps Invoice::all. * @return int[] */ -function fetch_paid_invoice_timestamps(string $customer_id, string $subscription_id): array -{ +function fetch_paid_invoice_timestamps( + string $customer_id, + string $subscription_id, + callable $invoice_lister +): array { $timestamps = []; $has_more = true; $params = [ @@ -176,7 +197,7 @@ function fetch_paid_invoice_timestamps(string $customer_id, string $subscription ]; while ($has_more) { - $invoices = \Stripe\Invoice::all($params); + $invoices = $invoice_lister($params); $has_more = $invoices->has_more; foreach ($invoices->data as $invoice) { diff --git a/tests/StripePaymentHistoryTest.php b/tests/StripePaymentHistoryTest.php new file mode 100644 index 0000000..0461d69 --- /dev/null +++ b/tests/StripePaymentHistoryTest.php @@ -0,0 +1,663 @@ + (object)['id' => $id], $ids); + return (object)['data' => $data, 'has_more' => false]; + } + + /** + * Build a real \Stripe\Subscription object with a single item. + * + * @param string $id Subscription ID. + * @param string $product_id The product ID on the price (bare string form). + * @return \Stripe\Subscription + */ + private function fakeSubscription(string $id, string $product_id): \Stripe\Subscription + { + return \Stripe\Subscription::constructFrom([ + 'id' => $id, + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_' . $id, + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_test', + 'object' => 'price', + 'product' => $product_id, + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + } + + /** + * Build a fake subscription list response. + * + * @param \Stripe\Subscription[] $subscriptions + * @param bool $has_more + * @return object + */ + private function fakeSubscriptionList(array $subscriptions, bool $has_more = false): object + { + return (object)['data' => $subscriptions, 'has_more' => $has_more]; + } + + /** + * Build a fake Invoice list response. + * + * @param int[] $paid_at_timestamps Unix timestamps for paid invoices. + * @param bool $has_more + * @return object + */ + private function fakeInvoiceList(array $paid_at_timestamps, bool $has_more = false): object + { + $data = array_map(function (int $ts) { + static $i = 0; + $i++; + return (object)[ + 'id' => "in_$i", + 'status_transitions' => (object)['paid_at' => $ts], + ]; + }, $paid_at_timestamps); + return (object)['data' => $data, 'has_more' => $has_more]; + } + + /** + * Build a fake wpdb that returns the given rows from get_results(). + */ + private function fakeWpdb(array $rows): object + { + return new class ($rows) { + public string $options = 'wp_options'; + + public function __construct(private array $rows) + { + } + + public function prepare(string $query): string + { + return $query; + } + + public function esc_like(string $str): string + { + return $str; + } + + public function get_results(string $query, $output): array + { + return $this->rows; + } + }; + } + + /** + * Unix timestamp for a given 'YYYY-MM-DD' string. + */ + private function ts(string $date): int + { + return (int)strtotime($date . 'T12:00:00Z'); + } + + // ------------------------------------------------------------------------- + // get_membership_product_ids + // ------------------------------------------------------------------------- + + public function test_product_ids_empty_when_no_plans() + { + global $wpdb; + $wpdb = $this->fakeWpdb([]); + + $ids = get_membership_product_ids(); + + $this->assertSame([], $ids); + } + + public function test_product_ids_extracted_from_single_plan() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_abc'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_abc'], $ids); + } + + public function test_product_ids_extracted_from_multiple_plans() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_1'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_2'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_1', 'prod_2'], $ids); + } + + public function test_product_ids_deduplicated() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_same'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_same'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_same'], $ids); + } + + public function test_product_ids_skips_plans_without_stripe_product_id() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['label' => 'No Stripe Yet'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_ok'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_ok'], $ids); + } + + // ------------------------------------------------------------------------- + // is_gmtu_subscription + // ------------------------------------------------------------------------- + + public function test_is_gmtu_subscription_matches_bare_product_id() + { + $sub = $this->fakeSubscription('sub_1', 'prod_gmtu'); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_no_match_when_different_product() + { + $sub = $this->fakeSubscription('sub_1', 'prod_other'); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_no_match_with_empty_product_id_list() + { + $sub = $this->fakeSubscription('sub_1', 'prod_gmtu'); + + $this->assertFalse(is_gmtu_subscription($sub, [])); + } + + public function test_is_gmtu_subscription_matches_one_of_multiple_product_ids() + { + $sub = $this->fakeSubscription('sub_1', 'prod_b'); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_a', 'prod_b', 'prod_c'])); + } + + public function test_is_gmtu_subscription_with_no_items() + { + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_empty', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_with_expanded_product_object() + { + // When the Product is expanded, price->product is a Product object, not a string. + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_expanded', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_1', + 'object' => 'price', + 'product' => [ + 'id' => 'prod_gmtu', + 'object' => 'product', + 'name' => 'Membership: Monthly', + ], + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_matches_first_of_multiple_items() + { + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_multi', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => ['id' => 'price_1', 'object' => 'price', 'product' => 'prod_other'], + ], + [ + 'id' => 'si_2', + 'object' => 'subscription_item', + 'price' => ['id' => 'price_2', 'object' => 'price', 'product' => 'prod_gmtu'], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + // ------------------------------------------------------------------------- + // fetch_paid_invoice_timestamps + // ------------------------------------------------------------------------- + + public function test_invoice_timestamps_returned_from_single_page() + { + $ts = $this->ts('2026-01-15'); + $lister = fn($params) => $this->fakeInvoiceList([$ts]); + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertSame([$ts], $result); + } + + public function test_invoice_timestamps_empty_when_no_invoices() + { + $lister = fn($params) => $this->fakeInvoiceList([]); + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertSame([], $result); + } + + public function test_invoice_timestamps_skips_null_paid_at() + { + $invoiceList = (object)[ + 'data' => [ + (object)['id' => 'in_1', 'status_transitions' => (object)['paid_at' => null]], + (object)['id' => 'in_2', 'status_transitions' => (object)['paid_at' => 0]], + (object)['id' => 'in_3', 'status_transitions' => (object)['paid_at' => $this->ts('2026-02-10')]], + ], + 'has_more' => false, + ]; + $lister = fn($params) => $invoiceList; + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertCount(1, $result); + $this->assertSame($this->ts('2026-02-10'), $result[0]); + } + + public function test_invoice_timestamps_paginates_correctly() + { + $ts1 = $this->ts('2026-01-15'); + $ts2 = $this->ts('2026-02-15'); + + $page1 = (object)[ + 'data' => [(object)['id' => 'in_1', 'status_transitions' => (object)['paid_at' => $ts1]]], + 'has_more' => true, + ]; + $page2 = (object)[ + 'data' => [(object)['id' => 'in_2', 'status_transitions' => (object)['paid_at' => $ts2]]], + 'has_more' => false, + ]; + + $calls = 0; + $lister = function ($params) use ($page1, $page2, &$calls) { + $calls++; + return $calls === 1 ? $page1 : $page2; + }; + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertCount(2, $result); + $this->assertContains($ts1, $result); + $this->assertContains($ts2, $result); + $this->assertSame(2, $calls); + } + + public function test_invoice_pagination_sets_starting_after() + { + $ts1 = $this->ts('2026-01-15'); + $ts2 = $this->ts('2026-02-15'); + + $page1 = (object)[ + 'data' => [(object)['id' => 'in_page1_last', 'status_transitions' => (object)['paid_at' => $ts1]]], + 'has_more' => true, + ]; + $page2 = (object)[ + 'data' => [(object)['id' => 'in_page2', 'status_transitions' => (object)['paid_at' => $ts2]]], + 'has_more' => false, + ]; + + $capturedParams = []; + $calls = 0; + $lister = function ($params) use ($page1, $page2, &$calls, &$capturedParams) { + $capturedParams[] = $params; + $calls++; + return $calls === 1 ? $page1 : $page2; + }; + + fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + // Second call must include starting_after = last invoice ID of page 1 + $this->assertArrayHasKey('starting_after', $capturedParams[1]); + $this->assertSame('in_page1_last', $capturedParams[1]['starting_after']); + } + + // ------------------------------------------------------------------------- + // fetch_gmtu_payment_months — error paths + // ------------------------------------------------------------------------- + + public function test_returns_error_when_stripe_key_not_configured() + { + unset($_ENV['STRIPE_SECRET_KEY']); + + $result = fetch_gmtu_payment_months('member@example.com'); + + $this->assertNull($result['month_keys'] ? null : null); // just checking structure + $this->assertSame([], $result['month_keys']); + $this->assertNotNull($result['error']); + $this->assertStringContainsString('Stripe secret key', $result['error']); + } + + public function test_returns_error_when_no_membership_products_configured() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => [], // no product IDs + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNotNull($result['error']); + $this->assertStringContainsString('No membership plans', $result['error']); + } + + public function test_returns_empty_month_keys_when_no_customers_found() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'nobody@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList([]), // no customers + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_error_on_stripe_api_exception() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => throw new \RuntimeException('Connection timeout'), + ); + + $this->assertSame([], $result['month_keys']); + $this->assertSame('Connection timeout', $result['error']); + } + + // ------------------------------------------------------------------------- + // fetch_gmtu_payment_months — main logic + // ------------------------------------------------------------------------- + + public function test_returns_empty_when_no_qualifying_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $nonGmtuSub = $this->fakeSubscription('sub_other', 'prod_different'); + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$nonGmtuSub]), + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_month_keys_from_paid_invoices() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $ts = $this->ts('2026-01-15'); + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList([$ts]), + ); + + $this->assertSame(['2026-01'], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_multiple_month_keys_sorted() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $timestamps = [ + $this->ts('2026-03-10'), + $this->ts('2026-01-05'), + $this->ts('2026-02-20'), + ]; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList($timestamps), + ); + + $this->assertSame(['2026-01', '2026-02', '2026-03'], $result['month_keys']); + } + + public function test_deduplicates_month_keys_from_multiple_invoices_same_month() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $timestamps = [ + $this->ts('2026-01-01'), + $this->ts('2026-01-15'), // same month + $this->ts('2026-01-31'), // same month + ]; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList($timestamps), + ); + + $this->assertSame(['2026-01'], $result['month_keys']); + } + + public function test_aggregates_invoices_across_multiple_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub1 = $this->fakeSubscription('sub_1', 'prod_gmtu'); + $sub2 = $this->fakeSubscription('sub_2', 'prod_gmtu'); + + $call = 0; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$sub1, $sub2]), + function ($p) use (&$call) { + $call++; + $ts = $call === 1 ? $this->ts('2026-01-10') : $this->ts('2026-03-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + $this->assertSame(['2026-01', '2026-03'], $result['month_keys']); + } + + public function test_aggregates_invoices_across_multiple_customers() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + + $customerCall = 0; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1', 'cus_2']), + fn($p) => $this->fakeSubscriptionList([$sub]), + function ($p) use (&$customerCall) { + $customerCall++; + $ts = $customerCall === 1 ? $this->ts('2026-01-10') : $this->ts('2026-02-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + $this->assertSame(['2026-01', '2026-02'], $result['month_keys']); + } + + public function test_skips_non_gmtu_subscriptions_but_collects_gmtu_ones() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $otherSub = $this->fakeSubscription('sub_other', 'prod_other'); + + $invoiceCallIds = []; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub, $otherSub]), + function ($p) use (&$invoiceCallIds) { + $invoiceCallIds[] = $p['subscription']; + return $this->fakeInvoiceList([$this->ts('2026-01-10')]); + } + ); + + // Invoice lister should only be called for the GMTU subscription. + $this->assertSame(['sub_gmtu'], $invoiceCallIds); + $this->assertSame(['2026-01'], $result['month_keys']); + } + + public function test_paginates_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub1 = $this->fakeSubscription('sub_1', 'prod_gmtu'); + $sub2 = $this->fakeSubscription('sub_2', 'prod_gmtu'); + + $page1 = (object)['data' => [$sub1], 'has_more' => true]; + $page2 = (object)['data' => [$sub2], 'has_more' => false]; + + $subCall = 0; + $capturedParams = []; + $invoiceCall = 0; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + function ($p) use ($page1, $page2, &$subCall, &$capturedParams) { + $capturedParams[] = $p; + $subCall++; + return $subCall === 1 ? $page1 : $page2; + }, + function ($p) use (&$invoiceCall) { + $invoiceCall++; + $ts = $invoiceCall === 1 ? $this->ts('2026-01-10') : $this->ts('2026-02-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + // Both subscription pages were fetched. + $this->assertSame(2, $subCall); + // Second page request used starting_after = first subscription's ID. + $this->assertSame('sub_1', $capturedParams[1]['starting_after']); + // Both subscriptions' invoices were collected. + $this->assertSame(['2026-01', '2026-02'], $result['month_keys']); + } + + protected function tear_down(): void + { + unset($_ENV['STRIPE_SECRET_KEY']); + parent::tear_down(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6dd2c95..124628b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,10 @@ // Composer autoloader (loads Brain Monkey, Mockery, PHPUnit polyfills) require_once dirname(__DIR__) . '/vendor/autoload.php'; +// Stub the parent plugin's Settings class so StripePaymentHistory can call +// Settings::get() without the full WordPress/CarbonFields stack. +require_once __DIR__ . '/stubs/Settings.php'; + // Define WordPress constants the plugin expects if (!defined('ABSPATH')) { define('ABSPATH', '/tmp/wordpress/'); @@ -15,6 +19,9 @@ if (!defined('DAY_IN_SECONDS')) { define('DAY_IN_SECONDS', 86400); } +if (!defined('ARRAY_A')) { + define('ARRAY_A', 'ARRAY_A'); +} // Load all source files in dependency order. require_once dirname(__DIR__) . '/src/Logger.php'; diff --git a/tests/stubs/Settings.php b/tests/stubs/Settings.php new file mode 100644 index 0000000..44712bd --- /dev/null +++ b/tests/stubs/Settings.php @@ -0,0 +1,20 @@ + Date: Fri, 3 Apr 2026 17:25:57 +0100 Subject: [PATCH 9/9] Improve test quality: remove redundant tests, add critical missing coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed (low-value, tautological, or interior duplicates): - LapsedStoreTest: test_option_key_is_deterministic, test_option_key_has_correct_prefix, test_different_emails_produce_different_keys, test_mark_lapsed_calls_update_option_with_correct_key - MembershipStandingTest: test_good_standing_paid_two_months_ago (1-missed interior duplicate), test_lapsed_twelve_missed_months (deep-interior duplicate) - LapsingOverrideTest: consolidated three separate hook-registration tests into test_registers_all_three_hooks Added (critical missing coverage): - MembershipStandingTest: test_count_missed_clamped_to_zero_when_last_paid_is_current_month (verifies the max(0,...) clamp that prevents negative counts for new members) - MembershipStandingTest: test_lapsed_seven_missed_months_across_year_boundary (year × 12 + month arithmetic; without it year-boundary crossing returns wrong band) - LapsingOverrideTest: test_lapse_suppressed_at_exactly_six_missed_months and test_lapse_allowed_at_exactly_seven_missed_months (the two sides of the threshold where suppress flips to allow — highest off-by-one risk point) - LapsingOverrideTest: test_lapse_allowed_when_flag_already_set_despite_recent_payment (verifies the $is_lapsed flag is threaded into classify_membership_standing in the lapse callback, not just the unlapse callback) - LapsingOverrideTest: test_success_hook_does_nothing_when_email_missing_from_data (verifies the $email && guard; without it is_lapsed(null) is called on every successful join, regardless of email presence) - LapsingOverrideTest: test_full_rejoin_cycle_clears_lapsed_flag_and_allows_unlapse (end-to-end scenario through all three hooks with shared stateful storage; the only test that exercises their interaction rather than each in isolation) - StripePaymentHistoryTest: test_is_gmtu_subscription_returns_false_when_product_is_null (null product must not accidentally match a configured product ID) Also: removed no-op assertNull assertion from test_returns_error_when_stripe_key_not_configured; renamed test_payment_within_six_months_resets_to_good to accurately describe what the test actually asserts. All 8 new tests verified by deliberate production code breaks (Red step confirmed). --- tests/LapsedStoreTest.php | 49 +------ tests/LapsingOverrideTest.php | 201 +++++++++++++++++++++++------ tests/MembershipStandingTest.php | 51 +++++--- tests/StripePaymentHistoryTest.php | 29 ++++- 4 files changed, 225 insertions(+), 105 deletions(-) diff --git a/tests/LapsedStoreTest.php b/tests/LapsedStoreTest.php index 08c0a35..6544774 100644 --- a/tests/LapsedStoreTest.php +++ b/tests/LapsedStoreTest.php @@ -12,18 +12,12 @@ class LapsedStoreTest extends TestCase { // --- lapsed_option_key --- - public function test_option_key_has_correct_prefix() + public function test_option_key_format_is_prefix_plus_sha256_of_lowercased_email() { - $key = lapsed_option_key('test@example.com'); - $this->assertStringStartsWith('gmtu_lapsed_', $key); - } + $email = 'test@example.com'; + $expected = 'gmtu_lapsed_' . hash('sha256', strtolower(trim($email))); - public function test_option_key_is_deterministic() - { - $this->assertSame( - lapsed_option_key('test@example.com'), - lapsed_option_key('test@example.com') - ); + $this->assertSame($expected, lapsed_option_key($email)); } public function test_option_key_is_case_insensitive() @@ -34,21 +28,6 @@ public function test_option_key_is_case_insensitive() ); } - public function test_option_key_suffix_is_sha256_of_lowercased_email() - { - $email = 'test@example.com'; - $expected = 'gmtu_lapsed_' . hash('sha256', strtolower(trim($email))); - $this->assertSame($expected, lapsed_option_key($email)); - } - - public function test_different_emails_produce_different_keys() - { - $this->assertNotSame( - lapsed_option_key('alice@example.com'), - lapsed_option_key('bob@example.com') - ); - } - // --- is_lapsed --- public function test_is_lapsed_returns_false_when_option_missing() @@ -73,23 +52,9 @@ public function test_is_lapsed_returns_true_when_option_exists() // --- mark_lapsed --- - public function test_mark_lapsed_calls_update_option_with_correct_key() - { - $email = 'member@example.com'; - $key = lapsed_option_key($email); - - Functions\expect('update_option') - ->once() - ->with($key, \Mockery::type('string'), false) - ->andReturn(true); - - mark_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); - $this->addToAssertionCount(1); - } - - public function test_mark_lapsed_stores_json_with_email() + public function test_mark_lapsed_stores_json_with_email_trigger_and_timestamp() { - $email = 'member@example.com'; + $email = 'member@example.com'; $stored = null; Functions\expect('update_option') @@ -127,7 +92,7 @@ public function test_mark_lapsed_sets_autoload_false() public function test_clear_lapsed_calls_delete_option_with_correct_key() { $email = 'member@example.com'; - $key = lapsed_option_key($email); + $key = lapsed_option_key($email); Functions\expect('delete_option') ->once() diff --git a/tests/LapsingOverrideTest.php b/tests/LapsingOverrideTest.php index dfa14dc..113f150 100644 --- a/tests/LapsingOverrideTest.php +++ b/tests/LapsingOverrideTest.php @@ -58,55 +58,29 @@ private function registerAndCaptureCallbacks(?callable $fetcher = null): array // Registration // ------------------------------------------------------------------------- - public function test_registers_lapse_filter() + public function test_registers_all_three_hooks() { - $registered = []; + $registered_filters = []; + $registered_actions = []; Functions\expect('add_filter') ->twice() - ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered) { - $registered[] = $hook; + ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered_filters) { + $registered_filters[] = $hook; return true; }); - Functions\expect('add_action')->once()->andReturn(true); - - register_lapsing_override(); - - $this->assertContains('ck_join_flow_should_lapse_member', $registered); - } - - public function test_registers_unlapse_filter() - { - $registered = []; - - Functions\expect('add_filter') - ->twice() - ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered) { - $registered[] = $hook; - return true; - }); - Functions\expect('add_action')->once()->andReturn(true); - - register_lapsing_override(); - - $this->assertContains('ck_join_flow_should_unlapse_member', $registered); - } - - public function test_registers_success_action() - { - $registered = []; - - Functions\expect('add_filter')->twice()->andReturn(true); Functions\expect('add_action') ->once() - ->andReturnUsing(function ($hook, $cb, $priority) use (&$registered) { - $registered[] = $hook; + ->andReturnUsing(function ($hook, $cb, $priority) use (&$registered_actions) { + $registered_actions[] = $hook; return true; }); register_lapsing_override(); - $this->assertContains('ck_join_flow_success', $registered); + $this->assertContains('ck_join_flow_should_lapse_member', $registered_filters); + $this->assertContains('ck_join_flow_should_unlapse_member', $registered_filters); + $this->assertContains('ck_join_flow_success', $registered_actions); } // ------------------------------------------------------------------------- @@ -160,6 +134,21 @@ public function test_lapse_suppressed_for_lapsing() $this->assertFalse($result); } + /** + * The critical threshold: exactly 6 missed months is still Lapsing — suppressed. + * One fewer missed month than the boundary at which lapsing becomes Lapsed. + */ + public function test_lapse_suppressed_at_exactly_six_missed_months() + { + $sixMissed = $this->monthOffset(-7); // paid 7 months ago = 6 completed missed months + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$sixMissed])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + public function test_lapse_allowed_and_lapsed_flag_set_for_lapsed() { // 8 missed months → Lapsed @@ -173,6 +162,47 @@ public function test_lapse_allowed_and_lapsed_flag_set_for_lapsed() $this->assertTrue($result); } + /** + * The critical threshold: exactly 7 missed months is Lapsed — lapse allowed. + * One more missed month than the upper bound of the Lapsing band. + */ + public function test_lapse_allowed_at_exactly_seven_missed_months() + { + $sevenMissed = $this->monthOffset(-8); // paid 8 months ago = 7 completed missed months + Functions\expect('get_option')->andReturn(false); + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$sevenMissed])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + + /** + * When the lapsed flag is already set in wp_options, a new payment does NOT + * reinstate the member. The flag takes precedence over payment history — + * the member must rejoin explicitly via the join form. + * + * This verifies classify_membership_standing's $is_lapsed=true path is + * correctly threaded through the lapse callback. + */ + public function test_lapse_allowed_when_flag_already_set_despite_recent_payment() + { + $lastMonth = $this->monthOffset(-1); // would be Good standing without the flag + $lapsedJson = json_encode([ + 'email' => 'member@example.com', + 'lapsed_at' => '2026-01-01T00:00:00Z', + 'trigger' => 'invoice_payment_failed', + ]); + Functions\expect('get_option')->andReturn($lapsedJson); // flag is already set + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed writes again + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + public function test_lapse_falls_through_on_stripe_api_error() { [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcherWithError('Connection timeout')); @@ -221,9 +251,9 @@ public function test_unlapse_allowed_for_good_standing_non_lapsed() public function test_unlapse_suppressed_for_lapsed() { - $lastMonth = $this->monthOffset(-1); - $lapsed = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); - Functions\expect('get_option')->andReturn($lapsed); // lapsed + $lastMonth = $this->monthOffset(-1); + $lapsedJson = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($lapsedJson); // lapsed [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); @@ -258,8 +288,8 @@ public function test_success_hook_clears_lapsed_flag() { [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); - $email = 'member@example.com'; - $lapsed = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + $email = 'member@example.com'; + $lapsed = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); Functions\expect('get_option')->andReturn($lapsed); Functions\expect('delete_option')->once()->andReturn(true); @@ -278,6 +308,95 @@ public function test_success_hook_does_nothing_when_not_lapsed() $this->addToAssertionCount(1); } + /** + * When $data contains no 'email' key, the success hook must be a complete + * no-op: it must not call get_option or delete_option, and must not throw. + */ + public function test_success_hook_does_nothing_when_email_missing_from_data() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + Functions\expect('get_option')->never(); + Functions\expect('delete_option')->never(); + + $success([]); // no 'email' key + $this->addToAssertionCount(1); + } + + // ------------------------------------------------------------------------- + // Full rejoin cycle + // ------------------------------------------------------------------------- + + /** + * End-to-end scenario: member lapses → rejoins via join form → next payment + * triggers an allowed unlapse. + * + * This is the most important user journey in the lapsing system. It verifies + * that the three hooks (lapse, success, unlapse) interact correctly through + * shared persistent state, not just in isolation. + * + * Sequence: + * 1. Lapse hook fires for a member with 8+ missed months → lapse allowed, flag set. + * 2. Unlapse hook fires before rejoin → suppressed (flag still set). + * 3. Member completes join form → success hook clears the flag. + * 4. Unlapse hook fires after rejoin with a recent payment → allowed. + */ + public function test_full_rejoin_cycle_clears_lapsed_flag_and_allows_unlapse() + { + $email = 'member@example.com'; + $eightMonthsAgo = $this->monthOffset(-9); // 8 missed months → Lapsed + $lastMonth = $this->monthOffset(-1); // 0 missed months → Good + + // Simulate stateful WordPress option storage in memory. + $store = []; + + Functions\expect('get_option') + ->times(4) // lapse, unlapse-before, success, unlapse-after + ->andReturnUsing(function ($key, $default = false) use (&$store) { + return $store[$key] ?? $default; + }); + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$store) { + $store[$key] = $value; + return true; + }); + Functions\expect('delete_option') + ->once() + ->andReturnUsing(function ($key) use (&$store) { + unset($store[$key]); + return true; + }); + + // The fetcher returns lapsed-era history during lapse/unlapse-before, + // then recent history after the member rejoins. + $phase = 'lapsed'; + [$lapse, $unlapse, $success] = $this->registerAndCaptureCallbacks( + function (string $e) use (&$phase, $eightMonthsAgo, $lastMonth) { + return [ + 'month_keys' => $phase === 'lapsed' ? [$eightMonthsAgo] : [$lastMonth], + 'error' => null, + ]; + } + ); + + // Step 1: Lapse fires → member is Lapsed, flag written to store. + $lapseResult = $lapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($lapseResult, 'Step 1: lapse must be allowed for Lapsed member'); + + // Step 2: Unlapse fires before rejoin → must be suppressed (flag is set). + $unlapseBeforeRejoin = $unlapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($unlapseBeforeRejoin, 'Step 2: unlapse must be suppressed while member is still lapsed'); + + // Step 3: Member completes join form → success hook clears the flag. + $phase = 'rejoined'; + $success(['email' => $email]); + + // Step 4: Unlapse fires after rejoin with a recent payment → must be allowed. + $unlapseAfterRejoin = $unlapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($unlapseAfterRejoin, 'Step 4: unlapse must be allowed after explicit rejoin'); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/tests/MembershipStandingTest.php b/tests/MembershipStandingTest.php index 8350b34..f347119 100644 --- a/tests/MembershipStandingTest.php +++ b/tests/MembershipStandingTest.php @@ -74,6 +74,18 @@ public function test_count_missed_year_boundary() $this->assertSame(3, count_missed_completed_months('2025-11', '2026-03')); } + /** + * When last paid equals the current (in-progress) month, missed = 0. + * + * The calculation is max(0, as_of_index - 1 - as_of_index) = max(0, -1) = 0. + * This clamp is load-bearing for the new-member exception path: without it, + * a first-time payment in the current month would produce a negative count. + */ + public function test_count_missed_clamped_to_zero_when_last_paid_is_current_month() + { + $this->assertSame(0, count_missed_completed_months('2026-04', '2026-04')); + } + // --- last_paid_month_before --- public function test_last_paid_before_current_month() @@ -111,16 +123,9 @@ public function test_good_standing_paid_last_month() $this->assertSame(STANDING_GOOD, $result); } - public function test_good_standing_paid_two_months_ago() - { - // Missed 1 completed month - $result = classify_membership_standing(['2026-02'], '2026-04'); - $this->assertSame(STANDING_GOOD, $result); - } - - public function test_good_standing_paid_three_months_ago() + public function test_good_standing_two_missed_months() { - // Missed 2 completed months (Jan, Feb) -- still good + // 2 missed = upper boundary of Good band $result = classify_membership_standing(['2026-01'], '2026-04'); $this->assertSame(STANDING_GOOD, $result); } @@ -150,9 +155,16 @@ public function test_lapsed_seven_missed_months() $this->assertSame(STANDING_LAPSED, $result); } - public function test_lapsed_twelve_missed_months() + /** + * Year-boundary case: 7 missed months spanning two calendar years. + * + * Last paid May 2025 → missed Jun, Jul, Aug, Sep, Oct, Nov, Dec 2025 = 7 months. + * As-of January 2026. month_key_to_index must use year × 12 + month for + * the arithmetic to produce the correct count across the year boundary. + */ + public function test_lapsed_seven_missed_months_across_year_boundary() { - $result = classify_membership_standing(['2025-03'], '2026-04'); + $result = classify_membership_standing(['2025-05'], '2026-01'); $this->assertSame(STANDING_LAPSED, $result); } @@ -197,17 +209,14 @@ public function test_multiple_payments_uses_most_recent_for_missed_count() $this->assertSame(STANDING_GOOD, $result); } - public function test_payment_within_six_months_resets_to_good() + /** + * A current-month payment does NOT immediately reset standing when the + * member has prior payment history. The missed count is still derived from + * the most recent *prior* month's payment. The status resets next month once + * the current month becomes a completed month. + */ + public function test_current_month_payment_does_not_reset_standing_when_prior_history_exists() { - // Missed 5 months (lapsing), then pays this month -> Good standing - // Last prior payment is from 6 months ago (lapsing band), - // but also paid this month -> current month resets via new-member-style logic - // Actually: last_paid_before is still old, so missed = 5 = lapsing. - // The current-month payment is treated as "new" only when there is no prior payment. - // With a prior payment, the missed count is based on the prior payment. - // This confirms: within the lapsing window, paying this month does NOT immediately - // reset (the completed-month count is based on prior payments). The counter resets - // next month once the current month becomes a completed month. $fiveMonthsAgo = gmdate('Y-m', mktime(0, 0, 0, (int)gmdate('n') - 6, 1, (int)gmdate('Y'))); $thisMonth = gmdate('Y-m'); $result = classify_membership_standing([$fiveMonthsAgo, $thisMonth], $thisMonth); diff --git a/tests/StripePaymentHistoryTest.php b/tests/StripePaymentHistoryTest.php index 0461d69..9752c24 100644 --- a/tests/StripePaymentHistoryTest.php +++ b/tests/StripePaymentHistoryTest.php @@ -250,6 +250,34 @@ public function test_is_gmtu_subscription_with_no_items() $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); } + public function test_is_gmtu_subscription_returns_false_when_product_is_null() + { + // Stripe may return a price without a product (e.g. archived/detached product). + // A null product must never match any configured product ID. + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_null_product', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_1', + 'object' => 'price', + // 'product' key absent → resolves to null + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + public function test_is_gmtu_subscription_with_expanded_product_object() { // When the Product is expanded, price->product is a Product object, not a string. @@ -416,7 +444,6 @@ public function test_returns_error_when_stripe_key_not_configured() $result = fetch_gmtu_payment_months('member@example.com'); - $this->assertNull($result['month_keys'] ? null : null); // just checking structure $this->assertSame([], $result['month_keys']); $this->assertNotNull($result['error']); $this->assertStringContainsString('Stripe secret key', $result['error']);