From 99a083ecc1659ffa7d79894040ef5bc96fc70832 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 15:55:08 +0100 Subject: [PATCH 1/3] Self-heal stale membership plan slugs in getMembershipPlan v1.4.4 (commit 0161e59) changed getMembershipPlanId() to compose IDs from label_frequency_currency. The frontend now submits the new-format ID, but plans saved before the upgrade still live in the WP options table under the old label-only key. Until an admin re-saves the settings page, /stripe/create-subscription throws "Selected plan is not in the list of plans" and no one can buy a membership. Make getMembershipPlan() fall back to scanning all ck_join_flow_membership_plan_* options and matching by recomputed ID. Test pins the production failure mode and the negative-match guard. --- packages/join-block/src/Settings.php | 31 +++- .../SettingsMembershipPlanLookupTest.php | 169 ++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 packages/join-block/tests/SettingsMembershipPlanLookupTest.php diff --git a/packages/join-block/src/Settings.php b/packages/join-block/src/Settings.php index 3e8389b1..6c7a30bb 100644 --- a/packages/join-block/src/Settings.php +++ b/packages/join-block/src/Settings.php @@ -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) diff --git a/packages/join-block/tests/SettingsMembershipPlanLookupTest.php b/packages/join-block/tests/SettingsMembershipPlanLookupTest.php new file mode 100644 index 00000000..1d18cd7c --- /dev/null +++ b/packages/join-block/tests/SettingsMembershipPlanLookupTest.php @@ -0,0 +1,169 @@ +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); + } +} From 118f572ea0e12158749f815dd17109f365a7e313 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 16:05:14 +0100 Subject: [PATCH 2/3] Bump version to 1.4.9 --- packages/join-block/join.php | 2 +- packages/join-block/readme.txt | 4 +++- packages/join-flow/src/index.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index a31c8ab2..612d0360 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.8 + * Version: 1.4.9 * 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 1e41c0c4..7edb8ce4 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.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 @@ -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 = diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index 1ea4f12f..8dce8108 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.8" + release: "1.4.9" }); if (getEnv('USE_CHARGEBEE')) { From e885a410aa0c049ed70524c86b3d56b45c93435d Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Fri, 1 May 2026 16:06:54 +0100 Subject: [PATCH 3/3] Bump version to 1.4.9 in README.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 8b639c7d..76916426 100644 --- a/readme.md +++ b/readme.md @@ -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 ---