diff --git a/docker-compose.accounts.yml b/docker-compose.accounts.yml index 37700db5..db073d48 100644 --- a/docker-compose.accounts.yml +++ b/docker-compose.accounts.yml @@ -112,6 +112,7 @@ services: - NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL} - NEXT_PUBLIC_KRATOS_BROWSER_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public - NEXT_PUBLIC_KRATOS_PUBLIC_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public + - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} networks: shared: ipv4_address: 10.10.10.85 diff --git a/package.json b/package.json index c146fb8b..80b22adf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,12 @@ { - "private": true, - "workspaces": [ - "packages/*" - ] + "private": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@tailwindcss/forms": "^0.2.1", + "autoprefixer": "^10.2.4", + "postcss": "^8.2.6", + "tailwindcss": "^2.0.3" + } } diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 4f92242d..fa0a7f45 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -27,6 +27,7 @@ "react-dom": "17.0.1", "skynet-js": "^3.0.0", "square": "^8.1.1", + "stripe": "^8.137.0", "superagent": "^6.1.0", "swr": "^0.4.1", "tailwindcss": "^2.0.3", diff --git a/packages/dashboard/src/pages/api/square/subscription.js b/packages/dashboard/src/pages/api/square/subscription.js new file mode 100644 index 00000000..5ece9e68 --- /dev/null +++ b/packages/dashboard/src/pages/api/square/subscription.js @@ -0,0 +1,46 @@ +import { Client, Environment } from "square"; +import { StatusCodes } from "http-status-codes"; + +const client = new Client({ + environment: Environment.Sandbox, + accessToken: process.env.SQUARE_ACCESS_TOKEN, +}); + +const api = { + GET: async (req, res) => { + try { + const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"]; + + // create subscriptions search query + const query = { filter: { customerIds: [user] } }; + + // query subscriptions with given search criteria + const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query }); + const { subscriptions } = subscriptionsResponse; + + // get active subscription + const subscription = subscriptions.find(({ status }) => status === "ACTIVE"); + + if (!subscription) { + return res.status(StatusCodes.NO_CONTENT).end(); // no active subscription found + } + + console.log("....", subscription); + + return res.json(subscription); + } catch (error) { + console.log(error); + console.log(error?.errors); + + return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling + } + }, +}; + +export default (req, res) => { + if (req.method in api) { + return api[req.method](req, res); + } + + return res.status(StatusCodes.NOT_FOUND).end(); +}; diff --git a/packages/dashboard/src/pages/api/square/subscription/cancel.js b/packages/dashboard/src/pages/api/square/subscription/cancel.js new file mode 100644 index 00000000..9b2f017c --- /dev/null +++ b/packages/dashboard/src/pages/api/square/subscription/cancel.js @@ -0,0 +1,55 @@ +import { Client, Environment } from "square"; +import { StatusCodes } from "http-status-codes"; + +const client = new Client({ + environment: Environment.Sandbox, + accessToken: process.env.SQUARE_ACCESS_TOKEN, +}); + +const cancelSubscription = async (id) => { + const { result: subscriptionsResponse } = await client.subscriptionsApi.cancelSubscription(id); + const { subscription } = subscriptionsResponse; + + return subscription; +}; + +const getActiveSubscription = async (customerId) => { + // create subscriptions search query + const query = { filter: { customerIds: [customerId] } }; + + // query subscriptions with given search criteria + const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query }); + const { subscriptions } = subscriptionsResponse; + + // get active subscription with a set cancellation date + return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && !canceledDate); +}; + +const api = { + POST: async (req, res) => { + try { + const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"]; + const subscription = await getActiveSubscription(user); + + if (!subscription) { + return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription found + } + + const canceledSubscription = await cancelSubscription(subscription.id); + + return res.json(canceledSubscription); + } catch (error) { + console.log(error.errors); + + return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling + } + }, +}; + +export default (req, res) => { + if (req.method in api) { + return api[req.method](req, res); + } + + return res.status(StatusCodes.NOT_FOUND).end(); +}; diff --git a/packages/dashboard/src/pages/api/square/subscription/restore.js b/packages/dashboard/src/pages/api/square/subscription/restore.js new file mode 100644 index 00000000..8cac9dfc --- /dev/null +++ b/packages/dashboard/src/pages/api/square/subscription/restore.js @@ -0,0 +1,58 @@ +import { Client, Environment } from "square"; +import { StatusCodes } from "http-status-codes"; + +const client = new Client({ + environment: Environment.Sandbox, + accessToken: process.env.SQUARE_ACCESS_TOKEN, +}); + +const updateSubscription = async (id, body) => { + const { result: subscriptionsResponse } = await client.subscriptionsApi.updateSubscription(id, body); + const { subscription } = subscriptionsResponse; + + return subscription; +}; + +const getActiveCanceledSubscription = async (customerId) => { + // create subscriptions search query + const query = { filter: { customerIds: [customerId] } }; + + // query subscriptions with given search criteria + const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query }); + const { subscriptions } = subscriptionsResponse; + + // get active subscription with a set cancellation date + return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && canceledDate); +}; + +const api = { + POST: async (req, res) => { + try { + const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"]; + const subscription = await getActiveCanceledSubscription(user); + + if (!subscription) { + return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription with cancel date found + } + + // update the subscription setting empty canceledDate + const updatedSubscription = await updateSubscription(subscription.id, { + subscription: { ...subscription, canceledDate: "" }, + }); + + return res.json(updatedSubscription); + } catch (error) { + console.log(error.errors); + + return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling + } + }, +}; + +export default (req, res) => { + if (req.method in api) { + return api[req.method](req, res); + } + + return res.status(StatusCodes.NOT_FOUND).end(); +}; diff --git a/packages/dashboard/src/pages/api/stripe/activeSubscription.js b/packages/dashboard/src/pages/api/stripe/activeSubscription.js new file mode 100644 index 00000000..8f7a4e1e --- /dev/null +++ b/packages/dashboard/src/pages/api/stripe/activeSubscription.js @@ -0,0 +1,16 @@ +import Stripe from "stripe"; +import { StatusCodes } from "http-status-codes"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +export default async (req, res) => { + try { + const stripeCustomerId = "cus_J09ECKPgFEPXoq"; + const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId); + const { subscriptions } = stripeCustomer; + + res.json(subscriptions); + } catch ({ message }) { + res.status(StatusCodes.BAD_REQUEST).json({ error: { message } }); + } +}; diff --git a/packages/dashboard/src/pages/api/stripe/createCheckoutSession.js b/packages/dashboard/src/pages/api/stripe/createCheckoutSession.js new file mode 100644 index 00000000..546b64f0 --- /dev/null +++ b/packages/dashboard/src/pages/api/stripe/createCheckoutSession.js @@ -0,0 +1,41 @@ +import Stripe from "stripe"; +import { StatusCodes } from "http-status-codes"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +export default async (req, res) => { + if (req.method !== "POST") { + return res.status(StatusCodes.NOT_FOUND).end(); + } + + const { price } = req.body; + + if (!price) { + return res.status(StatusCodes.BAD_REQUEST).json({ error: { message: "Missing 'price' attribute" } }); + } + + // Create new Checkout Session for the order + // Other optional params include: + // [billing_address_collection] - to display billing address details on the page + // [customer] - if you have an existing Stripe Customer ID + // [customer_email] - lets you prefill the email input in the form + // For full details see https://stripe.com/docs/api/checkout/sessions/create + try { + const stripeCustomerId = "cus_J09ECKPgFEPXoq"; + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + line_items: [{ price, quantity: 1 }], + customer: stripeCustomerId, + // ?session_id={CHECKOUT_SESSION_ID} means the redirect will have the session ID set as a query param + success_url: `${process.env.SKYNET_DASHBOARD_URL}/payments?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`, + }); + + console.log(session); + + res.json({ sessionId: session.id }); + } catch ({ message }) { + res.status(StatusCodes.BAD_REQUEST).json({ error: { message } }); + } +}; diff --git a/packages/dashboard/src/pages/api/stripe/customerPortal.js b/packages/dashboard/src/pages/api/stripe/customerPortal.js new file mode 100644 index 00000000..836ad173 --- /dev/null +++ b/packages/dashboard/src/pages/api/stripe/customerPortal.js @@ -0,0 +1,18 @@ +import Stripe from "stripe"; +import { StatusCodes } from "http-status-codes"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +export default async (req, res) => { + try { + const stripeCustomerId = "cus_J09ECKPgFEPXoq"; + const session = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`, + }); + + res.redirect(session.url); + } catch ({ message }) { + res.status(StatusCodes.BAD_REQUEST).json({ error: { message } }); + } +}; diff --git a/packages/dashboard/src/pages/payments-square.js b/packages/dashboard/src/pages/payments-square.js new file mode 100644 index 00000000..a75f4cc1 --- /dev/null +++ b/packages/dashboard/src/pages/payments-square.js @@ -0,0 +1,263 @@ +import dayjs from "dayjs"; +import Layout from "../components/Layout"; +import useSWR from "swr"; +import ky from "ky/umd"; +import { CardElement } from "@stripe/react-stripe-js"; + +const plans = [ + { id: "initial_free", name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" }, + { id: "initial_plus", name: "Skynet Plus", price: 5, description: "1 TB premium bandwidth with full speed" }, + { id: "initial_pro", name: "Skynet Pro", price: 20, description: "5 TB premium bandwidth with full speed" }, + { id: "initial_extreme", name: "Skynet Extreme", price: 80, description: "20 TB premium bandwidth with full speed" }, +]; +const currentlyActivePlan = "initial_free"; + +const fetcher = (url) => fetch(url).then((r) => r.json()); + +export default function Payments() { + const { data: invoices } = useSWR("/api/square/invoices", fetcher); + const { data: subscription, mutate: mutateSubscription } = useSWR("/api/square/subscription", fetcher); + const handleSubscriptionCancel = async (e) => { + e.preventDefault(); + + try { + const subscription = await ky.post("/api/square/subscription/cancel").json(); + + mutateSubscription(subscription, false); + } catch (error) { + console.log(error); + } + }; + const handleSubscriptionRestore = async (e) => { + e.preventDefault(); + + try { + const subscription = await ky.post("/api/square/subscription/restore").json(); + + mutateSubscription(subscription, false); + } catch (error) { + console.log(error); + } + }; + + return ( + +
+
+ {/* This example requires Tailwind CSS v2.0+ */} +
+
+
+
Current plan
+
Free
+
+
+
+
+
Current payment
+
+
+
+
+
+
Plan usage this month
+
24.57%
+
+
+
+ + + + {/* Plan */} +
+
+
+
+
+
+

