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.0
* Version: 1.4.1
* 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.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
Expand Down Expand Up @@ -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
Expand Down
95 changes: 53 additions & 42 deletions packages/join-block/src/Blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"),
Expand Down
37 changes: 30 additions & 7 deletions packages/join-e2e/scripts/setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,41 @@ 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));
update_option('ck_e2e_donation_upsell_page_url', get_permalink($donation_upsell_page_id));
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";
110 changes: 110 additions & 0 deletions packages/join-e2e/tests/allow-cards-override.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
20 changes: 20 additions & 0 deletions packages/join-flow/src/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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']);
});
});
});
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.0"
release: "1.4.1"
});

if (getEnv('USE_CHARGEBEE')) {
Expand Down
Loading