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.8
* Version: 1.4.9
* 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.8
Stable tag: 1.4.9
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.9 =
* Self-heal stale membership plan slugs after v1.4.4 slug-format change
= 1.4.8 =
* Improve custom fields UX
= 1.4.7 =
Expand Down
31 changes: 30 additions & 1 deletion packages/join-block/src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,36 @@ public static function getMembershipPlanId($membership_plan)

public static function getMembershipPlan($id)
{
return get_option('ck_join_flow_membership_plan_' . $id);
$plan = get_option('ck_join_flow_membership_plan_' . $id);
if ($plan) {
return $plan;
}

// Self-healing fallback for the v1.4.4 slug-format change.
// Plans saved before commit 0161e59 live under label-only keys
// (e.g. ck_join_flow_membership_plan_low-wage-payment-level) but the
// frontend now requests them by label_frequency_currency. Until an
// admin re-saves the settings page (which re-keys via
// saveMembershipPlans()), scan all stored plans and match by
// recomputed ID so create-subscription keeps working.
global $wpdb;
if (!$wpdb) {
return $plan;
}
$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
);
foreach ($rows as $row) {
$candidate = maybe_unserialize($row['option_value']);
if (is_array($candidate) && self::getMembershipPlanId($candidate) === $id) {
return $candidate;
}
}
return $plan;
}

public static function getMembershipPlanByPriceId($priceId)
Expand Down
169 changes: 169 additions & 0 deletions packages/join-block/tests/SettingsMembershipPlanLookupTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

namespace CommonKnowledge\JoinBlock\Tests;

use Brain\Monkey;
use CommonKnowledge\JoinBlock\Settings;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

