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