+ Plan +

+
+
+
+ Pricing plans +
    + {plans.map((plan, index) => ( +
  • + {/* On: "bg-orange-50 border-orange-200 z-10", Off: "border-gray-200" */} +
    + +

    + {/* On: "text-orange-900", Off: "text-gray-900" */} + {plan.price ? `$${plan.price} / mo` : "no cost"} + {/* On: "text-orange-700", Off: "text-gray-500" */} + {/* ($290 / yr) */} +

    + {/* On: "text-orange-700", Off: "text-gray-500" */} +

    + {plan.description} +

    +
    +
  • + ))} +
+
+
+ Currently active plan: + {subscription ? subscription.planId : "Free"} + {subscription && ( + + paid until {subscription.paidUntilDate} -{" "} + {subscription.canceledDate ? ( + <> + cancelled on {subscription.canceledDate} -{" "} + + restore subscription + + + ) : ( + <> + + cancel subscription + + + )} + + )} +
+
+
+ +
+
+
+
+ {/* Billing history */} +
+
+
+

+ Billing history +

+
+
+
+
+
+ + + + + + + + {/* + `relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile. + */} + + + + + {invoices && + invoices.map((invoice) => ( + + + + + + + + ))} + +
+ Date + + Invoice + + Description + + Status + + View receipt +
+ {dayjs(invoice.createdAt).format("DD/MM/YYYY")} + + {invoice.invoiceNumber} + {invoice.title}{invoice.status} + + View invoice + +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/packages/dashboard/src/pages/payments.js b/packages/dashboard/src/pages/payments.js index f61e0a8c..94e71048 100644 --- a/packages/dashboard/src/pages/payments.js +++ b/packages/dashboard/src/pages/payments.js @@ -1,19 +1,59 @@ import dayjs from "dayjs"; import Layout from "../components/Layout"; import useSWR from "swr"; +import ky from "ky/umd"; +import { useState } from "react"; const plans = [ - { id: "initial_free", name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" }, - { id: "initial_plus", name: "Skynet Plus", price: 5, description: "1 TB premium bandwidth with full speed" }, - { id: "initial_pro", name: "Skynet Pro", price: 20, description: "5 TB premium bandwidth with full speed" }, - { id: "initial_extreme", name: "Skynet Extreme", price: 80, description: "20 TB premium bandwidth with full speed" }, + { id: "initial_free", stripe: null, name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" }, + { + id: "initial_plus", + stripe: "price_1IO6FpIzjULiPWN6XHIG5mU9", + name: "Skynet Plus", + price: 5, + description: "1 TB premium bandwidth with full speed", + }, + { + id: "initial_pro", + stripe: "price_1IO6FpIzjULiPWN6xYjmUuGb", + name: "Skynet Pro", + price: 20, + description: "5 TB premium bandwidth with full speed", + }, + { + id: "initial_extreme", + stripe: "price_1IO6FpIzjULiPWN636iFN02j", + name: "Skynet Extreme", + price: 80, + description: "20 TB premium bandwidth with full speed", + }, ]; -const currentlyActivePlan = "initial_free"; +const stripeCustomerId = "cus_J09ECKPgFEPXoq"; +const activePlanId = "initial_free"; const fetcher = (url) => fetch(url).then((r) => r.json()); +const ActiveBadge = () => { + return ( + + active + + ); +}; + export default function Payments() { - const { data: invoices, error } = useSWR("/api/square/invoices", fetcher); + const [selectedPlanId, setSelectedPlanId] = useState("initial_free"); + const selectedPlan = plans.find(({ id }) => selectedPlanId === id); + const handleSubscribe = async () => { + try { + const price = selectedPlan.stripe; + const { sessionId } = await ky.post("/api/stripe/createCheckoutSession", { json: { price } }).json(); + const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); + await stripe.redirectToCheckout({ sessionId }); + } catch (error) { + // todo: handle error + } + }; return ( @@ -70,9 +110,11 @@ export default function Payments() { type="radio" className="h-4 w-4 text-orange-500 cursor-pointer focus:ring-gray-900 border-gray-300" aria-describedby="plan-option-pricing-0 plan-option-limit-0" - defaultChecked + checked={plan.id === selectedPlanId} + onChange={() => console.log(plan.id) || setSelectedPlanId(plan.id)} /> {plan.name} + {activePlanId === plan.id && }

{/* On: "text-orange-900", Off: "text-gray-900" */} @@ -89,114 +131,26 @@ export default function Payments() { ))} -

