diff --git a/package.json b/package.json index cd65ee9..4fa8122 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "nodemailer": "^6.4.8", "pluris": "^0.2.5", "signale": "^1.4.0", + "stripe": "^8.120.0", "uuid": "^8.0.0", "yaml": "^1.9.2" } diff --git a/src/api/loc.sh/main.ts b/src/api/loc.sh/main.ts index 4c0ca14..d1ad16a 100644 --- a/src/api/loc.sh/main.ts +++ b/src/api/loc.sh/main.ts @@ -1,6 +1,6 @@ import { Server, ServerManagement } from '../../class'; export default (management: ServerManagement) => { - const server = new Server(management, 3890, `${__dirname}/routes`); + const server = new Server(management, 3890, `${__dirname}/routes`, false); return server; }; diff --git a/src/api/loc.sh/routes/internal.ts b/src/api/loc.sh/routes/internal.ts index e143399..bf94bc4 100644 --- a/src/api/loc.sh/routes/internal.ts +++ b/src/api/loc.sh/routes/internal.ts @@ -1,7 +1,6 @@ -import { TextChannel } from 'eris'; -/* eslint-disable no-shadow */ -import jwt from 'jsonwebtoken'; -import { RichEmbed, Route, Server, LocalStorage } from '../../../class'; +import axios from 'axios'; +import bodyParser from 'body-parser'; +import { Route, Server, LocalStorage } from '../../../class'; // import acknowledgements from '../../../configs/acknowledgements.json'; export default class Internal extends Route { @@ -79,5 +78,42 @@ export default class Internal extends Route { return this.handleError(err, res); } }); + + this.router.post('/sub', bodyParser.raw({ type: 'application/json' }), async (req, res) => { + try { + const event = this.server.client.stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], this.server.client.config.stripeSubSigningSecret); + const data = event.data.object; + + switch (event.type) { + default: + return res.sendStatus(400); + case 'customer.subscription.created': + if (data.items.data[0].price.product === 'prod_Hi4EYmf2am5VZt') { + const customer = await this.server.client.db.Customer.findOne({ cusID: data.customer }).lean().exec(); + if (!customer) return res.sendStatus(404); + await axios({ + method: 'get', + url: `https://api.libraryofcode.org/wh/t2?userID=${customer.userID}&auth=${this.server.client.config.internalKey}`, + }); + res.sendStatus(200); + } + break; + case 'customer.subscription.deleted': + if (data.items.data[0].price.product === 'prod_Hi4EYmf2am5VZt') { + const customer = await this.server.client.db.Customer.findOne({ cusID: data.customer }).lean().exec(); + if (!customer) return res.sendStatus(404); + await axios({ + method: 'get', + url: `https://api.libraryofcode.org/wh/t2-rm?userID=${customer.userID}&auth=${this.server.client.config.internalKey}`, + }); + return res.sendStatus(200); + } + break; + } + return null; + } catch (err) { + return this.handleError(err, res); + } + }); } } diff --git a/src/api/loc.sh/routes/root.ts b/src/api/loc.sh/routes/root.ts index 110ba2e..0bce845 100644 --- a/src/api/loc.sh/routes/root.ts +++ b/src/api/loc.sh/routes/root.ts @@ -12,6 +12,43 @@ export default class Root extends Route { public bind() { this.router.get('/', (_req, res) => res.redirect('https://www.libraryofcode.org/')); + this.router.get('/dash', async (req, res) => { + try { + const lookup = await this.server.client.db.CustomerPortal.findOne({ key: req.query.q?.toString() }); + if (!lookup) return res.status(404).json({ code: this.constants.codes.NOT_FOUND, message: this.constants.messages.NOT_FOUND }); + if (new Date(lookup.expiresOn) < new Date()) return res.status(401).json({ code: this.constants.codes.UNAUTHORIZED, message: this.constants.messages.UNAUTHORIZED }); + + const customer = await this.server.client.db.Customer.findOne({ userID: lookup.userID }); + if (!customer) { + const newCus = await this.server.client.stripe.customers.create({ + email: lookup.emailAddress, + metadata: { + userID: lookup.userID, + username: lookup.username, + }, + }); + await (new this.server.client.db.Customer({ + cusID: newCus.id, + userID: lookup.userID, + })).save(); + const billingURL = await this.server.client.stripe.billingPortal.sessions.create({ + customer: newCus.id, + return_url: 'https://www.libraryofcode.org', + }); + res.redirect(302, billingURL.url); + return await lookup.updateOne({ $set: { used: true } }); + } + const billingURL = await this.server.client.stripe.billingPortal.sessions.create({ + customer: customer.cusID, + return_url: 'https://www.libraryofcode.org', + }); + res.redirect(302, billingURL.url); + return await lookup.updateOne({ $set: { used: true } }); + } catch (err) { + return this.handleError(err, res); + } + }); + this.router.get('/:key', async (req, res) => { try { const link: RedirectRaw = await this.server.client.db.Redirect.findOne({ key: req.params.key }).lean().exec(); diff --git a/src/class/Client.ts b/src/class/Client.ts index 075d6dd..b268489 100644 --- a/src/class/Client.ts +++ b/src/class/Client.ts @@ -1,9 +1,10 @@ +import Stripe from 'stripe'; import eris from 'eris'; import pluris from 'pluris'; import mongoose from 'mongoose'; import { promises as fs } from 'fs'; import { Collection, Command, LocalStorage, Queue, Util, ServerManagement, Event } from '.'; -import { File, FileInterface, Member, MemberInterface, Merchant, MerchantInterface, Moderation, ModerationInterface, NNTrainingData, NNTrainingDataInterface, Note, NoteInterface, PagerNumber, PagerNumberInterface, Rank, RankInterface, Redirect, RedirectInterface, Score, ScoreInterface, ScoreHistorical, ScoreHistoricalInterface, Staff, StaffInterface, Stat, StatInterface } from '../models'; +import { Customer, CustomerInterface, CustomerPortal, CustomerPortalInterface, File, FileInterface, Member, MemberInterface, Merchant, MerchantInterface, Moderation, ModerationInterface, NNTrainingData, NNTrainingDataInterface, Note, NoteInterface, PagerNumber, PagerNumberInterface, Rank, RankInterface, Redirect, RedirectInterface, Score, ScoreInterface, ScoreHistorical, ScoreHistoricalInterface, Staff, StaffInterface, Stat, StatInterface } from '../models'; import { Config } from '../../types'; // eslint-disable-line pluris(eris); @@ -23,7 +24,9 @@ export default class Client extends eris.Client { public queue: Queue; - public db: { File: mongoose.Model, Member: mongoose.Model, Merchant: mongoose.Model, Moderation: mongoose.Model, NNTrainingData: mongoose.Model, Note: mongoose.Model, PagerNumber: mongoose.Model, Rank: mongoose.Model, Redirect: mongoose.Model, Score: mongoose.Model, ScoreHistorical: mongoose.Model, Staff: mongoose.Model, Stat: mongoose.Model, local: { muted: LocalStorage } }; + public stripe: Stripe; + + public db: { Customer: mongoose.Model, CustomerPortal: mongoose.Model, File: mongoose.Model, Member: mongoose.Model, Merchant: mongoose.Model, Moderation: mongoose.Model, NNTrainingData: mongoose.Model, Note: mongoose.Model, PagerNumber: mongoose.Model, Rank: mongoose.Model, Redirect: mongoose.Model, Score: mongoose.Model, ScoreHistorical: mongoose.Model, Staff: mongoose.Model, Stat: mongoose.Model, local: { muted: LocalStorage } }; constructor(token: string, options?: eris.ClientOptions) { super(token, options); @@ -31,7 +34,7 @@ export default class Client extends eris.Client { this.events = new Collection(); this.intervals = new Collection(); this.queue = new Queue(this); - this.db = { File, Member, Merchant, Moderation, NNTrainingData, Note, PagerNumber, Rank, Redirect, Score, ScoreHistorical, Staff, Stat, local: { muted: new LocalStorage('muted') } }; + this.db = { Customer, CustomerPortal, File, Member, Merchant, Moderation, NNTrainingData, Note, PagerNumber, Rank, Redirect, Score, ScoreHistorical, Staff, Stat, local: { muted: new LocalStorage('muted') } }; } @@ -60,6 +63,7 @@ export default class Client extends eris.Client { public loadPlugins() { this.util = new Util(this); this.serverManagement = new ServerManagement(this); + this.stripe = new Stripe(this.config.stripeKey, { apiVersion: null, typescript: true }); } public async loadIntervals() { diff --git a/src/class/Server.ts b/src/class/Server.ts index ab4f576..f47d9f4 100644 --- a/src/class/Server.ts +++ b/src/class/Server.ts @@ -15,13 +15,17 @@ export default class Server { private root: string; - constructor(parent: ServerManagement, port: number, routeRoot: string) { + protected parse: boolean; + + constructor(parent: ServerManagement, port: number, routeRoot: string, parse = true) { this.parent = parent; this.app = express(); this.routes = new Collection(); this.port = port; this.root = routeRoot; + this.parse = parse; + this.init(); this.loadRoutes(); } @@ -50,7 +54,9 @@ export default class Server { } public init() { - this.app.use(bodyParser.json()); + if (this.parse) { + this.app.use(bodyParser.json()); + } this.app.set('trust proxy', 'loopback'); this.app.use(helmet({ hsts: false, diff --git a/src/commands/billing.ts b/src/commands/billing.ts new file mode 100644 index 0000000..0c5b4ec --- /dev/null +++ b/src/commands/billing.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; +import moment from 'moment'; +import { Message } from 'eris'; +import { randomBytes } from 'crypto'; +import { v4 as uuid } from 'uuid'; +import { Client, Command } from '../class'; +import Billing_T2 from './billing_t2'; + +export default class Billing extends Command { + constructor(client: Client) { + super(client); + this.name = 'billing'; + this.description = 'Pulls up your Billing Portal. You must have a CS Account to continue.'; + this.usage = `${this.client.config.prefix}billing`; + this.subcmds = [Billing_T2]; + this.permissions = 0; + this.guildOnly = false; + this.enabled = true; + } + + public async run(message: Message) { + try { + const response = <{ + found: boolean, + emailAddress?: string, + tier?: number, + supportKey?: string, + }> (await axios.get(`https://api.cloud.libraryofcode.org/wh/info?id=${message.author.id}&authorization=${this.client.config.internalKey}`)).data; + if (!response.found) return this.error(message.channel, 'CS Account not found.'); + + const portalKey = randomBytes(50).toString('hex'); + const uid = uuid(); + const redirect = new this.client.db.Redirect({ + key: uid, + to: `https://loc.sh/dash?q=${portalKey}`, + }); + const portal = new this.client.db.CustomerPortal({ + key: portalKey, + username: message.author.username, + userID: message.author.id, + emailAddress: response.emailAddress, + expiresOn: moment().add(5, 'minutes').toDate(), + used: false, + }); + await portal.save(); + await redirect.save(); + + const chan = await this.client.getDMChannel(message.author.id); + await chan.createMessage(`__***Billing Account Portal***__\nClick here: https://loc.sh/${uid}\n\nYou will be redirected to your billing portal, please note the link expires after 5 minutes.`); + return await this.success(message.channel, 'Your Billing Portal information has been DMed to you.'); + } catch (err) { + return this.client.util.handleError(err, message, this); + } + } +} diff --git a/src/commands/billing_t2.ts b/src/commands/billing_t2.ts new file mode 100644 index 0000000..392c13e --- /dev/null +++ b/src/commands/billing_t2.ts @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { Message } from 'eris'; +import { Client, Command } from '../class'; + +export default class Billing_T2 extends Command { + constructor(client: Client) { + super(client); + this.name = 't2'; + this.description = 'Subscription to CS Tier 2.'; + this.usage = `${this.client.config.prefix}billing t2`; + this.permissions = 0; + this.guildOnly = false; + this.enabled = true; + } + + public async run(message: Message) { + try { + const response = <{ + found: boolean, + emailAddress?: string, + tier?: number, + supportKey?: string, + }> (await axios.get(`https://api.cloud.libraryofcode.org/wh/info?id=${message.author.id}&authorization=${this.client.config.internalKey}`)).data; + if (!response.found) return this.error(message.channel, 'CS Account not found.'); + + const customer = await this.client.db.Customer.findOne({ userID: message.author.id }); + if (!customer) return this.error(message.channel, `You do not have a Customer Account. Please run \`${this.client.config.prefix}billing\`, once you visit the Billing Portal via the URL given to you, please try again.`); + + const subscription = await this.client.stripe.subscriptions.create({ + customer: customer.cusID, + payment_behavior: 'allow_incomplete', + items: [{ price: 'price_1H8e6ODatwI1hQ4WFVvX6Nda' }], + days_until_due: 1, + collection_method: 'send_invoice', + }); + + await this.client.stripe.invoices.finalizeInvoice(subscription.latest_invoice.toString()); + const invoice = await this.client.stripe.invoices.retrieve(subscription.latest_invoice.toString()); + + const chan = await this.client.getDMChannel(message.author.id); + await chan.createMessage(`__**Invoice for New Subscription**__\n${invoice.hosted_invoice_url}\n\n*Please click on the link above to pay for your subscription.*`); + return this.success(message.channel, 'Transaction processed.'); + } catch (err) { + return this.client.util.handleError(err, message, this); + } + } +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 06013fc..79f91b3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,6 +5,7 @@ export { default as addrank } from './addrank'; export { default as addredirect } from './addredirect'; export { default as apply } from './apply'; export { default as ban } from './ban'; +export { default as billing } from './billing'; export { default as delitem } from './delitem'; export { default as delmerchant } from './delmerchant'; export { default as delnote } from './delnote'; diff --git a/src/models/Customer.ts b/src/models/Customer.ts new file mode 100644 index 0000000..b7525b2 --- /dev/null +++ b/src/models/Customer.ts @@ -0,0 +1,13 @@ +import { Document, Schema, model } from 'mongoose'; + +export interface CustomerInterface extends Document { + cusID: string, + userID: string, +} + +const Customer: Schema = new Schema({ + cusID: String, + userID: String, +}); + +export default model('Customer', Customer); diff --git a/src/models/CustomerPortal.ts b/src/models/CustomerPortal.ts new file mode 100644 index 0000000..ca96881 --- /dev/null +++ b/src/models/CustomerPortal.ts @@ -0,0 +1,21 @@ +import { Document, Schema, model } from 'mongoose'; + +export interface CustomerPortalInterface extends Document { + key: string, + username: string, + userID: string, + emailAddress: string, + expiresOn: Date, + used?: boolean, +} + +const CustomerPortal: Schema = new Schema({ + key: String, + username: String, + userID: String, + emailAddress: String, + expiresOn: Date, + used: Boolean, +}); + +export default model('CustomerPortal', CustomerPortal); diff --git a/src/models/index.ts b/src/models/index.ts index e9dc0be..a560036 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,3 +1,5 @@ +export { default as Customer, CustomerInterface } from './Customer'; +export { default as CustomerPortal, CustomerPortalInterface } from './CustomerPortal'; export { default as File, FileInterface } from './File'; export { default as Member, MemberInterface } from './Member'; export { default as Merchant, MerchantInterface } from './Merchant'; diff --git a/types/index.d.ts b/types/index.d.ts index b9d27a7..46016f7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -7,4 +7,6 @@ export declare interface Config { webhookID: string; webhookToken: string; internalKey: string; + stripeKey: string; + stripeSubSigningSecret: string; } diff --git a/yarn.lock b/yarn.lock index 7dfbe9f..a31d34f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -155,6 +155,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.1.tgz#5d93e0a099cd0acd5ef3d5bde3c086e1f49ff68c" integrity sha512-FAYBGwC+W6F9+huFIDtn43cpy7+SzG+atzRiTfdp3inUKL2hXnd4rG8hylJLIh4+hqrQy1P17kvJByE/z825hA== +"@types/node@>=8.1.0": + version "14.14.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.6.tgz#146d3da57b3c636cc0d1769396ce1cfa8991147f" + integrity sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw== + "@types/nodemailer@^6.4.0": version "6.4.0" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.0.tgz#d8c039be3ed685c4719a026455555be82c124b74" @@ -2637,6 +2642,11 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.6.0: + version "6.9.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" + integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -3183,6 +3193,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +stripe@^8.120.0: + version "8.120.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.120.0.tgz#234a2fd7352c529532d32e76c75094068690a373" + integrity sha512-OzqyUWwdYPla1Onjn94pdGwqpVsOAOlNwo75Yr3T3n1eN17CMUclAVU9hIAqBrDVeyHOyriYklLRhT3NGr7BNw== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.6.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"