CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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