From 513bc1184e0420f4c39e3fadc743c12024035073 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Tue, 12 May 2026 16:32:23 +0200 Subject: [PATCH] feat: add lapsing tag --- packages/join-block/join.php | 2 +- packages/join-block/readme.txt | 4 +- .../join-block/src/Services/JoinService.php | 86 +++++++++++++++++++ .../join-block/src/Services/StripeService.php | 17 ++++ packages/join-block/src/Settings.php | 3 + .../join-block/tests/LapsingFilterTest.php | 70 +++++++++++++++ packages/join-block/tests/SessionLockTest.php | 14 +-- .../tests/SessionLockTestProcess.php | 4 +- packages/join-flow/src/index.tsx | 2 +- 9 files changed, 192 insertions(+), 10 deletions(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 00350ae0..9f2767f9 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -3,7 +3,7 @@ /** * Plugin Name: Common Knowledge Join Flow * Description: Common Knowledge join flow plugin. - * Version: 1.4.18 + * Version: 1.4.19 * Author: Common Knowledge * Text Domain: common-knowledge-join-flow * License: GPLv2 or later diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index b99a2128..ddd59011 100644 --- a/packages/join-block/readme.txt +++ b/packages/join-block/readme.txt @@ -4,7 +4,7 @@ Tags: membership, subscription, join Contributors: commonknowledgecoop Requires at least: 5.4 Tested up to: 6.8 -Stable tag: 1.4.18 +Stable tag: 1.4.19 Requires PHP: 8.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,8 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled == Changelog == += 1.4.19 = +* Add "Lapsing" tag applied to members when their payments start failing, before they fully lapse = 1.4.18 = * Update button styling to allow changing chevron color = 1.4.17 = diff --git a/packages/join-block/src/Services/JoinService.php b/packages/join-block/src/Services/JoinService.php index 1c3ba2ad..4d3b334e 100644 --- a/packages/join-block/src/Services/JoinService.php +++ b/packages/join-block/src/Services/JoinService.php @@ -365,6 +365,11 @@ public static function shouldUnlapseMember($email, $context = [], $default = tru return (bool) apply_filters('ck_join_flow_should_unlapse_member', $default, $email, $context); } + public static function shouldMarkMemberLapsing($email, $context = [], $default = true) + { + return (bool) apply_filters('ck_join_flow_should_mark_member_lapsing', $default, $email, $context); + } + public static function toggleMemberLapsed($email, $lapsed = true, $paymentDate = null, $context = []) { global $joinBlockLog; @@ -431,6 +436,16 @@ public static function toggleMemberLapsed($email, $lapsed = true, $paymentDate = } } + // Whether the member is recovering or progressing to fully lapsed, the + // transient "lapsing" state is over - clear that tag if it is set. + if (Settings::get("LAPSING_TAG")) { + try { + self::toggleMemberLapsing($email, false, $context); + } catch (\Exception $exception) { + $joinBlockLog->error("Error clearing lapsing tag for $email: " . $exception->getMessage()); + } + } + if ($lapsed) { do_action('ck_join_flow_member_lapsed', $email, $context); } else { @@ -438,6 +453,77 @@ public static function toggleMemberLapsed($email, $lapsed = true, $paymentDate = } } + public static function toggleMemberLapsing($email, $lapsing = true, $context = []) + { + global $joinBlockLog; + + $action = $lapsing ? "Marking" : "Unmarking"; + $done = $lapsing ? "Marked" : "Unmarked"; + $joinBlockLog->info("$action member $email as lapsing"); + + if (!Settings::get("LAPSING_TAG")) { + $joinBlockLog->warning("Skipping lapsing tag update for $email - no lapsing tag has been set. Configure it under WP Admin > CK Join Flow > Membership Plans > Lapsing Tag."); + return; + } + + if (Settings::get("USE_ACTION_NETWORK")) { + $joinBlockLog->info("$action member $email as lapsing in Action Network"); + try { + if ($lapsing) { + ActionNetworkService::addTag($email, Settings::get("LAPSING_TAG")); + } else { + ActionNetworkService::removeTag($email, Settings::get("LAPSING_TAG")); + } + $joinBlockLog->info("$done member $email as lapsing in Action Network"); + } catch (\Exception $exception) { + $joinBlockLog->error("Action Network error for email $email: " . $exception->getMessage()); + throw $exception; + } + } + + if (Settings::get("USE_MAILCHIMP")) { + $joinBlockLog->info("$action member $email as lapsing in Mailchimp"); + try { + if ($lapsing) { + MailchimpService::addTag($email, Settings::get("LAPSING_TAG")); + } else { + MailchimpService::removeTag($email, Settings::get("LAPSING_TAG")); + } + $joinBlockLog->info("$done member $email as lapsing in Mailchimp"); + } catch (\Exception $exception) { + $joinBlockLog->error("Mailchimp error for email $email: " . $exception->getMessage()); + } + } + + if (Settings::get("USE_ZETKIN")) { + $clientId = Settings::get("ZETKIN_CLIENT_ID"); + $clientSecret = Settings::get("ZETKIN_CLIENT_SECRET"); + $jwt = Settings::get("ZETKIN_JWT"); + if ($clientId && $clientSecret && $jwt) { + $joinBlockLog->info("$action member $email as lapsing in Zetkin"); + try { + if ($lapsing) { + ZetkinService::addTag($email, Settings::get("LAPSING_TAG")); + } else { + ZetkinService::removeTag($email, Settings::get("LAPSING_TAG")); + } + $joinBlockLog->info("$done member $email as lapsing in Zetkin"); + } catch (\Exception $exception) { + $joinBlockLog->error("Zetkin error for email $email: " . $exception->getMessage()); + throw $exception; + } + } else { + $joinBlockLog->warning("Can't $action member $email as lapsing in Zetkin - need OAuth credentials"); + } + } + + if ($lapsing) { + do_action('ck_join_flow_member_lapsing', $email, $context); + } else { + do_action('ck_join_flow_member_unlapsing', $email, $context); + } + } + private static function handleGocardless($data) { global $joinBlockLog; diff --git a/packages/join-block/src/Services/StripeService.php b/packages/join-block/src/Services/StripeService.php index 520a9bca..62f95dfe 100644 --- a/packages/join-block/src/Services/StripeService.php +++ b/packages/join-block/src/Services/StripeService.php @@ -789,6 +789,15 @@ public static function handleWebhook($event) } } else { $joinBlockLog->info("Payment failed for Stripe customer $customerId, retry scheduled."); + if (!empty($invoice['customer'])) { + $email = self::getEmailForCustomer($customerId); + if ($email) { + $context = ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed_retry_scheduled', 'event' => $event]; + if (JoinService::shouldMarkMemberLapsing($email, $context)) { + JoinService::toggleMemberLapsing($email, true, $context); + } + } + } } break; @@ -861,10 +870,12 @@ public static function handleWebhook($event) if ($email) { $activeStatuses = ['active', 'trialing']; $lapsedStatuses = ['unpaid', 'incomplete_expired']; + $lapsingStatuses = ['past_due']; $wasActive = in_array($previousStatus, $activeStatuses); $isNowActive = in_array($currentStatus, $activeStatuses); $isNowLapsed = in_array($currentStatus, $lapsedStatuses); + $isNowLapsing = in_array($currentStatus, $lapsingStatuses); if (!$wasActive && $isNowActive) { $joinBlockLog->info("Subscription reactivated for $email ($previousStatus -> $currentStatus)"); @@ -878,6 +889,12 @@ public static function handleWebhook($event) if (JoinService::shouldLapseMember($email, $context)) { JoinService::toggleMemberLapsed($email, true, null, $context); } + } elseif ($isNowLapsing) { + $joinBlockLog->info("Subscription lapsing for $email ($previousStatus -> $currentStatus)"); + $context = ['provider' => 'stripe', 'trigger' => 'subscription_status_changed', 'event' => $event]; + if (JoinService::shouldMarkMemberLapsing($email, $context)) { + JoinService::toggleMemberLapsing($email, true, $context); + } } } } diff --git a/packages/join-block/src/Settings.php b/packages/join-block/src/Settings.php index 3eee3281..cd4ca79c 100644 --- a/packages/join-block/src/Settings.php +++ b/packages/join-block/src/Settings.php @@ -85,6 +85,9 @@ public static function init() Field::make('text', 'lapsed_tag') ->set_default_value("Lapsed - failed payment") ->set_help_text("Will be applied to members in Action Network, Mailchimp and Zetkin if they delete or do not pay their subscription"), + Field::make('text', 'lapsing_tag') + ->set_default_value("Lapsing") + ->set_help_text("Will be applied to members in Action Network, Mailchimp and Zetkin when their payments start failing, before they reach the lapsed state. Removed once they recover or become fully lapsed."), Field::make('separator', 'membership_plans_sep', 'Membership Plans'), $membership_plans, ]; diff --git a/packages/join-block/tests/LapsingFilterTest.php b/packages/join-block/tests/LapsingFilterTest.php index 5b146154..ccfd063f 100644 --- a/packages/join-block/tests/LapsingFilterTest.php +++ b/packages/join-block/tests/LapsingFilterTest.php @@ -165,4 +165,74 @@ public function testActionsReceiveContext(): void JoinService::toggleMemberLapsed('test@example.com', true, null, ['provider' => 'stripe']); } + + // --- shouldMarkMemberLapsing --- + + public function testShouldMarkLapsingReturnsTrueByDefault(): void + { + $this->assertTrue(JoinService::shouldMarkMemberLapsing('test@example.com')); + } + + public function testLapsingFilterCanOverrideTrueDefaultToFalse(): void + { + Filters\expectApplied('ck_join_flow_should_mark_member_lapsing') + ->once() + ->andReturn(false); + + $this->assertFalse(JoinService::shouldMarkMemberLapsing('test@example.com', [], true)); + } + + public function testLapsingFilterReceivesContext(): void + { + Filters\expectApplied('ck_join_flow_should_mark_member_lapsing') + ->once() + ->with(true, 'test@example.com', \Mockery::on(fn($c) => $c['trigger'] === 'invoice_payment_failed_retry_scheduled')) + ->andReturn(true); + + JoinService::shouldMarkMemberLapsing('test@example.com', ['trigger' => 'invoice_payment_failed_retry_scheduled']); + } + + // --- toggleMemberLapsing action hooks --- + + public function testLapsingTagSkippedWhenNotConfigured(): void + { + Actions\expectDone('ck_join_flow_member_lapsing')->never(); + + JoinService::toggleMemberLapsing('test@example.com', true, []); + } + + public function testLapsingActionFiresWhenConfigured(): void + { + Monkey\Functions\when('carbon_get_theme_option') + ->alias(fn($key) => $key === 'lapsing_tag' ? 'Lapsing' : ''); + + Actions\expectDone('ck_join_flow_member_lapsing') + ->once() + ->with('test@example.com', \Mockery::type('array')); + + JoinService::toggleMemberLapsing('test@example.com', true, []); + } + + public function testUnlapsingActionFiresWhenConfigured(): void + { + Monkey\Functions\when('carbon_get_theme_option') + ->alias(fn($key) => $key === 'lapsing_tag' ? 'Lapsing' : ''); + + Actions\expectDone('ck_join_flow_member_unlapsing') + ->once() + ->with('test@example.com', \Mockery::type('array')); + + JoinService::toggleMemberLapsing('test@example.com', false, []); + } + + public function testTogglingLapsedClearsLapsingTag(): void + { + Monkey\Functions\when('carbon_get_theme_option') + ->alias(fn($key) => $key === 'lapsing_tag' ? 'Lapsing' : ''); + + Actions\expectDone('ck_join_flow_member_unlapsing')->once(); + Actions\expectDone('ck_join_flow_member_lapsed')->once(); + + JoinService::toggleMemberLapsed('test@example.com', true, null, []); + } } diff --git a/packages/join-block/tests/SessionLockTest.php b/packages/join-block/tests/SessionLockTest.php index 3fe8844e..34ee583d 100644 --- a/packages/join-block/tests/SessionLockTest.php +++ b/packages/join-block/tests/SessionLockTest.php @@ -34,13 +34,17 @@ public function testSessionLock(): void // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $logs = file_get_contents($logFile); - # Ensure that logs print: - # WORKING -> DONE -> Unlocked ... $sessionId -> WORKING -> DONE -> Unlocked ... $sessionId - # Proving sequential execution + # The two processes use the same session lock, so they must not overlap. + # If they ran in parallel the log would read WORKING -> WORKING -> DONE -> DONE. + # Sequential execution instead reads WORKING -> DONE -> WORKING -> DONE + # (the second process can only start WORKING once the first has released the lock). + # The session id is included on each line to scope the match to this test run. + # We don't assert on the "Unlocked" line here: it is logged after flock() releases + # the lock, so the next process can legitimately log "WORKING" before it appears. $matched = preg_match( - "#WORKING.*DONE.*Unlocked.*$sessionId.*WORKING.*DONE.*Unlocked.*$sessionId#s", + "#WORKING $sessionId.*DONE $sessionId.*WORKING $sessionId.*DONE $sessionId#s", $logs ); - $this->assertTrue((bool) $matched, "Should have expected sequence of logs"); + $this->assertTrue((bool) $matched, "Should have expected sequence of logs:\n$logs"); } } diff --git a/packages/join-block/tests/SessionLockTestProcess.php b/packages/join-block/tests/SessionLockTestProcess.php index 4aab67c7..11c23ab4 100644 --- a/packages/join-block/tests/SessionLockTestProcess.php +++ b/packages/join-block/tests/SessionLockTestProcess.php @@ -34,9 +34,9 @@ $lockFile = JoinService::lockSession($sessionId); -$joinBlockLog->info("WORKING"); +$joinBlockLog->info("WORKING $sessionId"); // Simulate work sleep(1); -$joinBlockLog->info("DONE"); +$joinBlockLog->info("DONE $sessionId"); JoinService::unlockSession($lockFile); diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index d4261283..59ea6290 100644 --- a/packages/join-flow/src/index.tsx +++ b/packages/join-flow/src/index.tsx @@ -24,7 +24,7 @@ const init = () => { const sentryDsn = getEnvStr("SENTRY_DSN") Sentry.init({ dsn: sentryDsn, - release: "1.4.18" + release: "1.4.19" }); if (getEnv('USE_CHARGEBEE')) {