Payment stuff, cont'd

This commit is contained in:
binwiederhier 2023-01-15 23:29:46 -05:00
parent f7f7f469ad
commit c06bfb989e
11 changed files with 457 additions and 309 deletions

View file

@ -183,6 +183,8 @@
"account_usage_tier_none": "Basic",
"account_usage_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_change_button": "Change",
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
"account_usage_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent",
"account_usage_reservations_title": "Reserved topics",

View file

@ -8,7 +8,7 @@ import {
accountTokenUrl,
accountUrl, maybeWithAuth, topicUrl,
withBasicAuth,
withBearerAuth, accountCheckoutUrl, accountBillingPortalUrl
withBearerAuth, accountBillingSubscriptionUrl, accountBillingPortalUrl
} from "./utils";
import session from "./Session";
import subscriptionManager from "./SubscriptionManager";
@ -264,9 +264,9 @@ class AccountApi {
this.triggerChange(); // Dangle!
}
async createCheckoutSession(tier) {
const url = accountCheckoutUrl(config.base_url);
console.log(`[AccountApi] Creating checkout session`);
async updateBillingSubscription(tier) {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Requesting tier change to ${tier}`);
const response = await fetch(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
@ -282,6 +282,20 @@ class AccountApi {
return await response.json();
}
async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling paid subscription`);
const response = await fetch(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`);

View file

@ -26,7 +26,7 @@ export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscr
export const accountSubscriptionSingleUrl = (baseUrl, id) => `${baseUrl}/v1/account/subscription/${id}`;
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
export const accountCheckoutUrl = (baseUrl) => `${baseUrl}/v1/account/checkout`;
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];

View file

@ -1,6 +1,6 @@
import * as React from 'react';
import {useContext, useState} from 'react';
import {LinearProgress, Stack, useMediaQuery} from "@mui/material";
import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material";
import Tooltip from '@mui/material/Tooltip';
import Typography from "@mui/material/Typography";
import EditIcon from '@mui/icons-material/Edit';
@ -18,7 +18,7 @@ import TextField from "@mui/material/TextField";
import DialogActions from "@mui/material/DialogActions";
import routes from "./routes";
import IconButton from "@mui/material/IconButton";
import {formatBytes} from "../app/utils";
import {formatBytes, formatShortDateTime} from "../app/utils";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref";
@ -28,6 +28,7 @@ import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration";
import {AccountContext} from "./App";
import {Warning, WarningAmber} from "@mui/icons-material";
const Account = () => {
if (!session.exists()) {
@ -183,7 +184,7 @@ const Stats = () => {
const handleManageBilling = async () => {
try {
const response = await accountApi.createBillingPortalSession();
window.location.href = response.redirect_url;
window.open(response.redirect_url, "billing_portal");
} catch (e) {
console.log(`[Account] Error changing password`, e);
if ((e instanceof UnauthorizedError)) {
@ -199,7 +200,10 @@ const Stats = () => {
{t("account_usage_title")}
</Typography>
<PrefGroup>
<Pref title={t("account_usage_tier_title")}>
<Pref
alignTop={account.billing?.status === "past_due"}
title={t("account_usage_tier_title")}
>
<div>
{account.role === "admin" &&
<>
@ -219,26 +223,29 @@ const Stats = () => {
>{t("account_usage_tier_upgrade_button")}</Button>
}
{config.enable_payments && account.role === "user" && account.tier?.paid &&
<>
<Button
variant="outlined"
size="small"
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_change_button")}</Button>
<Button
variant="outlined"
size="small"
onClick={handleManageBilling}
sx={{ml: 1}}
>Manage billing</Button>
</>
<Button
variant="outlined"
size="small"
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_change_button")}</Button>
}
{config.enable_payments && account.role === "user" && account.billing?.customer &&
<Button
variant="outlined"
size="small"
onClick={handleManageBilling}
sx={{ml: 1}}
>{t("account_usage_manage_billing_button")}</Button>
}
<UpgradeDialog
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
</div>
{account.billing?.status === "past_due" &&
<Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert>
}
</Pref>
{account.role !== "admin" &&
<Pref title={t("account_usage_reservations_title")}>

View file

@ -17,16 +17,20 @@ import {AccountContext} from "./App";
const UpgradeDialog = (props) => {
const { account } = useContext(AccountContext);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [selected, setSelected] = useState(account?.tier?.code || null);
const [newTier, setNewTier] = useState(account?.tier?.code || null);
const [errorText, setErrorText] = useState("");
const handleCheckout = async () => {
try {
const response = await accountApi.createCheckoutSession(selected);
if (response.redirect_url) {
window.location.href = response.redirect_url;
if (newTier == null) {
await accountApi.deleteBillingSubscription();
} else {
await accountApi.sync();
const response = await accountApi.updateBillingSubscription(newTier);
if (response.redirect_url) {
window.location.href = response.redirect_url;
} else {
await accountApi.sync();
}
}
} catch (e) {
@ -46,10 +50,10 @@ const UpgradeDialog = (props) => {
display: "flex",
flexDirection: "row"
}}>
<TierCard code={null} name={"Free"} selected={selected === null} onClick={() => setSelected(null)}/>
<TierCard code="starter" name={"Starter"} selected={selected === "starter"} onClick={() => setSelected("starter")}/>
<TierCard code="pro" name={"Pro"} selected={selected === "pro"} onClick={() => setSelected("pro")}/>
<TierCard code="business" name={"Business"} selected={selected === "business"} onClick={() => setSelected("business")}/>
<TierCard code={null} name={"Free"} selected={newTier === null} onClick={() => setNewTier(null)}/>
<TierCard code="starter" name={"Starter"} selected={newTier === "starter"} onClick={() => setNewTier("starter")}/>
<TierCard code="pro" name={"Pro"} selected={newTier === "pro"} onClick={() => setNewTier("pro")}/>
<TierCard code="business" name={"Business"} selected={newTier === "business"} onClick={() => setNewTier("business")}/>
</div>
</DialogContent>
<DialogFooter status={errorText}>