diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 0410cb3..a7950b8 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.0 + * Version: 1.4.1 * 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 2c6663e..fb3ec17 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.0 +Stable tag: 1.4.1 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.1 = +* Allow cards on individual forms when Direct Debit Only is set globally = 1.4.0 = * Add Donation Supporter Mode: a new block setting that puts donation first, before personal details and payment, skipping the membership plan step entirely * Supporter mode: donation frequency (monthly/one-off) and tier selection driven by block-level membership plans diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index 9296ada..f1d1a11 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -168,49 +168,58 @@ private static function registerJoinFormBlock() $custom_fields = self::createCustomFieldsField(); + $block_fields = [ + Field::make('separator', 'ck_join_form', 'CK Join Form'), + $joined_page_association, + Field::make('checkbox', 'require_address')->set_default_value(true), + Field::make('checkbox', 'hide_address') + ->set_help_text('Check to completely hide the address section from the form.'), + Field::make('checkbox', 'require_phone_number')->set_default_value(true), + Field::make('checkbox', 'ask_for_additional_donation') + ->set_help_text('Has no effect when Donation Supporter Mode is enabled.'), + Field::make('checkbox', 'donation_supporter_mode') + ->set_help_text( + 'Enable Supporter Mode: shows donation frequency and amount first, ' . + 'before personal details and payment. Skips the membership plan step. ' . + 'Requires block-level membership plans to be configured (used as donation tiers). ' . + 'One-off donations require Stripe. They are not available with Direct Debit only.' + ), + Field::make('checkbox', 'hide_home_address_copy') + ->set_help_text('Check to hide the copy that explains why the address is collected.'), + Field::make('checkbox', 'include_skip_payment_button') + ->set_help_text( + 'Check to include an additional button on the first page to skip to the thank you page ' . + '(which could include a form for additional questions)' + ), + Field::make('checkbox', 'is_update_flow', 'Is Update Flow (e.g. for existing members)') + ->set_help_text( + 'Check to skip collecting member details (e.g. name, address). If checked, this page must ' . + 'be linked to with the email URL search parameter set, e.g. /become-paid-member/?email=someone@example.com. ' . + 'This can be achieved by using the CK Join Form Link block on a landing page, and linking to this page.' + ), + $custom_fields, + $custom_membership_plans, + Field::make('text', 'custom_webhook_url') + ->set_help_text('Leave blank to use the default Join Complete webhook from the settings page.'), + Field::make('text', 'custom_sidebar_heading') + ->set_help_text('Leave blank to use the default from settings page.'), + Field::make('text', 'custom_membership_stage_label') + ->set_help_text('Leave blank to use the default from settings page.'), + Field::make('text', 'custom_joining_verb') + ->set_help_text('Leave blank to use the default from settings page (e.g., "Joining").'), + ]; + + if (Settings::get("STRIPE_DIRECT_DEBIT_ONLY")) { + $block_fields[] = Field::make('checkbox', 'allow_cards_override') + ->set_help_text( + 'Override the global "Direct Debit Only" setting for this block, ' . + 'allowing card payments alongside Direct Debit.' + ); + } + /** @var Block_Container $join_form_block */ $join_form_block = Block::make(__('CK Join Form', 'common-knowledge-join-flow')) - ->add_fields(array( - Field::make('separator', 'ck_join_form', 'CK Join Form'), - $joined_page_association, - Field::make('checkbox', 'require_address')->set_default_value(true), - Field::make('checkbox', 'hide_address') - ->set_help_text('Check to completely hide the address section from the form.'), - Field::make('checkbox', 'require_phone_number')->set_default_value(true), - Field::make('checkbox', 'ask_for_additional_donation') - ->set_help_text('Has no effect when Donation Supporter Mode is enabled.'), - Field::make('checkbox', 'donation_supporter_mode') - ->set_help_text( - 'Enable Supporter Mode: shows donation frequency and amount first, ' . - 'before personal details and payment. Skips the membership plan step. ' . - 'Requires block-level membership plans to be configured (used as donation tiers). ' . - 'One-off donations require Stripe. They are not available with Direct Debit only.' - ), - Field::make('checkbox', 'hide_home_address_copy') - ->set_help_text('Check to hide the copy that explains why the address is collected.'), - Field::make('checkbox', 'include_skip_payment_button') - ->set_help_text( - 'Check to include an additional button on the first page to skip to the thank you page ' . - '(which could include a form for additional questions)' - ), - Field::make('checkbox', 'is_update_flow', 'Is Update Flow (e.g. for existing members)') - ->set_help_text( - 'Check to skip collecting member details (e.g. name, address). If checked, this page must ' . - 'be linked to with the email URL search parameter set, e.g. /become-paid-member/?email=someone@example.com. ' . - 'This can be achieved by using the CK Join Form Link block on a landing page, and linking to this page.' - ), - $custom_fields, - $custom_membership_plans, - Field::make('text', 'custom_webhook_url') - ->set_help_text('Leave blank to use the default Join Complete webhook from the settings page.'), - Field::make('text', 'custom_sidebar_heading') - ->set_help_text('Leave blank to use the default from settings page.'), - Field::make('text', 'custom_membership_stage_label') - ->set_help_text('Leave blank to use the default from settings page.'), - Field::make('text', 'custom_joining_verb') - ->set_help_text('Leave blank to use the default from settings page (e.g., "Joining").'), - - )); + ->add_fields($block_fields); $join_form_block->set_render_callback(function ($fields, $attributes, $inner_blocks) { self::enqueueBlockCss(); self::echoEnvironment($fields, self::NORMAL_BLOCK_MODE); @@ -463,7 +472,9 @@ function ($o) { "REQUIRE_PHONE_NUMBER" => $fields["require_phone_number"] ?? true, "SENTRY_DSN" => Settings::get("SENTRY_DSN"), "STRIPE_DIRECT_DEBIT" => Settings::get("STRIPE_DIRECT_DEBIT"), - "STRIPE_DIRECT_DEBIT_ONLY" => Settings::get("STRIPE_DIRECT_DEBIT_ONLY"), + "STRIPE_DIRECT_DEBIT_ONLY" => !empty($fields['allow_cards_override']) + ? false + : Settings::get("STRIPE_DIRECT_DEBIT_ONLY"), "STRIPE_PUBLISHABLE_KEY" => Settings::get("STRIPE_PUBLISHABLE_KEY"), "SUBSCRIPTION_DAY_OF_MONTH_COPY" => Settings::get("SUBSCRIPTION_DAY_OF_MONTH_COPY"), "USE_CHARGEBEE" => Settings::get("USE_CHARGEBEE"), diff --git a/packages/join-e2e/scripts/setup.php b/packages/join-e2e/scripts/setup.php index 3048edf..a05d2b1 100644 --- a/packages/join-e2e/scripts/setup.php +++ b/packages/join-e2e/scripts/setup.php @@ -212,6 +212,27 @@ function ck_e2e_upsert_page(string $slug, string $title, string $content): int ck_e2e_make_block_content([], ['donation_supporter_mode' => true]) ); +// Supporter mode page with allow_cards_override enabled. +// Used to test that a per-block override of the global STRIPE_DIRECT_DEBIT_ONLY +// setting is reflected in the env JSON emitted by the PHP render callback. +$allow_cards_override_page_id = ck_e2e_upsert_page( + 'e2e-allow-cards-override-supporter', + 'E2E Allow Cards Override Test', + ck_e2e_make_block_content($supporter_plans, [ + 'donation_supporter_mode' => true, + 'allow_cards_override' => true, + ]) +); + +// Enable STRIPE_DIRECT_DEBIT_ONLY globally so that allow_cards_override has +// something to override. Without this the global default is false and the +// override would produce no observable difference. +// NOTE: this is a persistent global side-effect. Any future spec that relies +// on STRIPE_DIRECT_DEBIT_ONLY=false without injecting it via injectEnvOverrides +// will unexpectedly receive true. Always inject the value explicitly in specs +// that care about it. +carbon_set_theme_option('stripe_direct_debit_only', true); + // Persist URLs as options so get-page-url.sh can retrieve them. update_option('ck_e2e_standard_page_url', get_permalink($standard_page_id)); update_option('ck_e2e_free_page_url', get_permalink($free_page_id)); @@ -219,11 +240,13 @@ function ck_e2e_upsert_page(string $slug, string $title, string $content): int update_option('ck_e2e_supporter_page_url', get_permalink($supporter_page_id)); update_option('ck_e2e_supporter_custom_page_url', get_permalink($supporter_custom_page_id)); update_option('ck_e2e_supporter_no_plans_page_url', get_permalink($supporter_no_plans_page_id)); - -echo 'Standard page URL: ' . get_permalink($standard_page_id) . "\n"; -echo 'Free page URL: ' . get_permalink($free_page_id) . "\n"; -echo 'Donation upsell page URL: ' . get_permalink($donation_upsell_page_id) . "\n"; -echo 'Supporter page URL: ' . get_permalink($supporter_page_id) . "\n"; -echo 'Supporter custom page URL: ' . get_permalink($supporter_custom_page_id) . "\n"; -echo 'Supporter no-plans page URL: ' . get_permalink($supporter_no_plans_page_id) . "\n"; +update_option('ck_e2e_allow_cards_override_page_url', get_permalink($allow_cards_override_page_id)); + +echo 'Standard page URL: ' . get_permalink($standard_page_id) . "\n"; +echo 'Free page URL: ' . get_permalink($free_page_id) . "\n"; +echo 'Donation upsell page URL: ' . get_permalink($donation_upsell_page_id) . "\n"; +echo 'Supporter page URL: ' . get_permalink($supporter_page_id) . "\n"; +echo 'Supporter custom page URL: ' . get_permalink($supporter_custom_page_id) . "\n"; +echo 'Supporter no-plans page URL: ' . get_permalink($supporter_no_plans_page_id) . "\n"; +echo 'Allow cards override page URL: ' . get_permalink($allow_cards_override_page_id) . "\n"; echo "Setup complete.\n"; diff --git a/packages/join-e2e/tests/allow-cards-override.spec.ts b/packages/join-e2e/tests/allow-cards-override.spec.ts new file mode 100644 index 0000000..1b79347 --- /dev/null +++ b/packages/join-e2e/tests/allow-cards-override.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; +import { mockRestEndpoints, captureJoinBodyViaStripeRedirect, injectEnvOverrides, CONTINUE } from './helpers'; + +/** + * allow_cards_override — per-block card payment override + * + * When the global "Direct Debit Only" Stripe setting is enabled + * (STRIPE_DIRECT_DEBIT_ONLY=true), the block editor exposes an + * "allow_cards_override" checkbox. Checking it causes the PHP render + * callback to emit STRIPE_DIRECT_DEBIT_ONLY: false in the page's env JSON, + * re-enabling card (Stripe Elements) payments for that specific form instance. + * + * The seed script (setup.php) sets STRIPE_DIRECT_DEBIT_ONLY=true globally via + * carbon_set_theme_option and creates: + * - e2e-allow-cards-override-supporter: supporter mode + allow_cards_override=true + * - e2e-supporter: supporter mode, no override (global DD-only applies) + * + * Tests verify: + * 1. The PHP env script output is correct (full-stack, no injectEnvOverrides). + * 2. The one-off tab is enabled on the override page and disabled without it. + * 3. The /join request body for a one-off donation on the override page is correct. + * + * For behavioural tests, USE_STRIPE=true is injected (a global plugin setting + * unavailable in the test environment) but STRIPE_DIRECT_DEBIT_ONLY is NOT + * injected — it is left to the real PHP output to supply the value, which is + * what these tests are exercising. + */ + +const OVERRIDE_PAGE = '/e2e-allow-cards-override-supporter/'; +const SUPPORTER_PAGE = '/e2e-supporter/'; + +// --------------------------------------------------------------------------- +// PHP env script output — no injectEnvOverrides, reads raw PHP output +// --------------------------------------------------------------------------- + +test.describe('PHP env script: allow_cards_override overrides STRIPE_DIRECT_DEBIT_ONLY', () => { + test('page with allow_cards_override emits STRIPE_DIRECT_DEBIT_ONLY: false', async ({ page }) => { + await page.goto(OVERRIDE_PAGE); + const envJson = await page.locator('script#env').textContent(); + const env = JSON.parse(envJson || '{}'); + // PHP must have emitted false (not the global true) because allow_cards_override=true. + expect(env.STRIPE_DIRECT_DEBIT_ONLY).toBe(false); + }); + + test('page without allow_cards_override emits STRIPE_DIRECT_DEBIT_ONLY: true (global applies)', async ({ page }) => { + await page.goto(SUPPORTER_PAGE); + const envJson = await page.locator('script#env').textContent(); + const env = JSON.parse(envJson || '{}'); + // No override — the global STRIPE_DIRECT_DEBIT_ONLY=true must be present. + expect(env.STRIPE_DIRECT_DEBIT_ONLY).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// One-off tab availability — STRIPE_DIRECT_DEBIT_ONLY supplied by real PHP +// --------------------------------------------------------------------------- + +test.describe('One-off tab enabled on override page, disabled without it', () => { + test('one-off tab is enabled on page with allow_cards_override', async ({ page }) => { + // Inject USE_STRIPE=true (global plugin setting not available in test env). + // STRIPE_DIRECT_DEBIT_ONLY is intentionally NOT injected — it comes from PHP. + await injectEnvOverrides(page, `**${OVERRIDE_PAGE}`, { USE_STRIPE: true }); + await mockRestEndpoints(page); + await page.goto(OVERRIDE_PAGE); + await page.waitForSelector('h2:has-text("Support us")'); + + const oneOffBtn = page.locator('.btn-group button:has-text("One-off")'); + await expect(oneOffBtn).toBeVisible(); + await expect(oneOffBtn).toBeEnabled(); + }); + + test('one-off tab is disabled on page without allow_cards_override (global DD-only applies)', async ({ page }) => { + await injectEnvOverrides(page, `**${SUPPORTER_PAGE}`, { USE_STRIPE: true }); + await mockRestEndpoints(page); + await page.goto(SUPPORTER_PAGE); + await page.waitForSelector('h2:has-text("Support us")'); + + const oneOffBtn = page.locator('.btn-group button:has-text("One-off")'); + await expect(oneOffBtn).toBeVisible(); + await expect(oneOffBtn).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// /join request body for a one-off donation via the override page +// --------------------------------------------------------------------------- + +test.describe('/join body for one-off donation on override page', () => { + test('recurDonation=false and donationAmount>0 in /join body', async ({ page }) => { + await injectEnvOverrides(page, `**${OVERRIDE_PAGE}`, { USE_STRIPE: true }); + await mockRestEndpoints(page); + await page.goto(OVERRIDE_PAGE); + await page.waitForSelector('h2:has-text("Support us")'); + + // Select one-off, pick a tier, advance through details. + await page.locator('.btn-group button:has-text("One-off")').click(); + await page.locator('button[type="button"]:has-text("£5")').click(); + await page.locator('button[type="submit"]').click(); + await page.waitForSelector('input#firstName'); + await page.locator(CONTINUE).click(); + + const joinBody = await captureJoinBodyViaStripeRedirect(page, OVERRIDE_PAGE); + + expect(Object.keys(joinBody).length).toBeGreaterThan(0); + expect(joinBody.recurDonation).toBe(false); + expect(Number(joinBody.donationAmount)).toBeGreaterThan(0); + // paymentMethod must be creditCard (the allow_cards_override unlocks card for one-off). + expect(joinBody.paymentMethod).toBe('creditCard'); + }); +}); diff --git a/packages/join-flow/src/env.test.ts b/packages/join-flow/src/env.test.ts index 098a755..50b28d6 100644 --- a/packages/join-flow/src/env.test.ts +++ b/packages/join-flow/src/env.test.ts @@ -42,6 +42,20 @@ describe('getPaymentProviders — Stripe direct debit flags', () => { expect(getPaymentProviders().stripe).toEqual(['creditCard']); }); }); + + it('STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) produces creditCard only', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: false }, () => { + expect(getPaymentProviders().stripe).toEqual(['creditCard']); + }); + }); + + it('STRIPE_DIRECT_DEBIT_ONLY explicitly false with STRIPE_DIRECT_DEBIT true produces both methods', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: false, STRIPE_DIRECT_DEBIT: true }, () => { + const methods = getPaymentProviders().stripe; + expect(methods).toContain('creditCard'); + expect(methods).toContain('directDebit'); + }); + }); }); describe('getPaymentMethods — STRIPE_DIRECT_DEBIT_ONLY', () => { @@ -90,4 +104,10 @@ describe('resolveStripePaymentMethodTypes', () => { expect(resolveStripePaymentMethodTypes(false, 'eur')).toEqual(['card']); }); }); + + it('subscription with STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) returns card-only', () => { + withEnv({ STRIPE_DIRECT_DEBIT_ONLY: false }, () => { + expect(resolveStripePaymentMethodTypes(false, 'gbp')).toEqual(['card']); + }); + }); }); diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index 1ef613a..5300a8f 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.0" + release: "1.4.1" }); if (getEnv('USE_CHARGEBEE')) {