- Currently active plan: - Free - - (next payment $5 on 10/10/2020 -{" "} - - cancel - - ) - -
+
+ To manage your active subscription, payment methods and view your billing history, go to{" "} + + Stripe Customer Portal + +
- {/* Billing history */} -
-
-
-

- Billing history -

-
-
-
-
-
- - - - - - - - {/* - `relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile. - */} - - - - - {invoices && - invoices.map((invoice) => ( - - - - - - - - ))} - -
- Date - - Invoice - - Description - - Status - - View receipt -
- {dayjs(invoice.createdAt).format("DD/MM/YYYY")} - - {invoice.invoiceNumber} - {invoice.title}{invoice.status} - - View invoice - -
-
-
-
-
-
-
diff --git a/packages/dashboard/tailwind.config.js b/packages/dashboard/tailwind.config.js index a787a3e0..09477280 100644 --- a/packages/dashboard/tailwind.config.js +++ b/packages/dashboard/tailwind.config.js @@ -1,5 +1,3 @@ -const colors = require("tailwindcss/colors"); - module.exports = { purge: ["./src/**/*.js"], darkMode: false, // or 'media' or 'class' @@ -8,15 +6,13 @@ module.exports = { fontFamily: { sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"], }, - colors: { - orange: colors.orange, - }, }, }, variants: { - extend: {}, + extend: { + backgroundColor: ["disabled"], + textColor: ["disabled"], + }, }, - plugins: [ - // require("@tailwindcss/forms") - ], + plugins: [require("@tailwindcss/forms")], };