diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 9977e11..0410cb3 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.3.19 + * Version: 1.4.0 * Author: Common Knowledge * Text Domain: common-knowledge-join-flow * License: GPLv2 or later @@ -499,7 +499,14 @@ StripeService::initialise(); [$customer, $newCustomer] = StripeService::upsertCustomer($email); - $subscription = StripeService::createSubscription($customer, $plan, $data["customMembershipAmount"] ?? null); + $subscription = StripeService::createSubscription( + $customer, + $plan, + $data["customMembershipAmount"] ?? null, + $data["donationAmount"] ?? null, + $data["recurDonation"] ?? false, + !empty($data["donationSupporterMode"]) + ); return $subscription; } catch (\Exception $e) { @@ -512,6 +519,50 @@ } )); + register_rest_route('join/v1', '/stripe/create-payment-intent', array( + 'methods' => 'POST', + 'permission_callback' => function ($req) { + return true; + }, + 'callback' => function (WP_REST_Request $request) { + global $joinBlockLog; + + $email = ""; + try { + $data = json_decode($request->get_body(), true); + $email = $data['email']; + + $joinBlockLog->info("Received /stripe/create-payment-intent request for $email"); + + $plan = Settings::getMembershipPlan($data['membership'] ?? ''); + if (!$plan) { + return new WP_REST_Response(['error' => 'Invalid membership plan'], 400); + } + + $amount = (float) ($data['donationAmount'] ?? 0); + $amountError = StripeService::validateOneOffDonationAmount($amount); + if ($amountError) { + return new WP_REST_Response(['error' => $amountError], 400); + } + + $currency = $plan['currency'] ?? 'GBP'; + + StripeService::initialise(); + [$customer] = StripeService::upsertCustomer($email); + + $paymentIntent = StripeService::createPaymentIntent($customer, $amount, $currency); + + return new WP_REST_Response($paymentIntent, 200); + } catch (\Exception $e) { + $joinBlockLog->error( + 'Failed to create Stripe PaymentIntent for user ' . $email . ': ' . $e->getMessage(), + ['error' => $e] + ); + throw $e; + } + } + )); + register_rest_route('join/v1', '/stripe/create-confirm-subscription', array( 'methods' => 'POST', 'permission_callback' => function ($req) { @@ -541,7 +592,14 @@ StripeService::initialise(); [$customer, $newCustomer] = StripeService::upsertCustomer($email); - $subscription = StripeService::createSubscription($customer, $plan); + $subscription = StripeService::createSubscription( + $customer, + $plan, + $data["customMembershipAmount"] ?? null, + $data["donationAmount"] ?? null, + $data["recurDonation"] ?? false, + !empty($data["donationSupporterMode"]) + ); $confirmedPaymentIntent = StripeService::confirmSubscriptionPaymentIntent($subscription, $data['confirmationTokenId']); diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index 0979d45..2c6663e 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.3.19 +Stable tag: 1.4.0 Requires PHP: 8.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,12 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled == Changelog == += 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 +* Supporter mode: one-off donations processed via Stripe invoice item; recurring donations added as a second subscription item +* Supporter mode: GoCardless recurring donations added to the Direct Debit subscription total; one-off toggle disabled with explanation when Stripe is unavailable +* Supporter mode: donation amount and frequency submitted as correct native types to the payment backend = 1.3.19 = * Sync membership tier tag changes to Zetkin and Mailchimp when a member upgrades or downgrades their Stripe subscription plan. * Tags shared across tiers (e.g. `member`) are preserved during a tier change and never incorrectly removed. @@ -118,7 +124,6 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled * Added `ck_join_flow_should_unlapse_member` filter to control whether an unlapse should proceed. * Added `ck_join_flow_member_lapsed` action fired after a member is successfully lapsed. * Added `ck_join_flow_member_unlapsed` action fired after a member is successfully unlapsed. - = 1.3.18 = * Make Zetkin errors non-fatal so a Zetkin failure does not block a successful join. * Improve Zetkin 403 error message to indicate expired JWT credentials and remediation steps. diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index 1582331..56c2c64 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -176,7 +176,15 @@ private static function registerJoinFormBlock() 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'), + 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') @@ -327,7 +335,8 @@ private static function echoEnvironment($fields, $block_mode) } $membership_plans = $fields['custom_membership_plans'] ?? []; - if (!$membership_plans) { + $is_supporter_mode = !empty($fields['donation_supporter_mode']); + if (!$membership_plans && !$is_supporter_mode) { $membership_plans = Settings::get("MEMBERSHIP_PLANS") ?? []; } @@ -414,6 +423,7 @@ function ($o) { 'ABOUT_YOU_COPY' => wpautop(Settings::get("ABOUT_YOU_COPY")), 'ABOUT_YOU_HEADING' => Settings::get("ABOUT_YOU_HEADING"), "ASK_FOR_ADDITIONAL_DONATION" => $fields['ask_for_additional_donation'] ?? false, + "DONATION_SUPPORTER_MODE" => $fields['donation_supporter_mode'] ?? false, 'CHARGEBEE_SITE_NAME' => Settings::get('CHARGEBEE_SITE_NAME'), "CHARGEBEE_API_PUBLISHABLE_KEY" => Settings::get('CHARGEBEE_API_PUBLISHABLE_KEY'), "COLLECT_COUNTY" => Settings::get("COLLECT_COUNTY"), diff --git a/packages/join-block/src/Services/GocardlessService.php b/packages/join-block/src/Services/GocardlessService.php index a9a489f..87a10b3 100644 --- a/packages/join-block/src/Services/GocardlessService.php +++ b/packages/join-block/src/Services/GocardlessService.php @@ -104,6 +104,12 @@ public static function createCustomerSubscription($data) } $amountInPence = round(((float) $data['membershipPlan']['amount']) * 100); + // Add recurring donation to subscription amount. + $donationAmount = (float) ($data['donationAmount'] ?? 0); + if ($donationAmount > 0 && !empty($data['recurDonation'])) { + $amountInPence += round($donationAmount * 100); + } + $subscriptions = $client->subscriptions()->list([ "params" => ["mandate" => $mandate->id] ]); diff --git a/packages/join-block/src/Services/JoinService.php b/packages/join-block/src/Services/JoinService.php index a835102..3d2aa34 100644 --- a/packages/join-block/src/Services/JoinService.php +++ b/packages/join-block/src/Services/JoinService.php @@ -189,7 +189,9 @@ private static function tryHandleJoin($data) $data['gocardlessMandate'] = $subscription ? $subscription->links->mandate : null; $data['gocardlessCustomer'] = $subscription ? $subscription->links->customer : null; - if (Settings::get("USE_STRIPE")) { + $isOneOffSupporterDonation = !empty($data["donationSupporterMode"]) && empty($data["recurDonation"]); + + if (Settings::get("USE_STRIPE") && !$isOneOffSupporterDonation) { StripeService::initialise(); $subscriptionInfo = StripeService::removeExistingSubscriptions($data["email"], $data["stripeCustomerId"] ?? null, $data["stripeSubscriptionId"] ?? null); if ($subscriptionInfo["amount"] !== $membershipAmount) { @@ -230,8 +232,9 @@ private static function tryHandleJoin($data) MailchimpService::signup($data); $joinBlockLog->info("Completed Mailchimp signup request for $email"); } catch (\Exception $exception) { + // A Mailchimp failure should not block a successful join. + // The member record can be retro-added to Mailchimp once the underlying issue is resolved. $joinBlockLog->error("Mailchimp error for email $email: " . $exception->getMessage()); - throw $exception; } } @@ -398,7 +401,6 @@ public static function toggleMemberLapsed($email, $lapsed = true, $paymentDate = $joinBlockLog->info("$done member $email as lapsed in Mailchimp"); } catch (\Exception $exception) { $joinBlockLog->error("Mailchimp error for email $email: " . $exception->getMessage()); - throw $exception; } } diff --git a/packages/join-block/src/Services/StripeService.php b/packages/join-block/src/Services/StripeService.php index 8bc9a39..1ef6aca 100644 --- a/packages/join-block/src/Services/StripeService.php +++ b/packages/join-block/src/Services/StripeService.php @@ -18,6 +18,21 @@ public static function initialise() Stripe::setApiKey(Settings::get('STRIPE_SECRET_KEY')); } + /** + * Validates a one-off donation amount. + * Returns null if valid, or an error message string if invalid. + */ + public static function validateOneOffDonationAmount(float $amount): ?string + { + if ($amount <= 0) { + return 'Donation amount must be greater than zero'; + } + if ($amount > 10000) { + return 'Donation amount must not exceed £10,000'; + } + return null; + } + public static function getCustomers($extraParams = []) { global $joinBlockLog; @@ -70,31 +85,167 @@ public static function upsertCustomer($email) return [$customer, $newCustomer]; } - public static function createSubscription($customer, $plan, $customAmount = null) + /** + * Determine how a subscription's price should be resolved. + * + * Returns one of: + * 'custom_supporter' — use the generic Donation product at the given custom amount + * 'custom_plan' — use the plan's own product at the given custom amount + * 'default' — use the plan's pre-configured stripe_price_id unchanged + * + * NOTE: all donation amounts — supporter mode custom amounts and standard-flow + * donation upsells alike — share a single "Supporter Donation" Stripe product + * (see getOrCreateDonationProduct), with one price per unique amount. This + * contrasts with membership tiers, where each tier has its own dedicated + * Stripe product. + */ + public static function resolveSubscriptionPriceStrategy(array $plan, float $customAmount, bool $isSupporterMode): string + { + if (($plan['allow_custom_amount'] || $isSupporterMode) && $customAmount > 0) { + return $isSupporterMode ? 'custom_supporter' : 'custom_plan'; + } + return 'default'; + } + + public static function createSubscription($customer, $plan, $customAmount = null, $donationAmount = null, $recurDonation = false, $isSupporterMode = false) { $priceId = $plan["stripe_price_id"]; $customAmount = (float) $customAmount; - $minAmount = (float) $plan["amount"]; - if ($plan["allow_custom_amount"] && $customAmount && $customAmount > $minAmount) { - $product = self::getOrCreateProductForMembershipTier($plan); + $strategy = self::resolveSubscriptionPriceStrategy($plan, $customAmount, $isSupporterMode); + if ($strategy === 'custom_supporter') { + $product = self::getOrCreateDonationProduct(); $priceId = self::getOrCreatePriceForProduct($product, $customAmount, $plan['currency'], self::convertFrequencyToStripeInterval($plan['frequency'])); + } elseif ($strategy === 'custom_plan') { + $product = self::getOrCreateProductForMembershipTier($plan, false); + $priceId = self::getOrCreatePriceForProduct($product, $customAmount, $plan['currency'], self::convertFrequencyToStripeInterval($plan['frequency'])); + } elseif ($isSupporterMode) { + // Ensure the product name uses the "Donation:" prefix. + // Products are created during plan save (before supporter mode context is known), + // so this call triggers a rename if the name still has the "Membership:" prefix. + self::getOrCreateProductForMembershipTier($plan, true); } - $subscription = Subscription::create([ - 'customer' => $customer->id, - 'items' => [ - [ - 'price' => $priceId, - ], - ], + + $items = [['price' => $priceId]]; + $addInvoiceItems = []; + + $donationAmount = (float) $donationAmount; + if ($donationAmount > 0) { + $donationProduct = self::getOrCreateDonationProduct(); + if ($recurDonation) { + $interval = self::convertFrequencyToStripeInterval($plan['frequency']); + $donationPriceId = self::getOrCreatePriceForProduct( + $donationProduct, $donationAmount, $plan['currency'], $interval + ); + $items[] = ['price' => $donationPriceId]; + } else { + $donationPrice = self::getOrCreateOneTimePriceForProduct($donationProduct, $donationAmount, $plan['currency']); + $addInvoiceItems[] = ['price' => $donationPrice->id]; + } + } + + $subscriptionPayload = [ + 'customer' => $customer->id, + 'items' => $items, 'payment_behavior' => 'default_incomplete', 'collection_method' => 'charge_automatically', 'payment_settings' => ['save_default_payment_method' => 'on_subscription', 'payment_method_types' => ['card', 'bacs_debit']], - 'expand' => ['latest_invoice.payment_intent'], - ]); + 'expand' => ['latest_invoice.payment_intent'], + ]; + + if (!empty($addInvoiceItems)) { + $subscriptionPayload['add_invoice_items'] = $addInvoiceItems; + } + + $subscription = Subscription::create($subscriptionPayload); return $subscription; } + public static function createPaymentIntent($customer, $amount, $currency) + { + global $joinBlockLog; + + $currency = strtolower($currency); + + $joinBlockLog->info("Creating one-off invoice-based payment for customer {$customer->id}: {$currency} {$amount}"); + + $product = self::getOrCreateDonationProduct(); + $price = self::getOrCreateOneTimePriceForProduct($product, (float) $amount, $currency); + + $invoice = \Stripe\Invoice::create([ + 'customer' => $customer->id, + 'collection_method' => 'charge_automatically', + ]); + + \Stripe\InvoiceItem::create([ + 'customer' => $customer->id, + 'invoice' => $invoice->id, + 'price' => $price->id, + ]); + + $finalizedInvoice = $invoice->finalizeInvoice(); + $paymentIntent = \Stripe\PaymentIntent::retrieve($finalizedInvoice->payment_intent); + + return [ + 'id' => $paymentIntent->id, + 'client_secret' => $paymentIntent->client_secret, + 'customer' => $customer->id, + ]; + } + + public static function getOrCreateOneTimePriceForProduct($product, float $amount, string $currency) + { + global $joinBlockLog; + + $stripeAmount = (int) round($amount * 100); + $currency = strtolower($currency); + + $existingPrices = \Stripe\Price::search([ + 'query' => "active:'true' AND product:'{$product->id}' AND type:'one_time' AND currency:'{$currency}'", + ]); + + foreach ($existingPrices->data as $price) { + if ($price->unit_amount === $stripeAmount) { + $joinBlockLog->info("One-time price for product '{$product->id}' amount {$stripeAmount} already exists."); + return $price; + } + } + + $joinBlockLog->info("Creating one-time price for product '{$product->id}' amount {$stripeAmount}"); + + return \Stripe\Price::create([ + 'product' => $product->id, + 'unit_amount' => $stripeAmount, + 'currency' => $currency, + ]); + } + + public static function getOrCreateDonationProduct() + { + global $joinBlockLog; + + try { + $existingProducts = \Stripe\Product::search([ + 'query' => "active:'true' AND metadata['type']:'supporter_donation'", + ]); + + if (count($existingProducts->data) > 0) { + return $existingProducts->data[0]; + } + + $joinBlockLog->info("Creating Stripe product for supporter donations"); + + return \Stripe\Product::create([ + 'name' => 'Supporter Donation', + 'type' => 'service', + 'metadata' => ['type' => 'supporter_donation'], + ]); + } catch (\Stripe\Exception\ApiErrorException $e) { + $joinBlockLog->error("Error creating/retrieving donation product: " . $e->getMessage()); + throw $e; + } + } + public static function getSubscriptionsForCSVOutput() { global $joinBlockLog; @@ -276,13 +427,14 @@ public static function createMembershipPlanIfItDoesNotExist($membershipPlan) return [$newOrExistingProduct, $newOrExistingPrice]; } - public static function getOrCreateProductForMembershipTier($membershipPlan) + public static function getOrCreateProductForMembershipTier($membershipPlan, $isSupporterMode = false) { global $joinBlockLog; $tierID = Settings::getMembershipPlanId($membershipPlan); $tierDescription = $membershipPlan['description']; + $productPrefix = $isSupporterMode ? 'Donation' : 'Membership'; try { $joinBlockLog->info("Searching for existing Stripe product for membership tier '{$tierID}'"); @@ -299,10 +451,10 @@ public static function getOrCreateProductForMembershipTier($membershipPlan) $needsUpdate = false; $updateData = []; - if ($existingProduct->name !== "Membership: {$membershipPlan['label']}") { + if ($existingProduct->name !== "{$productPrefix}: {$membershipPlan['label']}") { $joinBlockLog->info("Name changed, updating existing product for membership tier '{$tierID}'"); - $updateData['name'] = "Membership: {$membershipPlan['label']}"; + $updateData['name'] = "{$productPrefix}: {$membershipPlan['label']}"; $needsUpdate = true; } @@ -325,7 +477,7 @@ public static function getOrCreateProductForMembershipTier($membershipPlan) $joinBlockLog->info("No existing product found for membership tier '{$tierID}', creating new product"); $stripeProduct = [ - 'name' => "Membership: {$membershipPlan['label']}", + 'name' => "{$productPrefix}: {$membershipPlan['label']}", 'type' => 'service', 'metadata' => ['membership_plan' => $tierID], ]; @@ -364,7 +516,7 @@ public static function getOrCreatePriceForProduct($product, $amount, $currency, foreach ($existingPrices->data as $price) { if ($price->unit_amount === $stripePrice) { $joinBlockLog->info("Recurring price for product '{$product->id}' with currency '{$currency}' and amount {$stripePrice} already exists."); - return $existingPrices->data[0]; + return $price; } } diff --git a/packages/join-block/tests/StripeServiceTest.php b/packages/join-block/tests/StripeServiceTest.php new file mode 100644 index 0000000..8a9f3cb --- /dev/null +++ b/packages/join-block/tests/StripeServiceTest.php @@ -0,0 +1,156 @@ + true, + 'amount' => 10, + 'stripe_price_id' => 'price_default', + 'currency' => 'GBP', + 'frequency' => 'monthly', + 'label' => 'Flexible', + ]; + + private array $planWithoutCustomAmount = [ + 'allow_custom_amount' => false, + 'amount' => 10, + 'stripe_price_id' => 'price_default', + 'currency' => 'GBP', + 'frequency' => 'monthly', + 'label' => 'Tenner a month', + ]; + + // ------------------------------------------------------------------------- + // Supporter mode — custom amount + // ------------------------------------------------------------------------- + + public function testSupporterModeWithCustomAmountReturnsSupporterStrategy(): void + { + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithoutCustomAmount, + 69.0, + true + ); + + $this->assertSame('custom_supporter', $strategy); + } + + public function testSupporterModeWithCustomAmountUsesGenericProductRegardlessOfPlanFlag(): void + { + // Even when allow_custom_amount is false on the plan, supporter mode + // should still route to the generic Donation product. + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithoutCustomAmount, + 100.0, + true + ); + + $this->assertSame('custom_supporter', $strategy); + } + + public function testSupporterModeWithZeroCustomAmountReturnsDefault(): void + { + // Zero / null custom amount should fall through to the plan's default price. + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithoutCustomAmount, + 0.0, + true + ); + + $this->assertSame('default', $strategy); + } + + // ------------------------------------------------------------------------- + // Standard mode — plan with allow_custom_amount + // ------------------------------------------------------------------------- + + public function testStandardModeWithAllowCustomAmountReturnsCustomPlanStrategy(): void + { + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithCustomAmount, + 25.0, + false + ); + + $this->assertSame('custom_plan', $strategy); + } + + public function testStandardModeWithCustomAmountUsesCustomPlanNotDonationProduct(): void + { + // Standard flow must NOT use the generic Donation product — it should + // create a price under the plan's own product. + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithCustomAmount, + 25.0, + false + ); + + $this->assertNotSame('custom_supporter', $strategy); + } + + // ------------------------------------------------------------------------- + // Standard mode — plan without allow_custom_amount + // ------------------------------------------------------------------------- + + public function testStandardModeWithoutCustomAmountFlagReturnsDefault(): void + { + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithoutCustomAmount, + 25.0, + false + ); + + $this->assertSame('default', $strategy); + } + + public function testStandardModeWithZeroAmountReturnsDefault(): void + { + $strategy = StripeService::resolveSubscriptionPriceStrategy( + $this->planWithCustomAmount, + 0.0, + false + ); + + $this->assertSame('default', $strategy); + } + + // ------------------------------------------------------------------------- + // validateOneOffDonationAmount + // ------------------------------------------------------------------------- + + public function testValidAmountReturnsNull(): void + { + $this->assertNull(StripeService::validateOneOffDonationAmount(10.0)); + } + + public function testZeroAmountReturnsError(): void + { + $this->assertNotNull(StripeService::validateOneOffDonationAmount(0.0)); + } + + public function testNegativeAmountReturnsError(): void + { + $this->assertNotNull(StripeService::validateOneOffDonationAmount(-5.0)); + } + + public function testMaximumBoundaryIsValid(): void + { + $this->assertNull(StripeService::validateOneOffDonationAmount(10000.0)); + } + + public function testAboveMaximumReturnsError(): void + { + $this->assertNotNull(StripeService::validateOneOffDonationAmount(10000.01)); + } +} diff --git a/packages/join-flow/__mocks__/fileMock.js b/packages/join-flow/__mocks__/fileMock.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/packages/join-flow/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/packages/join-flow/jest.config.js b/packages/join-flow/jest.config.js new file mode 100644 index 0000000..380c347 --- /dev/null +++ b/packages/join-flow/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + preset: '@wordpress/jest-preset-default', + testEnvironment: 'jsdom', + testMatch: ['/src/**/*.test.(ts|tsx)'], + moduleNameMapper: { + '\\.(css|scss|svg|png)$': '/__mocks__/fileMock.js', + }, + transformIgnorePatterns: [ + '/node_modules/(?!(lodash-es)/)', + ], +}; diff --git a/packages/join-flow/package.json b/packages/join-flow/package.json index 69794c0..3ace927 100644 --- a/packages/join-flow/package.json +++ b/packages/join-flow/package.json @@ -45,7 +45,8 @@ }, "scripts": { "start": "webpack serve --config './webpack/dev.js'", - "build": "webpack --config './webpack/production.js'" + "build": "webpack --config './webpack/production.js'", + "test": "jest" }, "proxy": "http://localhost:8080", "eslintConfig": { @@ -64,11 +65,17 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^14.6.1", "@types/lodash-es": "^4.17.3", "@types/uuid": "^8.3.0", "@types/yup": "^0.29.7", + "@wordpress/jest-preset-default": "^12.41.0", "env-cmd": "^10.1.0", "gifsicle": "^5.0.0", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "prettier": "^3.3.3", "resolve-url-loader": "^3.1.1", "typescript": "^4.0.3" diff --git a/packages/join-flow/src/app.tsx b/packages/join-flow/src/app.tsx index ae633ae..ef8e40c 100644 --- a/packages/join-flow/src/app.tsx +++ b/packages/join-flow/src/app.tsx @@ -69,6 +69,17 @@ const App = () => { if (getEnv("IS_UPDATE_FLOW")) { stages = stages.filter((s) => s.id !== "enter-details"); } + + if (getEnv("DONATION_SUPPORTER_MODE")) { + stages = [ + { id: "donation", label: "Your Donation", breadcrumb: true }, + { id: "enter-details", label: "Your Details", breadcrumb: true }, + { id: "payment-details", label: "Payment", breadcrumb: true }, + { id: "payment-method", label: "Payment", breadcrumb: false }, + { id: "confirm", label: "Confirm", breadcrumb: false } + ]; + } + const [data, setData] = useState(getInitialState); const [blockingMessage, setBlockingMessage] = useState(null); @@ -124,24 +135,39 @@ const App = () => { let nextStage = router.state.stage; - if (router.state.stage === "enter-details") { + if (getEnv("DONATION_SUPPORTER_MODE")) { + if (router.state.stage === "donation") { + nextStage = "enter-details"; + } else if (router.state.stage === "enter-details") { + nextStage = "payment-method"; + const response: any = await recordStep({ ...nextData, stage: "enter-details" }); + if (response?.status === 'blocked') { + setBlockingMessage(response.message || 'Unable to proceed.'); + return; + } + setBlockingMessage(null); + } + // payment-method, payment-details, confirm: fall through to shared logic below + } else if (router.state.stage === "enter-details") { nextStage = "plan"; // Send initial details to catch drop off const response: any = await recordStep({ ...nextData, stage: "enter-details" }); - + // Check if the form progression is blocked if (response?.status === 'blocked') { setBlockingMessage(response.message || 'Unable to proceed with this submission.'); return; // Stop progression } - + // Clear any previous blocking message setBlockingMessage(null); } else if (router.state.stage === "plan") { nextStage = "donation"; } else if (router.state.stage === "donation") { nextStage = "payment-method"; - } else if (router.state.stage === "payment-method") { + } + + if (router.state.stage === "payment-method") { // Check if this is a zero-price membership before setting next stage // This handles the case where the user is ON the payment-method page // (e.g., when there are multiple payment methods to choose from) @@ -369,16 +395,24 @@ const getInitialState = (): FormSchema => { } }; + const availableMethods = getPaymentMethods(); const state = { sessionToken: uuid.v4(), ...getTestDataIfEnabled(), ...getDefaultState(), ...getSavedState(), + // Env-driven overrides must come after session restore so config always wins + ...(getEnv("DONATION_SUPPORTER_MODE") ? { recurDonation: true, donationSupporterMode: true } : {}), ...getProvidedStateFromQueryParams(), isUpdateFlow: getEnv("IS_UPDATE_FLOW"), webhookUuid: getEnv("WEBHOOK_UUID"), customFieldsConfig: getEnv("CUSTOM_FIELDS") } as any; + // Clamp paymentMethod to available methods — session storage may have a stale value + // from a previous flow (e.g. "creditCard" persisted when STRIPE_DIRECT_DEBIT_ONLY is now active) + if (availableMethods.length && !availableMethods.includes(state.paymentMethod)) { + state.paymentMethod = defaultMethod; + } return state; }; diff --git a/packages/join-flow/src/env.test.ts b/packages/join-flow/src/env.test.ts new file mode 100644 index 0000000..8136c41 --- /dev/null +++ b/packages/join-flow/src/env.test.ts @@ -0,0 +1,55 @@ +import { getPaymentProviders, getPaymentMethods } from './env'; + +function withEnv(overrides: Record, fn: () => void) { + const original = (window as any).process?.env; + (window as any).process = { env: overrides }; + try { + fn(); + } finally { + (window as any).process = { env: original ?? {} }; + } +} + +describe('getPaymentProviders — Stripe direct debit flags', () => { + it('STRIPE_DIRECT_DEBIT_ONLY alone includes directDebit', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { + expect(getPaymentProviders().stripe).toContain('directDebit'); + }); + }); + + it('STRIPE_DIRECT_DEBIT_ONLY alone excludes creditCard', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { + expect(getPaymentProviders().stripe).not.toContain('creditCard'); + }); + }); + + it('STRIPE_DIRECT_DEBIT_ONLY alone produces a non-empty stripe provider so a provider is always resolved', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { + expect(getPaymentProviders().stripe?.length).toBeGreaterThan(0); + }); + }); + + it('STRIPE_DIRECT_DEBIT without STRIPE_DIRECT_DEBIT_ONLY includes both methods', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT: true }, () => { + const methods = getPaymentProviders().stripe; + expect(methods).toContain('creditCard'); + expect(methods).toContain('directDebit'); + }); + }); + + it('neither direct debit flag produces creditCard only', () => { + withEnv({ USE_STRIPE: true }, () => { + expect(getPaymentProviders().stripe).toEqual(['creditCard']); + }); + }); +}); + +describe('getPaymentMethods — STRIPE_DIRECT_DEBIT_ONLY', () => { + it('returns directDebit and not creditCard', () => { + withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { + const methods = getPaymentMethods(); + expect(methods).toContain('directDebit'); + expect(methods).not.toContain('creditCard'); + }); + }); +}); diff --git a/packages/join-flow/src/env.ts b/packages/join-flow/src/env.ts index a4698fa..154d09a 100644 --- a/packages/join-flow/src/env.ts +++ b/packages/join-flow/src/env.ts @@ -2,6 +2,7 @@ interface StaticEnv { ABOUT_YOU_COPY: string; ABOUT_YOU_HEADING: string; ASK_FOR_ADDITIONAL_DONATION: boolean; + DONATION_SUPPORTER_MODE: boolean; CHARGEBEE_API_PUBLISHABLE_KEY: string; CHARGEBEE_SITE_NAME: string; COLLECT_COUNTY: boolean; @@ -70,6 +71,7 @@ const staticEnv: StaticEnv = { ABOUT_YOU_COPY: process.env.REACT_APP_ABOUT_YOU_COPY || '', ABOUT_YOU_HEADING: process.env.REACT_APP_ABOUT_YOU_HEADING || '', ASK_FOR_ADDITIONAL_DONATION: parseBooleanEnvVar("REACT_APP_ASK_FOR_ADDITIONAL_DONATION"), + DONATION_SUPPORTER_MODE: parseBooleanEnvVar("REACT_APP_DONATION_SUPPORTER_MODE"), CHARGEBEE_API_PUBLISHABLE_KEY: process.env.REACT_APP_CHARGEBEE_API_PUBLISHABLE_KEY || '', CHARGEBEE_SITE_NAME: process.env.REACT_APP_CHARGEBEE_SITE_NAME || '', COLLECT_COUNTY: parseBooleanEnvVar("REACT_APP_COLLECT_COUNTY"), @@ -153,7 +155,7 @@ export const getPaymentProviders = () => { if (!get("STRIPE_DIRECT_DEBIT_ONLY")) { stripeMethods.push("creditCard"); } - if (get("STRIPE_DIRECT_DEBIT")) { + if (get("STRIPE_DIRECT_DEBIT") || get("STRIPE_DIRECT_DEBIT_ONLY")) { stripeMethods.push("directDebit"); } if (stripeMethods.length) { diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index 708e5b9..1ef613a 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.3.19" + release: "1.4.0" }); if (getEnv('USE_CHARGEBEE')) { diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx new file mode 100644 index 0000000..593703c --- /dev/null +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { DonationPage } from './donation.page'; + +// Mock the env module +jest.mock('../env', () => ({ + get: jest.fn(), +})); + +// Mock the summary component to keep tests simple +jest.mock('../components/summary', () => ({ + Summary: () => null, +})); + +import { get as getEnv } from '../env'; +const mockGetEnv = getEnv as jest.Mock; + +const mockOnCompleted = jest.fn(); +const baseData = { + membership: 'standard', +} as any; + +const MOCK_PLANS = [ + { value: 'plan-a', label: 'Plan A', amount: '5', currency: 'GBP', frequency: 'monthly' }, + { value: 'plan-b', label: 'Plan B', amount: '10', currency: 'GBP', frequency: 'monthly' }, + { value: 'plan-c', label: 'Plan C', amount: '20', currency: 'GBP', frequency: 'monthly' }, +]; + +function renderDonationPage(data = baseData) { + return render( + + ); +} + +beforeEach(() => { + mockGetEnv.mockReturnValue(false); + mockOnCompleted.mockClear(); +}); + +describe('DonationPage — standard mode (DONATION_SUPPORTER_MODE off)', () => { + test('renders recurring checkbox when supporter mode is off', () => { + mockGetEnv.mockReturnValue(false); + renderDonationPage(); + + expect( + screen.getByLabelText(/Make this extra donation recurring/i) + ).toBeInTheDocument(); + }); + + test('does not render Monthly/One-off toggle when supporter mode is off', () => { + mockGetEnv.mockReturnValue(false); + renderDonationPage(); + + expect(screen.queryByText('Monthly')).not.toBeInTheDocument(); + expect(screen.queryByText('One-off')).not.toBeInTheDocument(); + }); +}); + +describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { + beforeEach(() => { + mockGetEnv.mockImplementation((key: string) => { + if (key === 'DONATION_SUPPORTER_MODE') return true; + if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + if (key === 'USE_STRIPE') return true; + return false; + }); + }); + + test('shows error when no membership plans are configured', () => { + mockGetEnv.mockImplementation((key: string) => { + if (key === 'DONATION_SUPPORTER_MODE') return true; + if (key === 'MEMBERSHIP_PLANS') return []; + return false; + }); + renderDonationPage(); + + expect(screen.getByText(/No donation amounts configured/i)).toBeInTheDocument(); + expect(screen.queryByText('Monthly')).not.toBeInTheDocument(); + }); + + test('renders Monthly and One-off toggle buttons', () => { + renderDonationPage(); + + expect(screen.getByText('Monthly')).toBeInTheDocument(); + expect(screen.getByText('One-off')).toBeInTheDocument(); + }); + + test('does not render recurring checkbox', () => { + renderDonationPage(); + + expect( + screen.queryByLabelText(/Make this extra donation recurring/i) + ).not.toBeInTheDocument(); + }); + + test('Monthly is active by default and shows plan tiers', () => { + renderDonationPage(); + + expect(screen.getByText('Monthly')).toHaveClass('btn-dark'); + + // Tiers from MOCK_PLANS amounts + expect(screen.getByText('£5')).toBeInTheDocument(); + expect(screen.getByText('£10')).toBeInTheDocument(); + expect(screen.getByText('£20')).toBeInTheDocument(); + }); + + test('same tiers are shown after switching to One-off', () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('One-off')); + + // Same tiers — amounts do not change with the toggle + expect(screen.getByText('£5')).toBeInTheDocument(); + expect(screen.getByText('£10')).toBeInTheDocument(); + expect(screen.getByText('£20')).toBeInTheDocument(); + }); + + test('skip button is not present in supporter mode', () => { + renderDonationPage(); + + expect(screen.queryByText('skip for now')).not.toBeInTheDocument(); + }); + + test('CTA label reflects monthly selection', () => { + renderDonationPage(); + + // Default: first plan tier £5, monthly + expect(screen.getByText(/Donate £5\/month/i)).toBeInTheDocument(); + }); + + test('CTA label reflects one-off selection', () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('One-off')); + + // Same selected tier (£5), now one-off + expect(screen.getByText(/Donate £5 now/i)).toBeInTheDocument(); + }); + + test('submitting monthly calls onCompleted with recurDonation as native boolean true', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText(/Donate £5\/month/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(typeof submitted.recurDonation).toBe('boolean'); + expect(submitted.recurDonation).toBe(true); + }); + + test('submitting one-off calls onCompleted with recurDonation as native boolean false', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText(/Donate £5 now/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(typeof submitted.recurDonation).toBe('boolean'); + expect(submitted.recurDonation).toBe(false); + }); + + test('submitting calls onCompleted with donationAmount 0 (plan price is the donation)', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText(/Donate £5\/month/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.donationAmount).toBe(0); + }); + + test('submitting with default tier calls onCompleted with membership matching that tier', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText(/Donate £5\/month/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.membership).toBe('plan-a'); + }); + + test('selecting a different tier and submitting calls onCompleted with matching membership', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('£10')); + fireEvent.click(screen.getByText(/Donate £10\/month/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.membership).toBe('plan-b'); + expect(submitted.donationAmount).toBe(0); + }); +}); + +describe('DonationPage — supporter mode, custom amounts', () => { + beforeEach(() => { + mockGetEnv.mockImplementation((key: string) => { + if (key === 'DONATION_SUPPORTER_MODE') return true; + if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + if (key === 'USE_STRIPE') return true; + return false; + }); + }); + + test('entering a custom amount and submitting monthly sets customMembershipAmount to that amount', async () => { + renderDonationPage(); + + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '69' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.customMembershipAmount).toBe(69); + }); + + test('entering a custom amount monthly sets donationAmount to 0 (plan price is the donation)', async () => { + renderDonationPage(); + + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '69' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.donationAmount).toBe(0); + }); + + test('entering a custom amount monthly sets recurDonation to true', async () => { + renderDonationPage(); + + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '69' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.recurDonation).toBe(true); + }); + + test('entering a custom amount one-off sets donationAmount to that amount', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('One-off')); + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '100' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.donationAmount).toBe(100); + }); + + test('entering a custom amount one-off forces paymentMethod to creditCard', async () => { + renderDonationPage(); + + fireEvent.click(screen.getByText('One-off')); + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '100' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.paymentMethod).toBe('creditCard'); + }); + + test('selecting a tier that matches a plan does NOT set customMembershipAmount', async () => { + renderDonationPage(); + + // £10 matches plan-b exactly — no custom amount should be forwarded + fireEvent.click(screen.getByText('£10')); + fireEvent.click(screen.getByText(/Donate £10\/month/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + expect(submitted.membership).toBe('plan-b'); + expect(submitted.customMembershipAmount).toBeUndefined(); + }); + + test('custom amount uses fallback plan as membership when no tier matches', async () => { + renderDonationPage(); + + fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { + target: { value: '999' }, + }); + fireEvent.click(screen.getByText(/Donate/i)); + + await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); + const submitted = mockOnCompleted.mock.calls[0][0]; + // No plan has amount 999, so fallback to first plan + expect(submitted.membership).toBe('plan-a'); + expect(submitted.customMembershipAmount).toBe(999); + }); +}); + +describe('DonationPage — supporter mode, GoCardless only (USE_STRIPE off)', () => { + beforeEach(() => { + mockGetEnv.mockImplementation((key: string) => { + if (key === 'DONATION_SUPPORTER_MODE') return true; + if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + if (key === 'USE_STRIPE') return false; + return false; + }); + }); + + test('One-off button is disabled', () => { + renderDonationPage(); + expect(screen.getByText('One-off').closest('button')).toBeDisabled(); + }); + + test('shows explanation that one-off donations are not available', () => { + renderDonationPage(); + expect(screen.getByText(/one-off donations are not available/i)).toBeInTheDocument(); + }); + + test('Monthly button remains enabled', () => { + renderDonationPage(); + expect(screen.getByText('Monthly').closest('button')).not.toBeDisabled(); + }); +}); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 3f46ced..5cbf63b 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -1,10 +1,11 @@ -import React from "react"; +import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useForm } from "react-hook-form"; import { ContinueButton, FormItem } from "../components/atoms"; import { StagerComponent } from "../components/stager"; import { Summary } from "../components/summary"; -import { FormSchema } from "../schema"; +import { FormSchema, currencyCodeToSymbol } from "../schema"; +import { get as getEnv } from "../env"; const membershipToDonationTiers = (membership: string): Array => { switch (membership) { @@ -29,28 +30,159 @@ export const DonationPage: StagerComponent = ({ ? membershipToDonationTiers(data.membership) : [5, 10, 15, 20]; + const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); + const oneOffAvailable = Boolean(getEnv("USE_STRIPE")) && !Boolean(getEnv("STRIPE_DIRECT_DEBIT_ONLY")); + + const membershipPlans = (getEnv("MEMBERSHIP_PLANS") as any[]) || []; + const supporterTiers: number[] = membershipPlans.map((p) => Number(p.amount)).filter((n) => n > 0); + const supporterCurrency: string = membershipPlans[0]?.currency || "GBP"; + const defaultSupporterTier = supporterTiers[0] ?? 0; + + const [isMonthly, setIsMonthly] = useState(true); + const [selectedTier, setSelectedTier] = useState(defaultSupporterTier); + const form = useForm({ defaultValues: { - donationAmount: donationTiers[1], + donationAmount: supporterMode ? defaultSupporterTier : donationTiers[1], + recurDonation: supporterMode ? true : false, ...data } }); const selectedDonationAmount = form.watch("donationAmount"); + const otherDonationAmount = form.watch("otherDonationAmount"); + + const handleSubmit = form.handleSubmit((formData) => { + if (supporterMode) { + const amount = (formData.otherDonationAmount != null && formData.otherDonationAmount !== "") + ? Number(formData.otherDonationAmount) + : selectedTier; + delete formData.otherDonationAmount; + + // Find the plan whose amount matches the selected tier. + // The plan price IS the donation — no separate donationAmount item should be created. + const matchingPlan = membershipPlans.find((p: any) => Number(p.amount) === amount); + const fallbackPlan = membershipPlans.find((p: any) => p.allowCustomAmount) ?? membershipPlans[0]; + const resolvedPlan = matchingPlan ?? fallbackPlan; + + onCompleted({ + ...formData, + membership: resolvedPlan?.value ?? formData.membership, + ...(!matchingPlan ? { customMembershipAmount: amount } : {}), + // Recurring: plan price IS the donation — no separate donationAmount item needed + // One-off: no subscription is created, so pass the amount for the PaymentIntent + donationAmount: isMonthly ? 0 : amount, + recurDonation: isMonthly, + // One-off payments require card — override any stale directDebit from session + ...(!isMonthly ? { paymentMethod: "creditCard" } : {}), + }); + return; + } + if (formData.otherDonationAmount !== "" && formData.otherDonationAmount != null) { + formData.donationAmount = formData.otherDonationAmount; + delete formData.otherDonationAmount; + } + onCompleted(formData); + }); + + if (supporterMode && supporterTiers.length === 0) { + return ( +
+ No donation amounts configured. +

+ Add membership plans to this block in the WordPress editor. Their amounts will be used as the donation tiers shown to supporters. +

+
+ ); + } + + if (supporterMode) { + const currencySymbol = currencyCodeToSymbol(supporterCurrency); + const activeAmount = (otherDonationAmount != null && otherDonationAmount !== "") + ? Number(otherDonationAmount) + : selectedTier; + const ctaLabel = activeAmount > 0 + ? (isMonthly ? `Donate ${currencySymbol}${activeAmount}/month` : `Donate ${currencySymbol}${activeAmount} now`) + : (isMonthly ? "Donate monthly" : "Donate now"); + + return ( +
+
+ +

Support us

+
+
+ +
+
+ + +
+ {!oneOffAvailable && ( +

+ One-off donations are not available with Direct Debit. To make a one-off donation, please contact us. +

+ )} + +
+ {supporterTiers.map((tier) => ( + + ))} +
+ + + + +
+ + + + ); + } return (
{ - // From the perspective of the form schema, keep things clean with only having one variable for donation amount - // So remove the otherDonationAmount if we have it and copy it across. - if (data.otherDonationAmount !== "") { - data.donationAmount = data.otherDonationAmount; - delete data.otherDonationAmount; - } - - onCompleted(data); - })} + onSubmit={handleSubmit} >
@@ -61,12 +193,12 @@ export const DonationPage: StagerComponent = ({

Can you chip in?

- We rely on our members' generosity to build our movement, - particularly as we look ahead to the next General Election and + We rely on our members' generosity to build our movement, + particularly as we look ahead to the next General Election and the work that needs to be done to gain more MPs.

- Many of our members top up their membership, which forms a vital part of our income. + Many of our members top up their membership, which forms a vital part of our income. We'd be very grateful if you would consider doing the same.

diff --git a/packages/join-flow/src/pages/payment-details.page.tsx b/packages/join-flow/src/pages/payment-details.page.tsx index 7f8b431..32345cd 100644 --- a/packages/join-flow/src/pages/payment-details.page.tsx +++ b/packages/join-flow/src/pages/payment-details.page.tsx @@ -279,29 +279,32 @@ const StripePaymentPage: StagerComponent = ({ const plan = (getEnv("MEMBERSHIP_PLANS") as any[]).find( (plan) => plan.value === data.membership ); - const amount = plan.amount - ? convertCurrencyFromMajorToMinorUnits(plan.amount) - : 100; + const isOneOffDonation = Boolean(getEnv("DONATION_SUPPORTER_MODE")) && !data.recurDonation; + // For one-off donations the amount comes from donationAmount (set by donation.page). + // For subscriptions the amount is only used by Stripe Elements for display; the + // actual charge is determined server-side from the plan / customMembershipAmount. + const amountForElements = isOneOffDonation + ? convertCurrencyFromMajorToMinorUnits(data.donationAmount as number) + : (plan.amount ? convertCurrencyFromMajorToMinorUnits(plan.amount) : 100); const currency = plan.currency.toLowerCase() || "gbp"; - const paymentMethodTypes = getEnv("STRIPE_DIRECT_DEBIT_ONLY") ? ["bacs_debit"] : ["card"]; - // Add direct debit payment method for GBP only, as it is a UK only feature - // Only add if not in Direct Debit-only mode (as it's already the only option) - if (currency === "gbp" && getEnv("STRIPE_DIRECT_DEBIT") && !getEnv("STRIPE_DIRECT_DEBIT_ONLY")) { + // One-off donations are card-only (bacs_debit is a subscription-only product in the UK) + const paymentMethodTypes = (isOneOffDonation || getEnv("STRIPE_DIRECT_DEBIT_ONLY")) ? ["card"] : ["card"]; + if (!isOneOffDonation && currency === "gbp" && getEnv("STRIPE_DIRECT_DEBIT") && !getEnv("STRIPE_DIRECT_DEBIT_ONLY")) { paymentMethodTypes.push("bacs_debit"); } - + return ( - + ); }; @@ -309,11 +312,13 @@ const StripePaymentPage: StagerComponent = ({ const StripeForm = ({ data, setData, - plan + plan, + isOneOffDonation }: { data: FormSchema; setData: (d: FormSchema) => void; plan: { frequency: string }; + isOneOffDonation: boolean; }) => { const stripe = useStripe(); const elements = useElements(); @@ -325,6 +330,10 @@ const StripeForm = ({ latest_invoice: { payment_intent: { id: string; client_secret: string } }; } >("/stripe/create-subscription"); + const createPaymentIntent = usePostResource< + FormSchema, + { id: string; client_secret: string; customer: string } + >("/stripe/create-payment-intent"); const [errorMessage, setErrorMessage] = useState(); const [loading, setLoading] = useState(false); @@ -348,22 +357,41 @@ const StripeForm = ({ setLoading(true); - elements.submit(); + const { error: submitError } = await elements.submit(); + if (submitError) { + handleError(submitError as { message: string }); + return; + } try { - const subscription = await createSubscription({ ...data }); - const clientSecret = - subscription.latest_invoice.payment_intent.client_secret; - - sessionStorage.setItem( - SAVED_STATE_KEY, - JSON.stringify({ - ...data, - stripeCustomerId: subscription.customer, - stripeSubscriptionId: subscription.id, - stripePaymentIntentId: subscription.latest_invoice.payment_intent.id - }) - ); + let clientSecret: string; + + if (isOneOffDonation) { + const paymentIntent = await createPaymentIntent({ ...data }); + clientSecret = paymentIntent.client_secret; + + sessionStorage.setItem( + SAVED_STATE_KEY, + JSON.stringify({ + ...data, + stripeCustomerId: paymentIntent.customer, + stripePaymentIntentId: paymentIntent.id + }) + ); + } else { + const subscription = await createSubscription({ ...data }); + clientSecret = subscription.latest_invoice.payment_intent.client_secret; + + sessionStorage.setItem( + SAVED_STATE_KEY, + JSON.stringify({ + ...data, + stripeCustomerId: subscription.customer, + stripeSubscriptionId: subscription.id, + stripePaymentIntentId: subscription.latest_invoice.payment_intent.id + }) + ); + } const returnUrl = new URL(window.location.href); returnUrl.searchParams.set("stripe_success", "true"); @@ -381,9 +409,9 @@ const StripeForm = ({ handleError({ message }); return; } - } catch (e) { - console.error("Create subscription error", e); - handleError({ message: "Unknown error" }); + } catch (e: any) { + console.error("Create payment error", e); + handleError({ message: e?.message || JSON.stringify(e) || "Unknown error" }); Sentry.captureException(e) } }; diff --git a/packages/join-flow/src/schema.ts b/packages/join-flow/src/schema.ts index a07aa65..893414b 100644 --- a/packages/join-flow/src/schema.ts +++ b/packages/join-flow/src/schema.ts @@ -216,7 +216,9 @@ export const getPaymentFrequency = (membership: string | undefined) => { export const renderPaymentMethod = ({ paymentMethod, - membership + membership, + recurDonation, + donationSupporterMode, }: FormSchema) => { let paymentFormat = "None"; @@ -228,6 +230,11 @@ export const renderPaymentMethod = ({ paymentFormat = "Direct Debit"; } + // One-off supporter donations have no recurring frequency + if (donationSupporterMode && !recurDonation) { + return paymentFormat; + } + const frequency = getPaymentFrequency(membership); if (frequency) {