diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 2f1704d..25a96a2 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -15,6 +15,7 @@ use CommonKnowledge\JoinBlock\Blocks; use CommonKnowledge\JoinBlock\Exceptions\SubscriptionExistsException; use CommonKnowledge\JoinBlock\Services\GocardlessService; +use CommonKnowledge\JoinBlock\Services\StripeService; use CommonKnowledge\JoinBlock\Services\MailchimpService; use CommonKnowledge\JoinBlock\Settings; use Monolog\Logger; @@ -272,79 +273,38 @@ 'callback' => function (WP_REST_Request $request) { global $joinBlockLog; - $joinBlockLog->info('Processing Stripe subscription creation request'); - - // This should be handled in the Stripe class, not here, but for now let's do it here - Stripe::setApiKey(Settings::get('STRIPE_SECRET_KEY')); + $data = json_decode($request->get_body(), true); - $name = 'Giuseppe Pinot-Gallizio'; - $email = 'giuseppe.pinot-gallizio@situationalism.com'; + $selectedPlanLabel = $data['membership']; - $customers = Customer::all([ - 'email' => $email, - 'limit' => 1 // We just need the first match - ]); + $joinBlockLog->info('Attempting to find a matching plan', ['selectedPlanLabel' => $selectedPlanLabel]); - $newCustomer = false; + $plan = Settings::getMembershipPlan($selectedPlanLabel); - if (count($customers->data) > 0) { - $customer = $customers->data[0]; + if (!$plan) { + throw new \Exception('Selected plan is not in the list of plans, this is unexpected'); } else { - $newCustomer = true; + $joinBlockLog->info('Found a matching plan in the list of plans', $plan); + } - $customer = Customer::create([ - 'email' => $email, - 'name' => $name - ]); + $joinBlockLog->info('Processing Stripe subscription creation request'); - $joinBlockLog->info('Customer created successfully! Customer ID: ' . $customer->id); - } + $email = $data['email']; - $data = json_decode($request->get_body(), true); + StripeService::initialise(); + [$customer, $newCustomer] = StripeService::upsertCustomer($email); - $joinBlockLog->info('Here is your Stripe data dump', $data); + $subscription = StripeService::createSubscription($customer, $plan); + + $confirmedPaymentIntent = StripeService::confirmSubscriptionPaymentIntent($subscription, $data['confirmationTokenId']); + + StripeService::updateCustomerDefaultPaymentMethod($customer->id, $subscription->latest_invoice->payment_intent->payment_method); - /* Try catch around this */ - $subscription = Subscription::create([ - 'customer' => $customer->id, - 'items' => [ - [ - 'price' => 'price_1PyB84ISmeoaI3mwaI1At8af', - ], - ], - 'payment_behavior'=> 'default_incomplete', - 'payment_settings' => ['save_default_payment_method' => 'on_subscription'], - 'expand' => ['latest_invoice.payment_intent'], - ]); - - // Need to handle this payment intent stuff??? - $paymentIntentId = $subscription->latest_invoice->payment_intent->id; - $paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId); - - /* Try catch around this */ - $confirmedPaymentIntent = $paymentIntent->confirm([ - 'confirmation_token' => $data['confirmationTokenId'], - ]); - - $status = $confirmedPaymentIntent->status; - - $paymentMethodId = $subscription->latest_invoice->payment_intent->payment_method; - - /* Try catch around this */ - Customer::update( - $customer->id, - [ - 'invoice_settings' => [ - 'default_payment_method' => $paymentMethodId, - ], - ] - ); - return [ - "status" => $status, + "status" => $confirmedPaymentIntent->status, "new_customer" => $newCustomer, - "customer" => $customer->toArray(), - "subscription" => $subscription->toArray() + "stripe_customer" => $customer->toArray(), + "stripe_subscription" => $subscription->toArray() ]; } )); diff --git a/packages/join-block/src/Services/StripeService.php b/packages/join-block/src/Services/StripeService.php new file mode 100644 index 0000000..e380943 --- /dev/null +++ b/packages/join-block/src/Services/StripeService.php @@ -0,0 +1,226 @@ + $email, + 'limit' => 1 // We just need the first match + ]); + + $newCustomer = false; + + if (count($customers->data) > 0) { + $customer = $customers->data[0]; + } else { + $newCustomer = true; + + $customer = Customer::create([ + 'email' => $email + ]); + + $joinBlockLog->info('Customer created successfully! Customer ID: ' . $customer->id); + } + + return [$customer, $newCustomer]; + } + + public static function createSubscription($customer, $plan) + { + $subscription = Subscription::create([ + 'customer' => $customer->id, + 'items' => [ + [ + 'price' => $plan['stripe_price_id'], + ], + ], + 'payment_behavior'=> 'default_incomplete', + 'payment_settings' => ['save_default_payment_method' => 'on_subscription'], + 'expand' => ['latest_invoice.payment_intent'], + ]); + + return $subscription; + } + + public static function confirmSubscriptionPaymentIntent($subscription, $confirmationTokenId) + { + global $joinBlockLog; + + $joinBlockLog->info('Confirming payment intent for subscription', $subscription->toArray()); + + if (!$subscription->latest_invoice || !$subscription->latest_invoice->payment_intent) { + $joinBlockLog->info('No payment intent found for this subscription. It might be a free trial or zero-amount invoice'); + return null; + } + + $paymentIntentId = $subscription->latest_invoice->payment_intent->id; + $paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId); + + $confirmedPaymentIntent = $paymentIntent->confirm([ + 'confirmation_token' => $confirmationTokenId, + ]); + + return $confirmedPaymentIntent; + } + + public static function updateCustomerDefaultPaymentMethod($customerId, $paymentMethodId) + { + Customer::update( + $customerId, + [ + 'invoice_settings' => [ + 'default_payment_method' => $paymentMethodId, + ], + ] + ); + } + + public static function convertFrequencyToStripeInterval($frequency) + { + switch ($frequency) { + case 'monthly': + return 'month'; + case 'yearly': + return 'year'; + case 'weekly': + return 'week'; + case 'daily': + return 'day'; + } + } + + public static function createMembershipPlanIfItDoesNotExist($membershipPlan) + { + global $joinBlockLog; + + $newOrExistingProduct = self::getOrCreateProductForMembershipTier($membershipPlan); + $newOrExistingPrice = self::getOrCreatePriceForProduct($newOrExistingProduct, $membershipPlan['amount'], $membershipPlan['currency'], self::convertFrequencyToStripeInterval($membershipPlan['frequency'])); + + return [$newOrExistingProduct, $newOrExistingPrice]; + } + + public static function getOrCreateProductForMembershipTier($membershipPlan) + { + global $joinBlockLog; + + $tierID = sanitize_title($membershipPlan['label']); + + $tierDescription = $membershipPlan['description']; + + try { + $joinBlockLog->info("Searching for existing Stripe product for membership tier '{$tierID}'"); + + $existingProducts = \Stripe\Product::search([ + 'query' => "active:'true' AND metadata['membership_plan']:'{$tierID}'", + ]); + + if (count($existingProducts->data) > 0) { + $existingProduct = $existingProducts->data[0]; + $joinBlockLog->info("Product for membership tier '{$tierID}' already exists, with Stripe ID {$existingProduct->id}"); + + // Check if the product needs to be updated + $needsUpdate = false; + $updateData = []; + + if ($existingProduct->name !== "Membership: {$membershipPlan['label']}") { + $joinBlockLog->info("Name changed, updating existing product for membership tier '{$tierID}'"); + + $updateData['name'] = "Membership: {$membershipPlan['label']}"; + $needsUpdate = true; + } + + if ($existingProduct->description !== $tierDescription) { + $joinBlockLog->info("Description changed, updating existing product for membership tier '{$tierID}'"); + + $updateData['description'] = $tierDescription; + $needsUpdate = true; + } + + if ($needsUpdate) { + $updatedProduct = \Stripe\Product::update($existingProduct->id, $updateData); + $joinBlockLog->info("Product updated for membership tier '{$tierID}', with Stripe ID {$updatedProduct->id}"); + return $updatedProduct; + } + + return $existingProduct; + } + + $joinBlockLog->info("No existing product found for membership tier '{$tierID}', creating new product"); + + $stripeProduct = [ + 'name' => "Membership: {$membershipPlan['label']}", + 'type' => 'service', + 'metadata' => ['membership_plan' => $tierID], + ]; + + if ($tierDescription) { + $stripeProduct['description'] = $tierDescription; + } + + $newProduct = \Stripe\Product::create($stripeProduct); + + $joinBlockLog->info("New Stripe product created for membership tier '{$tierID}'. Stripe Product ID {$newProduct->id}"); + + return $newProduct; + } catch (\Stripe\Exception\ApiErrorException $e) { + $joinBlockLog->error("Error creating/retrieving product: " . $e->getMessage()); + throw $e; + } + } + + public static function getOrCreatePriceForProduct($product, $amount, $currency, $interval) + { + global $joinBlockLog; + + // Stripe requires the price in lowest denomination of the currency. E.G. cents for USD, pence for GBP. + // So we multiply the amount by 100 to get the price in this format. + // We store the amount in whole units of the currency, e.g. dollars for USD, pounds for GBP. + $stripePrice = $amount * 100; + + try { + $joinBlockLog->info("Searching for existing Stripe price for recurring product '{$product->id}' with currency '{$currency}'"); + + $existingPrices = \Stripe\Price::search([ + 'query' => "active:'true' AND product:'{$product->id}' AND type:'recurring' AND currency:'{$currency}'", + ]); + + if (count($existingPrices->data) > 0) { + $joinBlockLog->info("Recurring price for product '{$product->id}' with currency '{$currency}' already exists."); + return $existingPrices->data[0]; + } + + $joinBlockLog->info("No existing price found for product '{$product->id}' with currency '{$currency}', creating new price"); + + $newPrice = \Stripe\Price::create([ + 'product' => $product->id, + 'unit_amount' => $stripePrice, + 'currency' => $currency, + 'recurring' => ['interval' => $interval], + ]); + + $joinBlockLog->info("New Stripe price created for product '{$product->id}'. Stripe Price ID {$newPrice->id}"); + + return $newPrice; + } catch (ApiErrorException $e) { + $joinBlockLog->error("Error creating/retrieving price: " . $e->getMessage()); + throw $e; + } + } +} diff --git a/packages/join-block/src/Settings.php b/packages/join-block/src/Settings.php index 86a0129..2625113 100644 --- a/packages/join-block/src/Settings.php +++ b/packages/join-block/src/Settings.php @@ -8,6 +8,8 @@ use Carbon_Fields\Field\Html_Field; use Carbon_Fields\Field\Select_Field; +use CommonKnowledge\JoinBlock\Services\StripeService; + const CONTAINER_ID = 'ck_join_flow'; class Settings @@ -158,6 +160,36 @@ public static function init() Settings::saveMembershipPlans($membership_plans); } }, 10, 2); + + add_action('ck_join_flow_membership_plan_saved', function($membershipPlan) { + global $joinBlockLog; + + if (!Settings::get('USE_STRIPE')) { + return; + } + + $joinBlockLog->info('Creating or retrieving membership plan in Stripe', $membershipPlan); + + StripeService::initialise(); + [$newOrExistingProduct, $newOrExistingPrice] = StripeService::createMembershipPlanIfItDoesNotExist($membershipPlan); + + $joinBlockLog->info('Membership plan created or retrieved from Stripe', [ + 'product' => $newOrExistingProduct->id, + 'price' => $newOrExistingPrice->id, + ]); + + $membershipPlan['stripe_product_id'] = $newOrExistingProduct->id; + $membershipPlan['stripe_price_id'] = $newOrExistingPrice->id; + + $membershipPlanID = sanitize_title($membershipPlan['label']); + update_option('ck_join_flow_membership_plan_' . $membershipPlanID, $membershipPlan); + + $joinBlockLog->info("Membership plan {$membershipPlanID} saved"); + + $joinBlockLog->info('Membership plan retrieved from options', self::getMembershipPlan($membershipPlanID)); + + wp_cache_delete('ck_join_flow_membership_plan_' . $membershipPlanID, 'options'); + }); } public static function createMembershipPlansField($name = 'membership_plans') @@ -242,6 +274,8 @@ public static function saveMembershipPlans($membership_plans) { foreach ($membership_plans as $plan) { update_option('ck_join_flow_membership_plan_' . sanitize_title($plan['label']), $plan); + + do_action('ck_join_flow_membership_plan_saved', $plan); } } diff --git a/packages/join-flow/src/app.tsx b/packages/join-flow/src/app.tsx index a14ce0c..1c892f5 100644 --- a/packages/join-flow/src/app.tsx +++ b/packages/join-flow/src/app.tsx @@ -26,7 +26,8 @@ import { usePostResource } from "./services/rest-resource.service"; import gocardless from "./images/gocardless.svg"; import chargebee from "./images/chargebee.png"; -import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { Elements } from '@stripe/react-stripe-js'; +import MinimalJoinForm from "./components/minimal-join-flow"; import { loadStripe } from '@stripe/stripe-js'; interface Stage { @@ -167,23 +168,13 @@ const App = () => { }) - // mode is going to have to be payment - const options = { - mode: 'subscription', - amount: 1000, - currency: 'gbp', paymentMethodCreation: 'manual', - // Fully customizable with appearance API. - appearance: {/*...*/ }, + mode: 'subscription', + amount: 100, + currency: 'gbp' }; - /* - Start £4 a month, end at £40 a month - - Donations start at £10 and end at £100 - */ - // @ts-ignore const minimalJoinForm = @@ -304,127 +295,4 @@ const Fail: FC<{ router: StateRouter }> = ({ router }) => { return null; }; -const MinimalJoinForm = () => { - const stripe = useStripe(); - const elements = useElements(); - - const [errorMessage, setErrorMessage] = useState(); - const [loading, setLoading] = useState(false); - const [email, setEmail] = useState(''); - - const handleError = (error) => { - setLoading(false); - setErrorMessage(error.message); - } - - const handleSubmit = async (event) => { - event.preventDefault(); - - console.log('Making payment'); - - if (!stripe) { - // Stripe.js hasn't yet loaded. - // Make sure to disable form submission until Stripe.js has loaded. - return; - } - - setLoading(true); - - // Trigger form validation and wallet collection - const { error: submitError } = await elements.submit(); - - if (submitError) { - handleError(submitError); - return; - } - - // Create the ConfirmationToken using the details collected by the Payment Element - const { error, confirmationToken } = await stripe.createConfirmationToken({ - elements - }); - - if (error) { - // This point is only reached if there's an immediate error when - // creating the ConfirmationToken. Show the error to your customer (for example, payment details incomplete) - console.log(error); - handleError(error); - return; - } - - console.log(confirmationToken); - - const APIEndpoint = getEnv('WP_REST_API') + 'join/v1/stripe/create-confirm-subscription'; - - // Pass the confirmation token back to the server - - const res = await fetch(APIEndpoint, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - confirmationTokenId: confirmationToken.id, - }), - }); - - try { - const data = await res.json(); - console.log(data); - } catch (error) { - console.log(error); - handleError("Unknown payment error, please try again or contact us."); - return; - } - - // Send to Mailchimp if enabled - - if (getEnv('USE_MAILCHIMP')) { - const mailchimpRes = await fetch(getEnv('WP_REST_API') + 'join/v1/mailchimp', { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - email - }), - }); - - try { - const data = await mailchimpRes.json(); - console.log(data); - } catch (error) { - console.log(error); - handleError("Unknown Mailchimp error, please try again or contact us."); - return; - } - } - - // Now that you have a ConfirmationToken, you can use it in the following steps to render a confirmation page or run additional validations on the server - // return fetchAndRenderSummary(confirmationToken) - console.log('We made it!'); - return true; - }; - - return ( -
-

Support Us

-
-
- - -
-
Some random Pelican House copy
- -
Custom amount
- -
-

Pay by card

-

You can cancel any time

- - setEmail(e.target.value)}> - - - {errorMessage &&
{errorMessage}
} -
-
-
- ); -} - export default App; diff --git a/packages/join-flow/src/components/minimal-join-flow.tsx b/packages/join-flow/src/components/minimal-join-flow.tsx new file mode 100644 index 0000000..e467c32 --- /dev/null +++ b/packages/join-flow/src/components/minimal-join-flow.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from "react"; + +import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { get as getEnv } from "../env"; +import { currencyCodeToSymbol } from "../schema"; + +interface Plan { + label: string; + amount: number; + currency: string; + frequency: string; + allowCustomAmount: boolean; +} + +const MinimalJoinForm: React.FC = () => { + const stripe = useStripe(); + const elements = useElements(); + + const [errorMessage, setErrorMessage] = useState(); + const [otherMessage, setOtherMessage] = useState(); + const [loading, setLoading] = useState(false); + + const [selectedPlan, setSelectedPlan] = useState(null); + const [plans, setPlans] = useState([]); + const [email, setEmail] = useState(''); + + const handleError = (error: { message: string }) => { + setLoading(false); + setErrorMessage(error.message); + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + setLoading(true); + + const { error: submitError } = await elements.submit(); + + if (submitError) { + handleError(submitError); + return; + } + + const { error, confirmationToken } = await stripe.createConfirmationToken({ + elements + }); + + // This point is only reached if there's an immediate error when + // creating the ConfirmationToken. Show the error to your customer (for example, payment details incomplete) + if (error) { + handleError(error); + return; + } + + // Pass the confirmation token to the server to create a subscription + const APIEndpoint = getEnv('WP_REST_API') + 'join/v1/stripe/create-confirm-subscription'; + + const res = await fetch(APIEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmationTokenId: confirmationToken.id, + email, + membership: selectedPlan?.label + }), + }); + + const data = await res.json(); + + if (data.status !== 'succeeded') { + setErrorMessage('Connection to payment provider failed, please try again'); + } + + setOtherMessage('Payment successful, welcome'); + }; + + const handleRangeChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + + setSelectedPlan(plans[value]); + }; + + useEffect(() => { + const fetchedPlans = getEnv("MEMBERSHIP_PLANS") as Plan[]; + + const permissableMembershipPlans = fetchedPlans.filter((plan) => !plan.allowCustomAmount); + const sortedPlans = permissableMembershipPlans.sort((a, b) => a.amount - b.amount); + const medianIndex = Math.floor(sortedPlans.length / 2); + const medianPlan = sortedPlans[medianIndex]; + + setSelectedPlan(medianPlan); + setPlans(sortedPlans); + }, []); + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + }; + + return ( +
+
+ {selectedPlan && `${currencyCodeToSymbol(selectedPlan.currency)}${selectedPlan.amount} ${selectedPlan.frequency}`} +
+ plan === selectedPlan)} + /> +
+ + + + + {errorMessage &&
{errorMessage}
} + {otherMessage &&
{otherMessage}
} +
+
+ ); +}; + +export default MinimalJoinForm;