From a4fbe7b2b4dcee5d6ef97058845dcbc38c232582 Mon Sep 17 00:00:00 2001 From: Alex Johansson Date: Tue, 28 Sep 2021 14:00:19 +0100 Subject: [PATCH] downgrade users when trial ends (#767) * wip * wip * wip * wtf * should be all the logic * comment * fix receiver name * safeguard a bit more * downgrade users cron job Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .vscode/extensions.json | 3 +- .../api/integrations/stripepayment/webhook.ts | 67 +++++++++++++------ package.json | 1 + pages/_error.tsx | 2 +- pages/api/cron/downgradeUsers.ts | 26 +++++++ yarn.lock | 55 ++++++++++++++- 6 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 pages/api/cron/downgradeUsers.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 0f47d397c1..8088ca4470 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "esbenp.prettier-vscode", // prettier plugin "dbaeumer.vscode-eslint", // eslint plugin "bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind - "heybourn.headwind" // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind" + "heybourn.headwind", // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind", + "stripe.vscode-stripe" // stripe VSCode extension ] } diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index 922070e9ad..8940c12263 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -6,11 +6,10 @@ import Stripe from "stripe"; import stripe from "@ee/lib/stripe/server"; import { CalendarEvent } from "@lib/calendarClient"; +import { HttpError } from "@lib/core/http/error"; import EventManager from "@lib/events/EventManager"; import prisma from "@lib/prisma"; -const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - export const config = { api: { bodyParser: false, @@ -98,29 +97,55 @@ async function handlePaymentSuccess(event: Stripe.Event) { } } +type WebhookHandler = (event: Stripe.Event) => Promise; + +const webhookHandlers: Record = { + "payment_intent.succeeded": handlePaymentSuccess, + "customer.subscription.deleted": async (event) => { + const data = event.data as Stripe.Subscription; + + const customerId = typeof data.customer === "string" ? data.customer : data.customer.id; + + const customer = (await stripe.customers.retrieve(customerId)) as Stripe.Customer; + if (typeof customer.email !== "string") { + throw new Error(`Couldn't find customer email for ${data.customer}`); + } + + await prisma.user.update({ + where: { + email: customer.email, + }, + data: { + plan: "FREE", + }, + }); + }, +}; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const requestBuffer = await buffer(req); - const sig = req.headers["stripe-signature"]; - let event; - - if (!sig) { - res.status(400).send(`Webhook Error: missing Stripe signature`); - return; - } - - if (!webhookSecret) { - res.status(400).send(`Webhook Error: missing Stripe webhookSecret`); - return; - } - try { - event = stripe.webhooks.constructEvent(requestBuffer.toString(), sig, webhookSecret); + if (req.method !== "POST") { + throw new HttpError({ statusCode: 405, message: "Method Not Allowed" }); + } + const sig = req.headers["stripe-signature"]; + if (!sig) { + throw new HttpError({ statusCode: 400, message: "Missing stripe-signature" }); + } - // Handle the event - if (event.type === "payment_intent.succeeded") { - await handlePaymentSuccess(event); + if (!process.env.STRIPE_WEBHOOK_SECRET) { + throw new HttpError({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" }); + } + const requestBuffer = await buffer(req); + const payload = requestBuffer.toString(); + // console.log("payload", payload); + + const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET); + + const handler = webhookHandlers[event.type]; + if (handler) { + await handler(event); } else { - console.error(`Unhandled event type ${event.type}`); + console.warn(`Unhandled Stripe Webhook event type ${event.type}`); } } catch (_err) { const err = getErrorFromUnknown(_err); diff --git a/package.json b/package.json index 87cbcf4c77..8014dc9bd1 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@types/jest": "^27.0.1", "@types/lodash.debounce": "^4.0.6", "@types/lodash.merge": "^4.6.6", + "@types/micro": "^7.3.6", "@types/node": "^16.6.1", "@types/nodemailer": "^6.4.4", "@types/qrcode": "^1.4.1", diff --git a/pages/_error.tsx b/pages/_error.tsx index 99403156c3..b33da821e4 100644 --- a/pages/_error.tsx +++ b/pages/_error.tsx @@ -25,7 +25,7 @@ type AugmentedNextPageContext = Omit & { const log = logger.getChildLogger({ prefix: ["[error]"] }); -export function getErrorFromUnknown(cause: unknown): Error { +export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number } { if (cause instanceof Error) { return cause; } diff --git a/pages/api/cron/downgradeUsers.ts b/pages/api/cron/downgradeUsers.ts new file mode 100644 index 0000000000..12b6ea9156 --- /dev/null +++ b/pages/api/cron/downgradeUsers.ts @@ -0,0 +1,26 @@ +import dayjs from "dayjs"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@lib/prisma"; + +const TRIAL_LIMIT_DAYS = 14; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey; + if (process.env.CRON_API_KEY !== apiKey) { + return res.status(401).json({ message: "Not authenticated" }); + } + + await prisma.user.updateMany({ + data: { + plan: "FREE", + }, + where: { + plan: "TRIAL", + createdDate: { + lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(), + }, + }, + }); + res.json({ ok: true }); +} diff --git a/yarn.lock b/yarn.lock index 13586d65c8..9956fbd168 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1371,6 +1371,18 @@ version "2.4.2" resolved "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz" +"@types/component-emitter@^1.2.10": + version "1.2.10" + resolved "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea" + integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg== + +"@types/engine.io@*": + version "3.1.7" + resolved "https://registry.npmjs.org/@types/engine.io/-/engine.io-3.1.7.tgz#86e541a5dc52fb7e97735383564a6ae4cfe2e8f5" + integrity sha512-qNjVXcrp+1sS8YpRUa714r0pgzOwESdW5UjHL7D/2ZFdBX0BXUXtg1LUrp+ylvqbvMcMWUy73YpRoxPN2VoKAQ== + dependencies: + "@types/node" "*" + "@types/graceful-fs@^4.1.2": version "4.1.5" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz" @@ -1437,6 +1449,14 @@ version "4.14.168" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz" +"@types/micro@^7.3.6": + version "7.3.6" + resolved "https://registry.npmjs.org/@types/micro/-/micro-7.3.6.tgz#7d68eb5a780ac4761e3b80687b4ee7328ebc3f2e" + integrity sha512-rZHvZ3+Ev3cGJJSy/wtSiXZmafU8guI07PHXf4ku9sQLfDuFALHMCiV+LuH4VOaeMMMnRs8nqxU392gxfn661g== + dependencies: + "@types/node" "*" + "@types/socket.io" "2.1.13" + "@types/node@*", "@types/node@>=8.1.0", "@types/node@^16.6.1": version "16.9.6" resolved "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz" @@ -1517,6 +1537,22 @@ version "2.3.3" resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz" +"@types/socket.io-parser@*": + version "3.0.0" + resolved "https://registry.npmjs.org/@types/socket.io-parser/-/socket.io-parser-3.0.0.tgz#9726d3ab9235757a0a30dd5ccf8975dce54e5e2c" + integrity sha512-Ry/rbTE6HQNL9eu3LpL1Ocup5VexXu1bSSGlSho/IR5LuRc8YvxwSNJ3JxqTltVJEATLbZkMQETSbxfKNgp4Ew== + dependencies: + socket.io-parser "*" + +"@types/socket.io@2.1.13": + version "2.1.13" + resolved "https://registry.npmjs.org/@types/socket.io/-/socket.io-2.1.13.tgz#b6d694234e99956c96ff99e197eda824b6f9dc48" + integrity sha512-JRgH3nCgsWel4OPANkhH8TelpXvacAJ9VeryjuqCDiaVDMpLysd6sbt0dr6Z15pqH3p2YpOT3T1C5vQ+O/7uyg== + dependencies: + "@types/engine.io" "*" + "@types/node" "*" + "@types/socket.io-parser" "*" + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz" @@ -2467,6 +2503,11 @@ commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" +component-emitter@~1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" @@ -2707,7 +2748,7 @@ debug@2: dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@~4.3.1: version "4.3.2" resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz" dependencies: @@ -5014,7 +5055,8 @@ merge2@^1.3.0: micro@^9.3.4: version "9.3.4" - resolved "https://registry.npmjs.org/micro/-/micro-9.3.4.tgz" + resolved "https://registry.npmjs.org/micro/-/micro-9.3.4.tgz#745a494e53c8916f64fb6a729f8cbf2a506b35ad" + integrity sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w== dependencies: arg "4.1.0" content-type "1.0.4" @@ -6392,6 +6434,15 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +socket.io-parser@*: + version "4.0.4" + resolved "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0" + integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g== + dependencies: + "@types/component-emitter" "^1.2.10" + component-emitter "~1.3.0" + debug "~4.3.1" + source-map-js@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz"