Skip to content

[dashboard] Better surface errors when Gitpod fails to get your Stripe subscription details #14180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions components/dashboard/src/components/BillingAccountSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,22 @@ import { TeamsContext } from "../teams/teams-context";
import { UserContext } from "../user-context";
import SelectableCardSolid from "../components/SelectableCardSolid";
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
import Alert from "./Alert";

export function BillingAccountSelector(props: { onSelected?: () => void }) {
const { user, setUser } = useContext(UserContext);
const { teams } = useContext(TeamsContext);
const [teamsAvailableForAttribution, setTeamsAvailableForAttribution] = useState<Team[] | undefined>();
const [membersByTeam, setMembersByTeam] = useState<Record<string, TeamMemberInfo[]>>({});
const [errorMessage, setErrorMessage] = useState<string | undefined>();

useEffect(() => {
if (!teams) {
setTeamsAvailableForAttribution(undefined);
return;
}

// Fetch the liust of teams we can actually attribute to
// Fetch the list of teams we can actually attribute to
getGitpodService()
.server.listAvailableUsageAttributionIds()
.then((attrIds) => {
Expand All @@ -42,17 +44,22 @@ export function BillingAccountSelector(props: { onSelected?: () => void }) {
setTeamsAvailableForAttribution(
teamsAvailableForAttribution.sort((a, b) => (a.name > b.name ? 1 : -1)),
);
})
.catch((error) => {
console.error("Could not get list of available billing accounts.", error);
setErrorMessage(`Could not get list of available billing accounts. ${error?.message || String(error)}`);
});

const members: Record<string, TeamMemberInfo[]> = {};
teams.forEach(async (team) => {
try {
members[team.id] = await getGitpodService().server.getTeamMembers(team.id);
} catch (error) {
console.error("Could not get members of team", team, error);
}
});
setMembersByTeam(members);
Promise.all(
teams.map(async (team) => {
try {
members[team.id] = await getGitpodService().server.getTeamMembers(team.id);
} catch (error) {
console.warn("Could not get members of team", team, error);
}
}),
).then(() => setMembersByTeam(members));
}, [teams]);

const setUsageAttributionTeam = async (team?: Team) => {
Expand All @@ -74,6 +81,11 @@ export function BillingAccountSelector(props: { onSelected?: () => void }) {

return (
<>
{errorMessage && (
<Alert className="max-w-xl mt-2" closable={false} showIcon={true} type="error">
{errorMessage}
</Alert>
)}
{teamsAvailableForAttribution === undefined && <Spinner className="m-2 h-5 w-5 animate-spin" />}
{teamsAvailableForAttribution && (
<div>
Expand Down
47 changes: 25 additions & 22 deletions components/dashboard/src/components/UsageBasedBillingConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>();
const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>();
const [pendingStripeSubscription, setPendingStripeSubscription] = useState<PendingStripeSubscription | undefined>();
const [billingError, setBillingError] = useState<string | undefined>();
const [errorMessage, setErrorMessage] = useState<string | undefined>();

const localStorageKey = `pendingStripeSubscriptionFor${attributionId}`;
const now = dayjs().utc(true);
Expand All @@ -63,7 +63,8 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
setStripeSubscriptionId(subscriptionId);
setUsageLimit(limit);
} catch (error) {
console.error(error);
console.error("Could not get Stripe subscription details.", error);
setErrorMessage(`Could not get Stripe subscription details. ${error?.message || String(error)}`);
} finally {
setIsLoadingStripeSubscription(false);
}
Expand Down Expand Up @@ -112,7 +113,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
window.localStorage.removeItem(localStorageKey);
clearTimeout(pollStripeSubscriptionTimeout!);
setPendingStripeSubscription(undefined);
setBillingError(`Could not subscribe to Stripe. ${error?.message || String(error)}`);
setErrorMessage(`Could not subscribe to Stripe. ${error?.message || String(error)}`);
}
})();
}, [attributionId, location.search]);
Expand All @@ -130,7 +131,7 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
const pending = JSON.parse(pendingStripeSubscription);
setPendingStripeSubscription(pending);
} catch (error) {
console.error("Could not load pending Stripe subscription", attributionId, error);
console.warn("Could not load pending Stripe subscription", attributionId, error);
}
}, [attributionId, localStorageKey]);

