Skip to content
Open
13 changes: 10 additions & 3 deletions app/controllers/subscriptions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ class SubscriptionsController < ApplicationController
after_action :verify_authorized, except: PUBLIC_ACTIONS

before_action :fetch_subscription, only: %i[unsubscribe_by_seller unsubscribe_by_user magic_link send_magic_link]
before_action :hide_layouts, only: [:manage, :magic_link, :send_magic_link]
before_action :hide_layouts, only: [:magic_link, :send_magic_link]
before_action :set_noindex_header, only: [:manage]
before_action :check_can_manage, only: [:manage, :unsubscribe_by_user]

layout "inertia", only: [:manage]

SUBSCRIPTION_COOKIE_EXPIRY = 1.week

def unsubscribe_by_seller
Expand All @@ -23,9 +25,10 @@ def unsubscribe_by_seller

def unsubscribe_by_user
@subscription.cancel!(by_seller: false)
render json: { success: true }
subscription_entity = @subscription.is_installment_plan ? "installment plan" : "membership"
redirect_to manage_subscription_path(@subscription.external_id), notice: "Your #{subscription_entity} has been cancelled."
rescue ActiveRecord::RecordInvalid => e
render json: { success: false, error: e.message }
redirect_to manage_subscription_path(@subscription.external_id), alert: e.message
end

def manage
Expand All @@ -39,6 +42,10 @@ def manage
set_product_page_meta(@product)

set_subscription_confirmed_redirect_cookie

render inertia: "Subscriptions/Manage",
props: CheckoutPresenter.new(logged_in_user: logged_in_user, ip: request.remote_ip)
.subscription_manager_props(subscription: @subscription)
end

def magic_link
Expand Down
21 changes: 1 addition & 20 deletions app/javascript/data/subscription.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted cancelSubscriptionByUser function because cancellation is now handled via Inertia form submission with server redirect

Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,7 @@ import {
StripeErrorParams,
serializeCardParamsIntoQueryParamsObject,
} from "$app/data/payment_method_params";
import { request, ResponseError } from "$app/utils/request";

export const cancelSubscriptionByUser = async (subscriptionId: string): Promise<void> => {
const response = await request({
url: Routes.unsubscribe_by_user_subscription_path(subscriptionId),
method: "POST",
accept: "json",
});
if (response.ok) {
const responseData = cast<{ success: boolean; redirect_to?: string }>(await response.json());
if (responseData.success) {
return;
} else if (responseData.redirect_to) {
window.location.href = responseData.redirect_to;
} else {
throw new ResponseError();
}
}
throw new ResponseError();
};
import { request } from "$app/utils/request";

