Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/join-block/join.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hello@commonknowledge.coop>
* Text Domain: common-knowledge-join-flow
* License: GPLv2 or later
Expand Down
4 changes: 3 additions & 1 deletion packages/join-block/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
86 changes: 86 additions & 0 deletions packages/join-block/src/Services/JoinService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -431,13 +436,94 @@ 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 {
do_action('ck_join_flow_member_unlapsed', $email, $context);
}
}

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;
Expand Down
17 changes: 17 additions & 0 deletions packages/join-block/src/Services/StripeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)");
Expand All @@ -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);
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/join-block/src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand Down
70 changes: 70 additions & 0 deletions packages/join-block/tests/LapsingFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
}
}
14 changes: 9 additions & 5 deletions packages/join-block/tests/SessionLockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
4 changes: 2 additions & 2 deletions packages/join-block/tests/SessionLockTestProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 1 addition & 1 deletion packages/join-flow/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
Loading