/**
* Regression tests for Settings::getMembershipPlan() following the slug-format
* change in v1.4.4 (commit 0161e59).
*
* Before v1.4.4, getMembershipPlanId() returned `sanitize_title($plan['label'])`,
* so plans were saved to the WP options table under keys like:
*
* ck_join_flow_membership_plan_low-wage-payment-level
*
* v1.4.4 changed the slug format to include frequency and currency:
*
* ck_join_flow_membership_plan_low-wage-payment-level_monthly_gbp
*
* The frontend dropdown now submits the new-format ID, but options saved
* before the upgrade still live under the old key. Until an admin opens
* the Carbon Fields settings page and re-saves (which re-keys the options
* via Settings::saveMembershipPlans()), every /stripe/create-subscription
* call throws "Selected plan is not in the list of plans, this is unexpected"
* and no one can buy a membership.
*
* These tests pin the self-healing fallback: getMembershipPlan() must locate
* a plan whose recomputed ID matches the requested ID, even when the option
* is stored under a stale (legacy-format) key.
*/
class SettingsMembershipPlanLookupTest extends TestCase
{
use MockeryPHPUnitIntegration;

protected function setUp(): void
{
parent::setUp();
Monkey\setUp();

// sanitize_title in WP lowercases and replaces non-alphanumerics with
// hyphens. A faithful-enough stand-in for our purposes.
Monkey\Functions\when('sanitize_title')->alias(function ($title) {
$title = strtolower((string) $title);
$title = preg_replace('/[^a-z0-9]+/', '-', $title);
return trim($title, '-');
});

// maybe_unserialize: WP returns the value unchanged if not a serialized
// string. Our $wpdb stub already returns arrays, so passthrough is fine.
Monkey\Functions\when('maybe_unserialize')->returnArg();
}

protected function tearDown(): void
{
global $wpdb;
$wpdb = null;
Monkey\tearDown();
parent::tearDown();
}

/**
* The bug, distilled: a plan saved under the legacy slug
* `low-wage-payment-level` cannot be found by the new-format ID
* `low-wage-payment-level_monthly_gbp`, so create-subscription throws.
*
* Mirrors the production failure on tenantsunion.org.uk on 2026-05-01.
*/
public function testFindsPlanSavedUnderLegacySlugByNewFormatId(): void
{
$newFormatId = 'low-wage-payment-level_monthly_gbp';
$legacyOptionName = 'ck_join_flow_membership_plan_low-wage-payment-level';

$storedPlan = [
'label' => 'Low Wage Payment Level',
'frequency' => 'monthly',
'currency' => 'GBP',
'amount' => 5,
'stripe_price_id' => 'price_1SUUcWKspBtY4V5GYVE0YfiA',
];

// Direct lookup under the new-format key misses (option doesn't exist).
Monkey\Functions\expect('get_option')
->with('ck_join_flow_membership_plan_' . $newFormatId)
->andReturn(false);

// Fallback: scan options table for any ck_join_flow_membership_plan_*
// and recompute each plan's ID. The legacy row is what we want to find.
$this->stubWpdbWithRows([
['option_value' => $storedPlan],
]);

$plan = Settings::getMembershipPlan($newFormatId);

$this->assertIsArray($plan, 'Expected fallback to recover legacy-keyed plan');
$this->assertSame('price_1SUUcWKspBtY4V5GYVE0YfiA', $plan['stripe_price_id']);
$this->assertSame('Low Wage Payment Level', $plan['label']);
}

/**
* Direct hit must remain the fast path: when the option exists under the
* canonical new-format key, no $wpdb scan should be needed.
*/
public function testReturnsDirectMatchWithoutScanningOptionsTable(): void
{
$id = 'low-wage-payment-level_monthly_gbp';
$storedPlan = [
'label' => 'Low Wage Payment Level',
'frequency' => 'monthly',
'currency' => 'GBP',
'stripe_price_id' => 'price_direct',
];

Monkey\Functions\expect('get_option')
->once()
->with('ck_join_flow_membership_plan_' . $id)
->andReturn($storedPlan);

// No $wpdb stub: if the implementation tries to scan, the test will
// blow up on the missing global, proving the fast path was skipped.
global $wpdb;
$wpdb = null;

$plan = Settings::getMembershipPlan($id);

$this->assertSame('price_direct', $plan['stripe_price_id']);
}

/**
* If no option exists and no stored plan recomputes to the requested ID,
* the function must return a falsy value rather than a stale plan.
* Guards against the fallback returning the wrong tier when slugs collide.
*/
public function testReturnsFalsyWhenNoStoredPlanMatches(): void
{
Monkey\Functions\expect('get_option')
->andReturn(false);

$this->stubWpdbWithRows([
['option_value' => [
'label' => 'Some Other Plan',
'frequency' => 'yearly',
'currency' => 'EUR',
]],
]);

$plan = Settings::getMembershipPlan('low-wage-payment-level_monthly_gbp');

$this->assertEmpty($plan);
}

/**
* Stub global $wpdb so getMembershipPlan's fallback can iterate options.
* Returns whatever rows are passed in regardless of the SQL/LIKE pattern —
* the production query already filters to ck_join_flow_membership_plan_*.
*/
private function stubWpdbWithRows(array $rows): void
{
global $wpdb;
$wpdb = Mockery::mock();
$wpdb->options = 'wp_options';
$wpdb->shouldReceive('esc_like')->andReturnUsing(fn($s) => $s);
$wpdb->shouldReceive('prepare')->andReturnUsing(fn($sql) => $sql);
$wpdb->shouldReceive('get_results')->andReturn($rows);
}
}
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.8"
release: "1.4.9"
});

if (getEnv('USE_CHARGEBEE')) {
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is a monorepo containing packages that together provide a full membership a
- `packages/join-block` — A WordPress Gutenberg plugin containing the join form block(s) and the backend logic that processes memberships, payments, and CRM integrations.
- `packages/join-e2e` — End-to-end tests (Puppeteer).

**Current version:** 1.4.6
**Current version:** 1.4.9

---

Expand Down
Loading