export type UpdateSubscriptionPayload = {
cardParams: AnyPaymentMethodParams | StripeErrorParams | null;
Expand Down
8 changes: 0 additions & 8 deletions app/javascript/packs/subscription_edit.ts

This file was deleted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File change summary:

• Converted from ReactOnRails server component to Inertia page component
• Now uses usePage hook to get props instead of function parameters
• Replaced custom cancelSubscriptionByUser with Inertia useForm for cancellation
• Replaced window.location.href navigation with router.visit and router.reload

Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { router, useForm, usePage } from "@inertiajs/react";
import { parseISO } from "date-fns";
import * as React from "react";
import { createCast } from "ts-safe-cast";
import { cast } from "ts-safe-cast";

import { confirmLineItem } from "$app/data/purchase";
import { cancelSubscriptionByUser, updateSubscription } from "$app/data/subscription";
import { updateSubscription } from "$app/data/subscription";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • updateSubscription is kept as it is because payment flows require client-side 3D Secure handling via Stripe.js. When the server returns requires_card_action, we need to call stripe.confirmCardPayment() in the browser before any navigation occurs.
  • Inertia's redirect-based model doesn't allow intercepting the response to handle 3D Secure before navigation.

@Pradumn27 let me know if you have any opinion/approach in mind here? cc @EmCousin

import { SavedCreditCard } from "$app/parsers/card";
import { Discount } from "$app/parsers/checkout";
import { CustomFieldDescriptor, ProductNativeType } from "$app/parsers/product";
Expand All @@ -13,10 +14,7 @@ import {
formatUSDCentsWithExpandedCurrencySymbol,
getMinPriceCents,
} from "$app/utils/currency";
import { asyncVoid } from "$app/utils/promise";
import { recurrenceLabels, RecurrenceId } from "$app/utils/recurringPricing";
import { assertResponseError } from "$app/utils/request";
import { register } from "$app/utils/serverComponentUtil";

import { Button } from "$app/components/Button";
import { Creator } from "$app/components/Checkout/cartState";
Expand All @@ -38,10 +36,9 @@ import {
import { showAlert } from "$app/components/server-components/Alert";
import { Alert } from "$app/components/ui/Alert";
import { Card, CardContent } from "$app/components/ui/Card";
import { useOnChangeSync } from "$app/components/useOnChange";
import { useOriginalLocation } from "$app/components/useOriginalLocation";

import { useOnChangeSync } from "../useOnChange";

type Props = {
product: {
permalink: string;
Expand Down Expand Up @@ -98,22 +95,23 @@ type Props = {
paypal_client_id: string;
};

const SubscriptionManager = ({
product,
subscription,
recaptcha_key,
paypal_client_id,
contact_info,
countries,
us_states,
ca_provinces,
used_card,
Comment on lines -101 to -110
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Props now accessed via usePage().props instead of function parameters

}: Props) => {
export default function SubscriptionsManage() {
const {
product,
subscription,
recaptcha_key,
paypal_client_id,
contact_info,
countries,
us_states,
ca_provinces,
used_card,
} = cast<Props>(usePage().props);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Props now accessed via usePage().props instead of function parameters

const url = new URL(useOriginalLocation());

const subscriptionEntity = subscription.is_installment_plan ? "installment plan" : "membership";
const restartable = !subscription.alive || subscription.pending_cancellation;
const [cancelled, setCancelled] = React.useState(restartable);
const initialSelection = {
recurrence: subscription.recurrence,
rent: false,
Expand Down Expand Up @@ -209,7 +207,7 @@ const SubscriptionManager = ({
nativeType: product.native_type,
canGift: false,
};
const payLabel = cancelled ? `Restart ${subscriptionEntity}` : `Update ${subscriptionEntity}`;
const payLabel = restartable ? `Restart ${subscriptionEntity}` : `Update ${subscriptionEntity}`;
const { require_email_typo_acknowledgment } = useFeatureFlags();
const reducer = createReducer({
country: contact_info.country,
Expand Down Expand Up @@ -268,10 +266,10 @@ const SubscriptionManager = ({
});
if (result.type === "done") {
showAlert(result.message, "success");
setCancelled(false);
setCancellationStatus("initial");
if (result.next != null) {
window.location.href = result.next;
router.visit(result.next);
} else {
router.reload();
}
} else if (result.type === "requires_card_action") {
await confirmLineItem({
Expand All @@ -282,8 +280,7 @@ const SubscriptionManager = ({
}).then((itemResult) => {
if (itemResult.success) {
showAlert(`Your ${subscriptionEntity} has been updated.`, "success");
setCancelled(false);
setCancellationStatus("initial");
router.reload();
}
});
} else {
Expand All @@ -300,20 +297,13 @@ const SubscriptionManager = ({
if (state.status.type === "offering") dispatchAction({ type: "validate" });
}, [state.status.type]);

const [cancellationStatus, setCancellationStatus] = React.useState<"initial" | "processing" | "done">("initial");
const handleCancel = asyncVoid(async () => {
if (cancellationStatus === "processing" || cancellationStatus === "done") return;
setCancellationStatus("processing");
try {
await cancelSubscriptionByUser(subscription.id);
setCancellationStatus("done");
setCancelled(true);
} catch (e) {
assertResponseError(e);
setCancellationStatus("initial");
showAlert("Sorry, something went wrong.", "error");
}
});
const cancelForm = useForm({});
const unsubscribe = () => {
cancelForm.post(Routes.unsubscribe_by_user_subscription_path(subscription.id), {
preserveScroll: true,
onError: () => showAlert("Sorry, something went wrong.", "error"),
});
};

const hasSavedCard = state.savedCreditCard != null;
const isPendingFirstGifteePayment = subscription.is_gift && subscription.successful_purchases_count === 1;
Expand Down Expand Up @@ -378,16 +368,14 @@ const SubscriptionManager = ({
<Button
color="danger"
outline
onClick={handleCancel}
disabled={cancellationStatus === "processing" || cancellationStatus === "done"}
onClick={unsubscribe}
disabled={cancelForm.processing}
className="grow basis-0"
>
{cancellationStatus === "done" ? "Cancelled" : `Cancel ${subscriptionEntity}`}
{`Cancel ${subscriptionEntity}`}
</Button>
</CardContent>
) : null}
</Card>
);
};

export default register({ component: SubscriptionManager, propParser: createCast() });
}
2 changes: 0 additions & 2 deletions app/javascript/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import ProfileWishlistPage from "$app/components/server-components/Profile/Wishl
import DisputeEvidencePage from "$app/components/server-components/Purchase/DisputeEvidencePage";
import SecureRedirectPage from "$app/components/server-components/SecureRedirectPage";
import SubscribePage from "$app/components/server-components/SubscribePage";
import SubscriptionManager from "$app/components/server-components/SubscriptionManager";
import SubscriptionManagerMagicLink from "$app/components/server-components/SubscriptionManagerMagicLink";
import SupportHeader from "$app/components/server-components/support/Header";
import TaxesCollectionModal from "$app/components/server-components/TaxesCollectionModal";
Expand Down Expand Up @@ -66,7 +65,6 @@ ReactOnRails.register({
ProfileWishlistPage,
SecureRedirectPage,
SubscribePage,
SubscriptionManager,
SubscriptionManagerMagicLink,
TaxesCollectionModal,
VideoStreamPlayer,
Expand Down
4 changes: 0 additions & 4 deletions app/views/subscriptions/manage.html.erb

This file was deleted.

16 changes: 8 additions & 8 deletions spec/controllers/subscriptions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@
post :unsubscribe_by_user, params: { id: @subscription.external_id }
end

it "returns json success" do
it "redirects to manage page with success notice" do
post :unsubscribe_by_user, params: { id: @subscription.external_id }
expect(response.parsed_body["success"]).to be(true)
expect(response).to redirect_to(manage_subscription_path(@subscription.external_id))
expect(flash[:notice]).to eq("Your membership has been cancelled.")
end

it "is not allowed for installment plans" do
Expand All @@ -79,22 +80,21 @@

post :unsubscribe_by_user, params: { id: subscription.external_id }

expect(response.parsed_body["success"]).to be(false)
expect(response.parsed_body["error"]).to include("Installment plans cannot be cancelled by the customer")
expect(response).to redirect_to(manage_subscription_path(subscription.external_id))
expect(flash[:alert]).to include("Installment plans cannot be cancelled by the customer")
end

context "when the encrypted cookie is not present" do
before do
cookies.encrypted[@subscription.cookie_key] = nil
end

it "renders success false with redirect_to URL" do
it "redirects to magic link page" do
expect do
post :unsubscribe_by_user, params: { id: @subscription.external_id }, format: :json
post :unsubscribe_by_user, params: { id: @subscription.external_id }
end.to_not change { @subscription.reload.user_requested_cancellation_at }

expect(response.parsed_body["success"]).to be(false)
expect(response.parsed_body["redirect_to"]).to eq(magic_link_subscription_path(@subscription.external_id))
expect(response).to redirect_to(magic_link_subscription_path(@subscription.external_id))
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions spec/requests/subscription/non_tiered_membership_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,10 @@
visit "/subscriptions/#{@subscription_with_purchaser.external_id}/manage?token=#{@subscription_with_purchaser.token}"

click_on "Cancel membership"
wait_for_ajax

expect(page).to have_button("Cancelled", disabled: true)
expect(page).to have_alert(text: "Your membership has been cancelled.")
expect(page).to have_button("Restart membership")
expect(page).not_to have_button("Cancel membership")

click_on "Restart membership"
wait_for_ajax
Expand Down