Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/next/components/billing/subscriptions.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2021 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Alert, Button, Popconfirm, Table } from "antd";6import { useMemo, useState } from "react";7import basePath from "lib/base-path";8import { join } from "path";9import { NewFileButton } from "@cocalc/frontend/project/new/new-file-button";10import { Icon } from "@cocalc/frontend/components/icon";11import { capitalize, cmp, planInterval, stripeAmount } from "@cocalc/util/misc";12import License from "components/licenses/license";13import { CSS, Paragraph, Text, Title } from "components/misc";14import A from "components/misc/A";15import HelpEmail from "components/misc/help-email";16import Timestamp from "components/misc/timestamp";17import Loading from "components/share/loading";18import apiPost from "lib/api/post";19import useAPI from "lib/hooks/api";20import useIsMounted from "lib/hooks/mounted";21import { Details as LicenseLoader } from "../licenses/license";22import { InvoicesData } from "@cocalc/util/types/stripe";2324const DESCR_STYLE: CSS = {25wordWrap: "break-word",26wordBreak: "break-word",27} as const;2829function getInvoiceById(invoices, id) {30for (const invoice of invoices.data ?? []) {31if (invoice.id == id) return invoice;32}33return null;34}3536interface DescriptionProps {37latest_invoice: string;38metadata?: { license_id: string };39invoices: InvoicesData;40}4142function Description(props: DescriptionProps) {43const { latest_invoice, metadata, invoices } = props;4445const invoice = getInvoiceById(invoices, latest_invoice);4647if (invoice?.lines != null) {48const cnt = invoice.lines.total_count ?? 1;49const url = invoice.hosted_invoice_url;50return (51<div style={DESCR_STYLE}>52{invoice.lines.data[0].description}53{cnt > 1 && ", etc."}54{url && (55<div>56<A href={url}>57<Icon name="external-link" /> Invoice58</A>59</div>60)}61{metadata?.license_id && (62<div>63License: <License license_id={metadata?.license_id} />64</div>65)}66</div>67);68}6970// in case the above didn't return, i.e. invoice was not found in the invoices.data array, we try to load it:71if (metadata?.license_id) {72return (73<div style={DESCR_STYLE}>74<LicenseLoader license_id={metadata.license_id} condensed={true} />75License: <License license_id={metadata.license_id} />76</div>77);78}79return null;80}8182function Period({83current_period_start,84current_period_end,85cancel_at_period_end,86}) {87return (88<>89<Timestamp epoch={1000 * current_period_start} dateOnly absolute /> –{" "}90<Timestamp epoch={1000 * current_period_end} dateOnly absolute />91{cancel_at_period_end && (92<span>93<br />94(will cancel at period end)95</span>96)}97</>98);99}100101function Status({ status }) {102return <>{capitalize(status)}</>;103}104105interface CostProps {106latest_invoice: string;107plan: {108amount: number;109currency: string;110interval: string;111interval_count: number;112};113invoices: InvoicesData;114metadata?: { license_id: string };115}116117function Cost({ latest_invoice, plan, invoices, metadata }: CostProps) {118const invoice = getInvoiceById(invoices, latest_invoice);119if (invoice != null) {120const unitCount = invoice.lines?.data?.[0].quantity ?? 1;121return (122<>123{stripeAmount(plan.amount, plan.currency, unitCount)} for{" "}124{planInterval(plan.interval, plan.interval_count)}125</>126);127// since no invoice has been not found in the invoices.data array, we try to load it:128} else if (metadata?.license_id) {129return (130<LicenseLoader131license_id={metadata.license_id}132type={"cost"}133plan={plan}134/>135);136}137return <Text type="secondary">no data available</Text>;138}139140interface CancelProps {141cancel_at_period_end: boolean;142cancel_at: number | null;143id: string;144onChange: () => void;145}146147function Cancel(props: CancelProps) {148const { cancel_at_period_end, cancel_at, id, onChange } = props;149const [error, setError] = useState<string>("");150const [canceling, setCanceling] = useState<boolean>(false);151const isMounted = useIsMounted();152const isCanceled = !!cancel_at_period_end || !!cancel_at;153return (154<div>155<Popconfirm156placement="bottomLeft"157title={158<div style={{ maxWidth: "500px" }}>159Cancel? Are you sure you want to{" "}160<b>cancel this subscription at period end</b>? If you cancel your161subscription, it will run to the end of the subscription period, but162will not be renewed when the current (already paid for) period ends.163If you need further clarification or need a refund,{" "}164<HelpEmail lower />.165</div>166}167onConfirm={async () => {168setCanceling(true);169setError("");170try {171await apiPost("billing/cancel-subscription", { id });172} catch (err) {173if (!isMounted.current) return;174setError(err.message);175} finally {176if (!isMounted.current) return;177setCanceling(false);178onChange();179}180}}181okText="Yes, cancel at period end (do not auto-renew)"182cancelText="Make no change"183>184<Button disabled={isCanceled || canceling} type="dashed">185{canceling ? (186<Loading delay={0}>Canceling...</Loading>187) : (188`Cancel${isCanceled ? "ed" : ""}`189)}190</Button>191{error && (192<Alert193style={{ marginTop: "15px" }}194type="error"195message={`Error: ${error}`}196/>197)}198</Popconfirm>199</div>200);201}202203function columns(invoices, onChange) {204return [205{206responsive: ["xs"],207title: "Subscriptions",208render: (_, sub) => (209<div>210<Description {...sub} invoices={invoices} />211Status: <Status {...sub} />212<br />213Period: <Period {...sub} />214<br />215Cost: <Cost {...sub} invoices={invoices} />216<br />217<Cancel {...sub} onChange={onChange} />218</div>219),220},221{222responsive: ["sm"],223title: "Description",224width: "50%",225render: (_, sub) => <Description {...sub} invoices={invoices} />,226},227{228responsive: ["sm"],229title: "Status",230align: "center" as "center",231render: (_, sub) => <Status {...sub} />,232sorter: { compare: (a, b) => cmp(a.status, b.status) },233},234{235responsive: ["sm"],236title: "Period",237align: "center" as "center",238render: (_, sub) => <Period {...sub} />,239},240{241responsive: ["sm"],242title: "Cost",243sorter: { compare: (a, b) => cmp(a.plan.amount, b.plan.amount) },244render: (_, sub) => <Cost {...sub} invoices={invoices} />,245},246{247responsive: ["sm"],248title: "Cancel",249align: "center" as "center",250render: (_, sub) => <Cancel {...sub} onChange={onChange} />,251},252];253}254255export default function Subscriptions() {256const subscriptions = useAPI("billing/get-subscriptions", { limit: 100 });257const invoices = useAPI("billing/get-invoices-and-receipts");258259const { numLicense, numUpgrade, subs } = useMemo(() => {260let numLicense = 0,261numUpgrade = 0,262subs: any[] = [];263for (const sub of subscriptions.result?.data ?? []) {264if (sub.metadata?.service != null || sub.automatic_tax?.enabled) {265// new automatic payment subscriptions266} else if (sub.metadata?.license_id != null) {267numLicense += 1;268subs.push(sub);269} else {270numUpgrade += 1;271subs.push(sub);272}273}274return { numLicense, numUpgrade, subs };275}, [subscriptions.result]);276277if (subscriptions.error) {278return <Alert type="error" message={subscriptions.error} />;279}280if (!subscriptions.result) {281return <Loading />;282}283if (invoices.error) {284return <Alert type="error" message={invoices.error} />;285}286if (!invoices.result) {287return <Loading />;288}289290function onChange() {291subscriptions.call();292invoices.call();293}294295return (296<div>297<div style={{ textAlign: "center", marginBottom: "30px" }}>298<NewFileButton299href={join(basePath, "settings", "subscriptions")}300icon="calendar"301name="Visit the new subscriptions page..."302/>303</div>304305<Title level={2}>Legacy Subscriptions ({subs.length})</Title>306<Paragraph style={{ marginBottom: "30px" }}>307{numLicense > 0 && (308<p>309Your license subscriptions should be listed as canceled below, and310have{" "}311<A href={join(basePath, "settings", "subscriptions")} external>312migrated to the new subscriptions page313</A>314.315</p>316)}317{numUpgrade > 0 && (318<p>319Upgrade packages have been deprecated for years, but you have one so320you're grandfathered in still. Please consider cancelling your321subscription and purchasing licenses, pay-as-you-go project322upgrades, etc.{" "}323</p>324)}325<p>326You can <A href="/store/site-license">visit the store</A>.327</p>{" "}328If you have any questions <HelpEmail lower />.329</Paragraph>330<Table331columns={columns(invoices.result, onChange) as any}332dataSource={subs}333rowKey={"id"}334pagination={{ hideOnSinglePage: true, pageSize: 100 }}335style={{ overflowX: "auto" }}336/>337</div>338);339}340341342