Files
jambonz-webapp/src/containers/internal/views/accounts/subscription-form.tsx
Hoan Luu Huu e65d9b9db6 some S3 compatible storage systems have a region parameter (#524)
* some S3 compatible storage systems have a region parameter

* wip

* wip

* replace current toastMethod by new toastProvider

* wip

* fix failing testcase

* wip
2025-05-28 10:03:39 -04:00

656 lines
21 KiB
TypeScript

import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
import React, { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { postSubscriptions, useApiData } from "src/api";
import { CurrencySymbol } from "src/api/constants";
import {
CurrentUserData,
PriceInfo,
ServiceData,
StripeCustomerId,
Subscription,
} from "src/api/types";
import { Modal, Section } from "src/components";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { hasValue } from "src/utils";
import {
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { PaymentMethod } from "@stripe/stripe-js";
import { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
const SubscriptionForm = () => {
const { toastError, toastSuccess } = useToast();
const [userData] = useApiData<CurrentUserData>("Users/me");
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
const [total, setTotal] = useState(0);
const [cardErrorCase, setCardErrorCase] = useState(false);
const [isReviewChanges, setIsReviewChanges] = useState(false);
const [isReturnToFreePlan, setIsReturnToFreePlan] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const isModifySubscription = location.pathname.includes(
"modify-subscription",
);
const [billingCharge, setBillingCharge] = useState<Subscription | null>(null);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [isDisableSubmitButton, setIsDisableSubmitButton] =
useState(isModifySubscription);
const stripe = useStripe();
const elements = useElements();
const createSubscription = async (paymentMethod: PaymentMethod) => {
let body: Subscription = {};
if (cardErrorCase) {
body = {
action: "update-payment-method",
payment_method_id: paymentMethod.id,
};
} else {
body = {
action: "upgrade-to-paid",
payment_method_id: paymentMethod.id,
stripe_customer_id: userStripeInfo?.stripe_customer_id,
products: serviceData.map((service) => ({
price_id: service.stripe_price_id,
product_sid: service.product_sid,
quantity: service.capacity || 0,
})),
};
}
postSubscriptions(body)
.then(({ json }) => {
if (json.status === "success") {
toastSuccess("Payment completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
} else if (json.status === "action required") {
if (stripe) {
const location = window.location;
stripe
.confirmPayment({
clientSecret: json.client_secret || "",
confirmParams: {
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
},
})
.then((error) => {
if (error) {
toastError(error.error.message || "");
return;
}
})
.finally(() => {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
});
}
} else if (json.status === "card error") {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
setCardErrorCase(true);
}
})
.catch((error) => {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(error.msg || "Something went wrong, please try again.");
});
};
const retrieveBillingChanges = async () => {
const updatedProducts = serviceData.map((product) => ({
price_id: product.stripe_price_id,
product_sid: product.product_sid,
quantity: product.capacity || 0,
}));
postSubscriptions({
action: "update-quantities",
dry_run: true,
products: updatedProducts,
})
.then(({ json }) => {
setBillingCharge(json);
setIsReviewChanges(true);
})
.catch((error) => {
toastError(error.msg || "Something went wrong, please try again.");
setIsDisableSubmitButton(false);
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setIsDisableSubmitButton(true);
if (isModifySubscription) {
retrieveBillingChanges();
return;
}
setIsShowModalLoader(true);
const { error: elementsError } = await elements.submit();
if (elementsError) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(elementsError.message || "");
return;
}
const card = elements.getElement(PaymentElement);
if (!card) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
return;
}
const { error, paymentMethod } = await stripe.createPaymentMethod({
element: card,
});
if (error) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(error.message || "");
return;
}
createSubscription(paymentMethod);
};
const handleReturnToFreePlan = () => {
setIsReturnToFreePlan(false);
setIsShowModalLoader(true);
const body: Subscription = {
action: "downgrade-to-free",
};
postSubscriptions(body)
.then(() => {
toastSuccess("Downgrade to free plan completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
})
.catch((error) => {
toastError(error.msg);
})
.finally(() => setIsShowModalLoader(false));
};
const handleReviewChangeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsShowModalLoader(true);
const updatedProducts = serviceData.map((product) => ({
price_id: product.stripe_price_id,
product_sid: product.product_sid,
quantity: product.capacity,
}));
postSubscriptions({
action: "update-quantities",
products: updatedProducts,
})
.then(() => {
toastSuccess(
"Your subscription capacity has been successfully modified.",
);
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
})
.catch(() => {
toastError(
`The additional capacity you that you requested could not be granted due to a failure processing payment.
Please configure a valid credit card for your account and the upgrade will be automatically processed`,
);
})
.finally(() => {
setIsShowModalLoader(false);
setIsDisableSubmitButton(false);
});
};
// subscription categories
const [serviceData, setServiceData] = useState<ServiceData[]>([
{
category: "voice_call_session",
name: "concurrent call session",
service: "Maximum concurrent call sessions",
fees: 0,
feesLabel: "",
cost: 0,
capacity: 0,
invalid: false,
currency: "usd",
min: 5,
max: 1000,
dirty: false,
visible: true,
required: true,
},
{
category: "device",
name: "registered device",
service: "Additional device registrations",
fees: 0,
feesLabel: "",
cost: 0,
capacity: 0,
invalid: false,
currency: "usd",
min: 0,
max: 200,
dirty: false,
visible: false,
required: false,
},
]);
const [originalServiceData, setOriginalServiceData] = useState<ServiceData[]>(
[],
);
const initFeesAndCost = (
priceData: PriceInfo[],
serviceData: ServiceData[],
) => {
serviceData.forEach((service) => {
const record = priceData.find(
(item) => item.category === service.category,
);
if (record) {
const price = record.prices.find(
(item) => item.currency === service.currency,
);
if (price) {
let fees = 0;
switch (price.billing_scheme) {
case "per_unit":
fees = ((price.unit_amount || 0) * 1) / 100;
break;
case "tiered":
if (price.tiers && price.tiers.length) {
const tier = price.tiers.find(
(item) => !item.up_to || item.up_to >= service.capacity,
);
if (tier) {
if (typeof tier.flat_amount === "number") {
fees = tier.flat_amount / 100;
} else {
fees = ((tier.unit_amount || 0) * 1) / 100;
}
}
service.tiers = price.tiers;
}
break;
default:
break;
}
service.billing_scheme = price.billing_scheme;
service.stripe_price_id = price.stripe_price_id;
service.unit_label = record.unit_label;
service.product_sid = record.product_sid;
service.stripe_product_id = record.stripe_product_id;
service.fees = fees;
service.cost = fees * service.capacity;
service.feesLabel = `${
CurrencySymbol[service.currency || "usd"]
}${fees} per ${
record.unit_label?.slice(0, 3) === "per"
? record.unit_label.slice(3)
: record.unit_label
}`;
}
}
});
return [...serviceData];
};
const getServicePrice = (
service: ServiceData,
capacity: number,
): [number, string, number] => {
let fees = 0;
let feesLabel = "";
let cost = 0;
const capacityNum = capacity;
if (service.billing_scheme === "per_unit") {
fees = service.fees;
cost = fees * capacityNum;
} else if (service.billing_scheme === "tiered") {
const filteredTiers = service.tiers
? service.tiers.filter(
(item) => !item.up_to || item.up_to >= capacityNum,
)
: [];
if (filteredTiers.length) {
const tier = filteredTiers[0];
if (typeof tier.flat_amount === "number") {
fees = tier.flat_amount / 100;
cost = fees;
} else {
fees = (tier.unit_amount || 0) / 100;
cost = fees * capacityNum;
}
}
}
feesLabel = `${CurrencySymbol[service.currency || "usd"]}${fees} per ${
service.unit_label && service.unit_label.slice(0, 3) === "per"
? service.unit_label.slice(3)
: service.unit_label
}`;
return [fees, feesLabel, cost];
};
const setProductsInfo = (data: CurrentUserData) => {
const { products } = data.subscription || {};
const services = serviceData.map((service) => {
const { quantity } = products
? products.find((item) => item.name === service.name) || {}
: { quantity: null };
const [fees, feesLabel, cost] = getServicePrice(service, quantity || 0);
return {
...service,
capacity: quantity || 0,
invalid: false,
fees,
feesLabel,
cost,
visible: hasValue(quantity) && quantity > 0,
};
});
setServiceData(services);
setOriginalServiceData([...services]);
};
const updateServiceData = (
index: number,
key: string,
value: (typeof serviceData)[number][keyof ServiceData],
) => {
let serviceD = serviceData.map((g, i) =>
i === index
? {
...g,
[key]: value,
}
: g,
);
if (key === "capacity" && priceInfo) {
serviceD = initFeesAndCost(priceInfo, serviceD);
}
setServiceData([...serviceD]);
};
useEffect(() => {
if (priceInfo) {
setServiceData(initFeesAndCost(priceInfo, serviceData));
}
if (userData && priceInfo) {
setProductsInfo(userData);
}
}, [priceInfo, userData]);
useEffect(() => {
if (isModifySubscription && originalServiceData.length > 0) {
setIsDisableSubmitButton(
serviceData[0].capacity === originalServiceData[0].capacity &&
serviceData[1].capacity === originalServiceData[1].capacity,
);
}
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
}, [serviceData]);
return (
<>
<H1 className="h2">
{isModifySubscription
? "Configure Your Subscription"
: "Upgrade your Subscription"}
</H1>
{isShowModalLoader && (
<ModalLoader>
<P>
Your requested changes are being processed. Please do not leave the
page or hit the back button until complete.
</P>
</ModalLoader>
)}
{isReviewChanges && !isShowModalLoader && (
<Modal
handleCancel={() => {
setIsReviewChanges(false);
setIsDisableSubmitButton(false);
}}
handleSubmit={handleReviewChangeSubmit}
>
<H1 className="h4">Confirm Changes</H1>
<P>
By pressing{" "}
<span>
<strong>Confirm</strong>
</span>{" "}
below, your plan will be immediately adjusted to the following
levels:
</P>
<ul className="m">
<li>{`- ${serviceData[0].capacity} simultaneous calls`}</li>
{userData?.account && userData?.account.device_to_call_ratio && (
<li>{`- ${
userData?.account.device_to_call_ratio *
(serviceData[0].capacity + serviceData[1].capacity)
} registered devices`}</li>
)}
</ul>
<P>
{(billingCharge?.prorated_cost || 0) > 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will immediately be charged a one-time prorated amount of $${
(billingCharge?.prorated_cost || 0) / 100
} to cover the remainder of the current billing period.`}
{billingCharge?.prorated_cost === 0 &&
`Your monthly charge will be $${
(billingCharge.monthly_cost || 0) / 100
}.`}
{(billingCharge?.prorated_cost || 0) < 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will receive a credit of $${
-(billingCharge?.prorated_cost || 0) / 100
} on your next invoice to reflect changes made during the current billing period.`}
</P>
</Modal>
)}
{isReturnToFreePlan && !isShowModalLoader && (
<Modal
handleCancel={() => setIsReturnToFreePlan(false)}
handleSubmit={handleReturnToFreePlan}
>
<H1 className="h4">Return to Free Plan</H1>
<P>
Returning to the free plan will reduce your capacity to a maximum of
1 simultaneous call session and 1 registered device. Your current
plan and capacity will continue through the rest of the billing
cycle and your plan change will take effect at the beginning of the
next billing cycle. Are you sure you want to continue?
</P>
</Modal>
)}
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<div className="grid grid--col4--users">
<div className="grid__row grid__th">
<div>Service</div>
<div>Capacity</div>
<div>Price</div>
<div>Cost</div>
</div>
{serviceData &&
serviceData
.filter((service) => service.visible)
.map((service, idx) => (
<React.Fragment key={`subscription-${idx}`}>
<div className="grid__row">
<div>
<label htmlFor={service.name || ""}>
{service.service}
<span>*</span>
</label>
</div>
<div>
<input
id="tech_prefix"
name="tech_prefix"
type="number"
value={service.capacity}
required
min={service.min}
max={service.max}
onChange={(e) => {
updateServiceData(
idx,
"capacity",
e.target.value ? Number(e.target.value) : "",
);
}}
/>
</div>
<div>
<em>{service.feesLabel}</em>
</div>
<div>
<P>
<strong>
{CurrencySymbol[service.currency || "usd"]}
{service.cost}
</strong>
</P>
</div>
</div>
</React.Fragment>
))}
{serviceData[0].capacity !== 0 && !serviceData[1].visible && (
<>
<div className="grid__row">
<label htmlFor="max_concurrent_call_sessons">
{`With ${
serviceData[0].capacity
} call sessions you can register ${
serviceData[0].capacity *
(userData?.account?.device_to_call_ratio || 0)
} concurrent devices`}
</label>
<div>
<Button
mainStyle="hollow"
onClick={() =>
setServiceData((prev) => {
prev[1].visible = true;
return [...prev];
})
}
>
Would you like to purchase additional device
registrations?
</Button>
</div>
</div>
</>
)}
<div className="grid__row">
<div>
<label htmlFor="total">Total Monthly Cost</label>
</div>
<div></div>
<div></div>
<div>
<P>
<strong>
{CurrencySymbol[serviceData[0].currency || "usd"]}
{total}
</strong>
</P>
</div>
</div>
{!isModifySubscription && (
<fieldset>
<label htmlFor="total">Payment Information</label>
<div className="grid__row">
<div></div>
<div>
<PaymentElement
options={{
paymentMethodOrder: ["card"],
}}
/>
</div>
</div>
</fieldset>
)}
</div>
<fieldset>
<>
<div className={isModifySubscription ? "mast" : ""}>
{isModifySubscription && (
<ButtonGroup right>
<Button
type="button"
subStyle="grey"
mainStyle="hollow"
onClick={() => setIsReturnToFreePlan(true)}
small
>
Return to free plan
</Button>
</ButtonGroup>
)}
<ButtonGroup right>
<Button
subStyle="grey"
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
small
>
Cancel
</Button>
<Button type="submit" disabled={isDisableSubmitButton} small>
{isModifySubscription
? "Review Changes"
: `Pay ${CurrencySymbol[serviceData[0].currency || "usd"]}
${total} and Upgrade to Paid Plan`}
</Button>
</ButtonGroup>
</div>
</>
</fieldset>
</form>
</Section>
</>
);
};
export default SubscriptionForm;