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/store/checkout.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Checkout -- finalize purchase and pay.7*/8import {9Alert,10Button,11Card,12Checkbox,13Divider,14Col,15Row,16Spin,17Table,18} from "antd";19import { useContext, useEffect, useState } from "react";20import { Icon } from "@cocalc/frontend/components/icon";21import { money } from "@cocalc/util/licenses/purchase/utils";22import { copy_without as copyWithout, isValidUUID } from "@cocalc/util/misc";23import A from "components/misc/A";24import SiteName from "components/share/site-name";25import useIsMounted from "lib/hooks/mounted";26import { useRouter } from "next/router";27import { describeItem, DisplayCost } from "./site-license-cost";28import { useProfileWithReload } from "lib/hooks/profile";29import { Paragraph, Title, Text } from "components/misc";30import { COLORS } from "@cocalc/util/theme";31import { ChangeEmailAddress } from "components/account/config/account/email";32import * as purchasesApi from "@cocalc/frontend/purchases/api";33import { currency, round2up, round2down } from "@cocalc/util/misc";34import type { CheckoutParams } from "@cocalc/server/purchases/shopping-cart-checkout";35import { ProductColumn } from "./cart";36import ShowError from "@cocalc/frontend/components/error";37import { StoreBalanceContext } from "../../lib/balance";3839enum PaymentIntent {40PAY_TOTAL,41APPLY_BALANCE,42}4344export default function Checkout() {45const router = useRouter();46const isMounted = useIsMounted();47const [completingPurchase, setCompletingPurchase] = useState<boolean>(false);48const [paymentIntent, setPaymentIntent] = useState<PaymentIntent>(49PaymentIntent.APPLY_BALANCE,50);51const [totalCost, setTotalCost] = useState<number>(0);52const [error, setError] = useState<string>("");53const { profile, reload: reloadProfile } = useProfileWithReload({54noCache: true,55});56const { refreshBalance } = useContext(StoreBalanceContext);57const [session, setSession] = useState<{ id: string; url: string } | null>(58null,59);60const updateSession = async () => {61const session = await purchasesApi.getCurrentCheckoutSession();62setSession(session);63return session;64};6566const [paymentAmount, setPaymentAmount0] = useState<number>(0);67const setPaymentAmount = (amount: number) => {68// no matter how this is set, always round it up to nearest penny.69setPaymentAmount0(round2up(amount));70};71const [params, setParams] = useState<CheckoutParams | null>(null);72const updateParams = async (intent?) => {73try {74const params = await purchasesApi.getShoppingCartCheckoutParams({75ignoreBalance: (intent ?? paymentIntent) == PaymentIntent.PAY_TOTAL,76});77const cost = params.total;78setParams(params);79setTotalCost(round2up(cost));8081if ((intent ?? paymentIntent) === PaymentIntent.APPLY_BALANCE) {82setPaymentAmount(params.chargeAmount ?? 0);83} else {84setPaymentAmount(85Math.max(Math.max(params.minPayment, cost), params.chargeAmount ?? 0),86);87}88} catch (err) {89setError(`${err}`);90}91};9293useEffect(() => {94// on load, check for existing payent session.95updateSession();96// on load also get current price, cart, etc.97updateParams();98}, []);99100// handle ?complete -- i.e., what happens after successfully paying101// for a purchase - we do ANOTHER completePurchase, and for the second102// one no additional payment is required, so in this case user actually103// gets the items and goes to the congrats page. Unless, of course,104// they try to be sneaky and add something to their cart right *after*105// paying... in which case they will just get asked for additional106// money for that last thing. :-)107useEffect(() => {108if (router.query.complete == null) {109// nothing to handle110return;111}112(async () => {113// in case webhooks aren't configured, get the payment via sync:114try {115setCompletingPurchase(true);116await purchasesApi.syncPaidInvoices();117} catch (err) {118console.warn("syncPaidInvoices buying licenses -- issue", err);119} finally {120setCompletingPurchase(false);121}122// now do the purchase flow again with money available.123completePurchase(false);124})();125}, []);126127if (error) {128return <ShowError error={error} setError={setError} />;129}130async function completePurchase(ignoreBalance: boolean) {131try {132setError("");133setCompletingPurchase(true);134const curSession = await updateSession();135if (curSession != null || !isMounted.current) {136// there is already a stripe checkout session that hasn't been finished, so let's137// not cause confusion by creating another one.138// User will see a big alert with a link to finish this one, since updateSession139// sets the session state.140return;141}142// This api call tells the backend, "make a session that, when successfully finished, results in143// buying everything in my shopping cart", or, if it returns {done:true}, then144// It succeeds if the purchase goes through.145const currentUrl = window.location.href.split("?")[0];146const success_url = `${currentUrl}?complete=true`;147const result = await purchasesApi.shoppingCartCheckout({148success_url,149cancel_url: currentUrl,150paymentAmount,151ignoreBalance,152});153if (result.done) {154// done -- nothing further to do!155if (isMounted.current) {156router.push("/store/congrats");157}158return;159}160// payment is required to complete the purchase, since user doesn't161// have enough credit.162window.location = result.session.url as any;163} catch (err) {164// The purchase failed.165setError(err.message);166} finally {167refreshBalance();168if (!isMounted.current) return;169setCompletingPurchase(false);170}171}172173const cancelPurchaseInProgress = async () => {174try {175await purchasesApi.cancelCurrentCheckoutSession();176updateSession();177updateParams();178} catch (err) {179setError(err.message);180}181};182183if (params == null) {184return (185<div style={{ textAlign: "center" }}>186<Spin size="large" tip="Loading" />187</div>188);189}190191const columns = getColumns();192let mode;193if (completingPurchase) {194mode = "completing";195} else if (params == null || paymentAmount == 0) {196mode = "complete";197} else {198mode = "add";199}200201return (202<>203{session != null && (204<div style={{ textAlign: "center" }}>205<Alert206style={{ margin: "30px", display: "inline-block" }}207type="warning"208message={<h2>Purchase in Progress</h2>}209description={210<div style={{ fontSize: "14pt", width: "450px" }}>211<Divider />212<p>213<Button href={session.url} type="primary" size="large">214Complete Purchase215</Button>216</p>217or218<p style={{ marginTop: "15px" }}>219<Button onClick={cancelPurchaseInProgress}>Cancel</Button>220</p>221</div>222}223/>224</div>225)}226<div style={session != null ? { opacity: 0.4 } : undefined}>227<RequireEmailAddress profile={profile} reloadProfile={reloadProfile} />228{params.cart.length == 0 && (229<div style={{ maxWidth: "800px", margin: "auto" }}>230<h3>231<Icon name={"shopping-cart"} style={{ marginRight: "5px" }} />232{params.cart.length > 0 && (233<>234Nothing in Your <SiteName />{" "}235<A href="/store/cart">Shopping Cart</A> is Selected236</>237)}238{(params.cart.length ?? 0) == 0 && (239<>240Your <SiteName /> <A href="/store/cart">Shopping Cart</A> is241Empty242</>243)}244</h3>245<br />246<br />247You must have at least one item in{" "}248<A href="/store/cart">your cart</A> to checkout. Shop for{" "}249<A href="/store/site-license">upgrades</A>, a{" "}250<A href="/store/boost">license boost</A>, or a{" "}251<A href="/dedicated">dedicated VM or disk</A>.252</div>253)}254{params.cart.length > 0 && (255<>256<ShowError error={error} setError={setError} />257<Card title={<>1. Review Items ({params.cart.length})</>}>258<Table259showHeader={false}260columns={columns}261dataSource={params.cart}262rowKey={"id"}263pagination={{ hideOnSinglePage: true }}264/>265<GetAQuote items={params.cart} />266</Card>267268<div style={{ height: "30px" }} />269270<Card title={<>2. Place Your Order</>}>271<Row>272<Col sm={12} style={{ textAlign: "center" }}>273{round2down(274(params.balance ?? 0) - (params.minBalance ?? 0),275) > 0 && (276<Checkbox277style={{ marginTop: "38px" }}278checked={paymentIntent == PaymentIntent.APPLY_BALANCE}279onChange={async (e) => {280let intent;281if (e.target.checked) {282intent = PaymentIntent.APPLY_BALANCE;283} else {284intent = PaymentIntent.PAY_TOTAL;285}286setPaymentIntent(intent);287await updateParams(intent);288}}289>290Apply Account Balance Toward Purchase291</Checkbox>292)}293</Col>294<Col sm={12}>295<div style={{ fontSize: "15pt" }}>296<TotalCost totalCost={totalCost} />297<br />298<Terms />299</div>300</Col>301</Row>302303<ExplainPaymentSituation304params={params}305style={{ margin: "15px 0" }}306/>307<div style={{ textAlign: "center" }}>308<Divider />309<Button310disabled={311params?.total == 0 ||312completingPurchase ||313!profile?.email_address ||314session != null315}316style={{ marginTop: "7px", marginBottom: "15px" }}317size="large"318type="primary"319onClick={() =>320completePurchase(paymentIntent === PaymentIntent.PAY_TOTAL)321}322>323<Icon name="credit-card" />{" "}324{mode == "completing" && (325<>326Completing Purchase327<Spin style={{ marginLeft: "10px" }} />328</>329)}330{mode == "complete" &&331`Complete Purchase${332session != null ? " (finish payment first)" : ""333}`}334{mode == "add" &&335`Add ${currency(paymentAmount)} credit to your account`}336</Button>337</div>338{completingPurchase ||339params == null ||340paymentAmount != params.minPayment ? null : (341<div style={{ color: "#666", marginTop: "15px" }}>342NOTE: There is a minimum transaction amount of{" "}343{currency(params.minPayment)}.344</div>345)}346{mode == "add" && (347<div>348<b>DO NOT ADD MONEY TO COMPLETE YOUR PURCHASE TWICE:</b>{" "}349<div style={{ color: "#666" }}>350If you pay us, and the money doesn't immediately show up,351wait a minute and refresh your browser rather than going352through the entire process of paying us again. If you pay353us, we will definitely receive the money, but money transfer354is not always instant. Instead of just trying to pay us355again, wait a little and refresh your browser or{" "}356<A href="/support/new">contact us</A>. Your shopping cart357contents won't be lost.358</div>359</div>360)}361</Card>362</>363)}364<ShowError error={error} setError={setError} />365</div>366</>367);368}369370export function fullCost(items) {371let full_cost = 0;372for (const { cost, checked } of items) {373if (checked) {374full_cost += cost.cost_sub_first_period ?? cost.cost;375}376}377return full_cost;378}379380function TotalCost({ totalCost }) {381return (382<>383Total:{" "}384<b style={{ float: "right", color: "darkred" }}>{money(totalCost)}</b>385</>386);387}388389function Terms() {390return (391<Paragraph392style={{ color: COLORS.GRAY, fontSize: "10pt", marginTop: "8px" }}393>394By placing your order, you agree to{" "}395<A href="/policies/terms" external>396our terms of service397</A>{" "}398regarding refunds and subscriptions.399</Paragraph>400);401}402403export function DescriptionColumn({ cost, description, voucherPeriod }) {404const { input } = cost;405return (406<>407<div style={{ fontSize: "12pt" }}>408{description.title && (409<div>410<b>{description.title}</b>411</div>412)}413{description.description && <div>{description.description}</div>}414{describeItem({ info: input, voucherPeriod })}415</div>416</>417);418}419420const MIN_AMOUNT = 100;421422function GetAQuote({ items }) {423const router = useRouter();424const [more, setMore] = useState<boolean>(false);425let isSub;426for (const item of items) {427if (item.description.period != "range") {428isSub = true;429break;430}431}432433function createSupportRequest() {434const x: any[] = [];435for (const item of items) {436x.push({437cost: money(item.cost.cost),438...copyWithout(item, [439"account_id",440"added",441"removed",442"purchased",443"checked",444"cost",445]),446});447}448const body = `Hello,\n\nI would like to request a quote. I filled out the online form with the\ndetails listed below:\n\n\`\`\`\n${JSON.stringify(449x,450undefined,4512,452)}\n\`\`\``;453router.push({454pathname: "/support/new",455query: {456hideExtra: true,457subject: "Request for a quote",458body,459type: "question",460},461});462}463464return (465<Paragraph style={{ paddingTop: "15px" }}>466<A onClick={() => setMore(!more)}>467Need to obtain a quote, invoice, modified terms, a purchase order, or468pay via wire transfer, etc.?469</A>470{more && (471<Paragraph>472{fullCost(items) <= MIN_AMOUNT || isSub ? (473<Alert474showIcon475style={{476margin: "15px 0",477fontSize: "12pt",478borderRadius: "5px",479}}480type="warning"481message={482<>483Customized payment is available only for{" "}484<b>non-subscription purchases over ${MIN_AMOUNT}</b>. Make485sure your cost before discounts is over ${MIN_AMOUNT} and{" "}486<A href="/store/cart">convert</A> any subscriptions in your487cart to explicit date ranges, then try again. If this is488confusing, <A href="/support/new">make a support request</A>.489</>490}491/>492) : (493<Alert494showIcon495style={{496margin: "15px 0",497fontSize: "12pt",498borderRadius: "5px",499}}500type="info"501message={502<>503Click the button below to copy your shopping cart contents to504a support request, and we will take if from there. Note that505the 25% self-service discount is <b>only available</b> when506you purchase from this page.507<div style={{ textAlign: "center", marginTop: "5px" }}>508<Button onClick={createSupportRequest}>509<Icon name="medkit" /> Copy cart to support request510</Button>511</div>512</>513}514/>515)}516</Paragraph>517)}518</Paragraph>519);520}521522function RequireEmailAddressDescr({523emailSuccess,524onSuccess,525profile,526}): JSX.Element {527if (emailSuccess) {528return (529<Paragraph>530Your email address is now:{" "}531<Text code>{profile?.email_address ?? ""}</Text>.532</Paragraph>533);534} else {535return (536<Paragraph537style={{538backgroundColor: "white",539padding: "20px",540borderRadius: "10px",541}}542>543<ChangeEmailAddress embedded={true} onSuccess={onSuccess} />544</Paragraph>545);546}547}548549function RequireEmailAddressMesg({ emailSuccess }): JSX.Element {550return (551<>552<Title level={2}>553<Icon name="envelope" />{" "}554{!emailSuccess ? "Missing Email Address" : "Email Address Saved"}555</Title>556{!emailSuccess && (557<Paragraph>558To place an order, we need to know an email address of yours. Please559save it to your profile:560</Paragraph>561)}562</>563);564}565566export function RequireEmailAddress({ profile, reloadProfile }) {567const [emailSuccess, setEmailSuccess] = useState<boolean>(false);568569if (profile == null) {570// profile not yet loaded.571// there was a bug where it would flash the alert below while572// loading the user's profile, which looks really dumb.573return null;574}575if (profile?.email_address != null && !emailSuccess) {576// address is defined, and they didn't just set it (so we don't577// have to show a message confirming that), then nothing to do.578return null;579}580581return (582<Alert583style={{ marginBottom: "30px" }}584type={emailSuccess ? "success" : "error"}585message={<RequireEmailAddressMesg emailSuccess={emailSuccess} />}586description={587<RequireEmailAddressDescr588emailSuccess={emailSuccess}589profile={profile}590onSuccess={() => {591reloadProfile();592setEmailSuccess(true);593}}594/>595}596/>597);598}599600export function getColumns({601noDiscount,602voucherPeriod,603}: { noDiscount?: boolean; voucherPeriod?: boolean } = {}) {604return [605{606responsive: ["xs" as "xs"],607render: ({ cost, description, project_id }) => {608return (609<div>610<DescriptionColumn611cost={cost}612description={description}613voucherPeriod={voucherPeriod}614/>615<ProjectID project_id={project_id} />616<div>617<b style={{ fontSize: "11pt" }}>618<DisplayCost619cost={cost}620simple621oneLine622noDiscount={noDiscount}623/>624</b>625</div>626</div>627);628},629},630{631responsive: ["sm" as "sm"],632title: "Product",633align: "center" as "center",634render: (_, { product }) => <ProductColumn product={product} />,635},636{637responsive: ["sm" as "sm"],638width: "60%",639render: (_, { cost, description, project_id }) => (640<>641<DescriptionColumn642cost={cost}643description={description}644voucherPeriod={voucherPeriod}645/>{" "}646<ProjectID project_id={project_id} />647</>648),649},650{651responsive: ["sm" as "sm"],652title: "Price",653align: "right" as "right",654render: (_, { cost }) => (655<b style={{ fontSize: "11pt" }}>656<DisplayCost cost={cost} simple noDiscount={noDiscount} />657</b>658),659},660] as any;661}662663function ProjectID({ project_id }: { project_id: string }): JSX.Element | null {664if (!project_id || !isValidUUID(project_id)) return null;665return (666<div>667For project: <code>{project_id}</code>668</div>669);670}671672export function ExplainPaymentSituation({673params,674style,675}: {676params: CheckoutParams | null;677style?;678}) {679if (params == null) {680return <Spin />;681}682const { balance, chargeAmount, total, minBalance } = params;683const curBalance = (684<div style={{ float: "right", marginLeft: "30px", fontWeight: "bold" }}>685Account Balance: {currency(round2down(balance))}686{minBalance ? `, Minimum allowed balance: ${currency(minBalance)}` : ""}687</div>688);689690if (chargeAmount == 0) {691return (692<Alert693showIcon694type="info"695style={style}696description={697<>698{curBalance}699Complete this purchase without adding credit to your account.700</>701}702/>703);704}705return (706<Alert707showIcon708type="info"709style={style}710description={711<>712{curBalance}713Complete this purchase by adding {currency(chargeAmount)} to your714account.{" "}715{chargeAmount > total && (716<>717Your account balance must always be at least{" "}718{currency(params.minBalance)}.719</>720)}721</>722}723/>724);725}726727728