Expand Down Expand Up @@ -201,18 +202,18 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
await getGitpodService().server.setUsageLimit(attributionId, newLimit);
setUsageLimit(newLimit);
} catch (error) {
console.error(error);
setBillingError(`Failed to update usage limit. ${error?.message || String(error)}`);
console.error("Failed to update usage limit", error);
setErrorMessage(`Failed to update usage limit. ${error?.message || String(error)}`);
}
};

return (
<div className="mb-16">
<h2 className="text-gray-500">Manage usage-based billing, usage limit, and payment method.</h2>
<div className="max-w-xl flex flex-col">
{billingError && (
{errorMessage && (
<Alert className="max-w-xl mt-2" closable={false} showIcon={true} type="error">
{billingError}
{errorMessage}
</Alert>
)}
{showSpinner && (
Expand Down Expand Up @@ -480,15 +481,15 @@ function CreditCardInputForm(props: { attributionId: string }) {
const elements = useElements();
const { currency, setCurrency } = useContext(PaymentContext);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [billingError, setBillingError] = useState<string | undefined>();
const [errorMessage, setErrorMessage] = useState<string | undefined>();

const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const attrId = AttributionId.parse(props.attributionId);
if (!stripe || !elements || !attrId) {
return;
}
setBillingError(undefined);
setErrorMessage(undefined);
setIsLoading(true);
try {
// Create Stripe customer with currency
Expand All @@ -508,18 +509,18 @@ function CreditCardInputForm(props: { attributionId: string }) {
// site first to authorize the payment, then redirected to the `return_url`.
}
} catch (error) {
console.error(error);
setBillingError(`Failed to submit form. ${error?.message || String(error)}`);
console.error("Failed to submit form.", error);
setErrorMessage(`Failed to submit form. ${error?.message || String(error)}`);
} finally {
setIsLoading(false);
}
};

return (
<form className="mt-4 flex-grow flex flex-col" onSubmit={handleSubmit}>
{billingError && (
{errorMessage && (
<Alert className="mb-4" closable={false} showIcon={true} type="error">
{billingError}
{errorMessage}
</Alert>
)}
<PaymentElement />
Expand Down Expand Up @@ -557,24 +558,24 @@ function UpdateLimitModal(props: {
onClose: () => void;
onUpdate: (newLimit: number) => {};
}) {
const [error, setError] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
const [newLimit, setNewLimit] = useState<string | undefined>(
typeof props.currentValue === "number" ? String(props.currentValue) : undefined,
);

function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!newLimit) {
setError("Please specify a limit");
setErrorMessage("Please specify a limit");
return;
}
const n = parseInt(newLimit, 10);
if (typeof n !== "number") {
setError("Please specify a limit that is a valid number");
setErrorMessage("Please specify a limit that is a valid number");
return;
}
if (typeof props.minValue === "number" && n < props.minValue) {
setError(`Please specify a limit that is >= ${props.minValue}`);
setErrorMessage(`Please specify a limit that is >= ${props.minValue}`);
return;
}
props.onUpdate(n);
Expand All @@ -586,9 +587,9 @@ function UpdateLimitModal(props: {
<form onSubmit={onSubmit}>
<div className="border-t border-b border-gray-200 dark:border-gray-700 -mx-6 px-6 py-4 flex flex-col">
<p className="pb-4 text-gray-500 text-base">Set usage limit in total credits per month.</p>
{error && (
{errorMessage && (
<Alert type="error" className="-mt-2 mb-2">
{error}
{errorMessage}
</Alert>
)}
<label className="font-medium">
Expand All @@ -597,9 +598,11 @@ function UpdateLimitModal(props: {
<input
type="text"
value={newLimit}
className={`rounded-md w-full truncate overflow-x-scroll pr-8 ${error ? "error" : ""}`}
className={`rounded-md w-full truncate overflow-x-scroll pr-8 ${
errorMessage ? "error" : ""
}`}
onChange={(e) => {
setError("");
setErrorMessage("");
setNewLimit(e.target.value);
}}
/>
Expand Down