629 lines
16 KiB
TypeScript
629 lines
16 KiB
TypeScript
import { Request, Response } from "express";
|
|
import logger, { userLogger } from "../logger/logger";
|
|
import Stripe from "stripe";
|
|
import {
|
|
DASHBOARD_URL,
|
|
PAYMENT_PLAN,
|
|
PAYMENT_PLAN_SETTINGS,
|
|
} from "../utils/constants";
|
|
import UserPendingPayment from "../models/userPendingPayment";
|
|
import User from "../models/user";
|
|
import { getUserSession, stripe } from "../utils/utils";
|
|
import Store from "../models/store";
|
|
import Session from "../models/session";
|
|
|
|
const ZeitAdlerBasicMonthly = "za-basic-monthly";
|
|
const ZeitAdlerBasicYearly = "za-basic-yearly";
|
|
const lookupKeys = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
|
|
|
|
let cachedPrices = [] as any[];
|
|
|
|
export async function loadPrices() {
|
|
if (cachedPrices.length > 0) {
|
|
return;
|
|
}
|
|
|
|
logger.info("Loading prices from Stripe API");
|
|
|
|
// load prices from stripe
|
|
const prices = await stripe.prices.list({
|
|
lookup_keys: lookupKeys,
|
|
expand: ["data.product"],
|
|
});
|
|
|
|
logger.debug("Prices from Stripe API", JSON.stringify(prices, null, 2));
|
|
|
|
for (const price of prices.data) {
|
|
cachedPrices.push({
|
|
id: price.id,
|
|
unit_amount: (price.unit_amount as number) / 100,
|
|
lookup_key: price.lookup_key,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function getUserPlanInfo(req: Request, res: Response) {
|
|
try {
|
|
const userSession = await getUserSession(req);
|
|
|
|
if (userSession === null) {
|
|
return res.status(401).send({ err: "unauthorized" });
|
|
}
|
|
|
|
await loadPrices();
|
|
|
|
const user = await User.findOne({
|
|
where: {
|
|
user_id: userSession.user_id,
|
|
},
|
|
attributes: [
|
|
"payment_plan",
|
|
"payment_plan_interval",
|
|
"payment_plan_trial_end",
|
|
"payment_plan_canceled_at",
|
|
],
|
|
});
|
|
|
|
if (user === null) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
let resData = {
|
|
payment_plan: user.payment_plan,
|
|
payment_plan_interval: user.payment_plan_interval,
|
|
prices: cachedPrices
|
|
.filter(
|
|
(price) =>
|
|
price.lookup_key === lookupKeys[0] ||
|
|
price.lookup_key === lookupKeys[1]
|
|
)
|
|
.map((price) => {
|
|
return {
|
|
lookup_key: price.lookup_key,
|
|
unit_amount: price.unit_amount,
|
|
};
|
|
}),
|
|
} as any;
|
|
|
|
if (user.payment_plan_trial_end !== null) {
|
|
resData["payment_plan_trial_end"] = user.payment_plan_trial_end;
|
|
}
|
|
|
|
if (user.payment_plan_canceled_at !== null) {
|
|
resData["payment_plan_canceled_at"] = user.payment_plan_canceled_at;
|
|
}
|
|
|
|
userLogger.info(userSession.user_id, "GetUserPlanInfo");
|
|
|
|
res.status(200).send(resData);
|
|
} catch (error) {
|
|
logger.error("getUserPlanInfo", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
export async function getPriceId(paymentPlan: number, interval: number) {
|
|
await loadPrices();
|
|
|
|
const val = `zeitadler-${PAYMENT_PLAN_SETTINGS[paymentPlan].id}-${
|
|
interval === 1 ? "yearly" : "monthly"
|
|
}`;
|
|
|
|
const found = cachedPrices.find((price) => price.lookup_key === val);
|
|
|
|
if (found === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return found.id;
|
|
}
|
|
|
|
// used to get the prices for the frontend
|
|
export async function GetPrices(req: Request, res: Response) {
|
|
try {
|
|
const { productId } = req.params;
|
|
|
|
if (productId === undefined || (productId !== "0" && productId !== "1")) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
await loadPrices();
|
|
|
|
let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
|
|
|
|
res.status(200).send({
|
|
prices: cachedPrices
|
|
.filter(
|
|
(price) =>
|
|
price.lookup_key === lookupKey[0] ||
|
|
price.lookup_key === lookupKey[1]
|
|
)
|
|
.map((price) => {
|
|
return {
|
|
lookup_key: price.lookup_key,
|
|
unit_amount: price.unit_amount,
|
|
};
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
logger.error("GetPrices", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
export async function CreateCheckoutSession(req: Request, res: Response) {
|
|
try {
|
|
const { lookupKey } = req.body;
|
|
|
|
if (lookupKey === undefined) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
await loadPrices();
|
|
|
|
// check if lookupKey is valid
|
|
|
|
const priceId = cachedPrices.find(
|
|
(price) => price.lookup_key === lookupKey
|
|
)?.id;
|
|
|
|
if (priceId === undefined) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
const userSession = await getUserSession(req);
|
|
|
|
if (userSession === null) {
|
|
return res.status(401).send({ err: "unauthorized" });
|
|
}
|
|
|
|
const user = await User.findOne({
|
|
where: {
|
|
user_id: userSession.user_id,
|
|
},
|
|
attributes: ["user_id", "payment_plan_trial_end", "stripe_customer_id"],
|
|
});
|
|
|
|
if (user === null) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
if (user.stripe_customer_id !== null && user.stripe_customer_id !== "") {
|
|
const subscriptions = await stripe.subscriptions.list({
|
|
customer: user.stripe_customer_id,
|
|
limit: 1,
|
|
});
|
|
|
|
if (
|
|
subscriptions.data.length > 0 &&
|
|
subscriptions.data[0].id !== priceId
|
|
) {
|
|
const latestSubscription = subscriptions.data[0];
|
|
|
|
await stripe.subscriptions.update(latestSubscription.id, {
|
|
items: [
|
|
{
|
|
id: latestSubscription.items.data[0].id,
|
|
price: priceId,
|
|
},
|
|
],
|
|
});
|
|
|
|
// update user
|
|
|
|
await User.update(
|
|
{
|
|
payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0,
|
|
},
|
|
{
|
|
where: {
|
|
user_id: userSession.user_id,
|
|
},
|
|
}
|
|
);
|
|
|
|
userLogger.info(
|
|
userSession.user_id,
|
|
`CreateCheckoutSession: update subscription ${latestSubscription.id} to ${priceId}`
|
|
);
|
|
|
|
return res.status(200).json({ status: "ok" });
|
|
}
|
|
}
|
|
|
|
// create checkout session
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
payment_method_types: ["card"],
|
|
line_items: [
|
|
{
|
|
price: priceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
billing_address_collection: "required",
|
|
mode: "subscription",
|
|
subscription_data: {
|
|
trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000,
|
|
},
|
|
success_url: `${DASHBOARD_URL}/payment-plan`,
|
|
cancel_url: `${DASHBOARD_URL}/payment-plan`,
|
|
metadata: {
|
|
userId: userSession.user_id,
|
|
payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0,
|
|
} /*
|
|
automatic_tax: {
|
|
// see https://dashboard.stripe.com/settings/tax
|
|
enabled: true,
|
|
},*/,
|
|
});
|
|
/*
|
|
await UserPendingPayment.create({
|
|
payment_session_id: session.id,
|
|
user_id: userSession.user_id,
|
|
payment_session_url: session.url as string,
|
|
}); */
|
|
|
|
userLogger.info(
|
|
userSession.user_id,
|
|
`CreateCheckoutSession: ${session.id}`
|
|
);
|
|
|
|
res.status(200).send({
|
|
url: session.url,
|
|
});
|
|
} catch (error) {
|
|
logger.error("CreateCheckoutSession", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
// user is redirected to dashboard checkout page after successful payment
|
|
// and then makes request to this endpoint to create a user session
|
|
export async function CheckoutSuccess(req: Request, res: Response) {
|
|
try {
|
|
const { sessionId } = req.body;
|
|
|
|
logger.info("CheckoutSuccess", sessionId);
|
|
|
|
if (sessionId === undefined) {
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
const userPendingPayment = await UserPendingPayment.findOne({
|
|
where: {
|
|
payment_session_id: sessionId,
|
|
},
|
|
});
|
|
|
|
if (userPendingPayment === null) {
|
|
logger.info("CheckoutSuccess payment successful");
|
|
return res.status(200).send({ status: 1 });
|
|
}
|
|
|
|
logger.info("CheckoutSuccess pending payment");
|
|
|
|
res.status(200).send({ status: 0 });
|
|
} catch (err) {
|
|
logger.error("CheckoutSuccess", err as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
export async function CheckoutCanceled(req: Request, res: Response) {
|
|
try {
|
|
const { sessionId } = req.body;
|
|
|
|
if (sessionId === undefined) {
|
|
logger.error("CheckoutCanceled: invalid request");
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
const userPendingPayment = await UserPendingPayment.findOne({
|
|
where: {
|
|
payment_session_id: sessionId,
|
|
},
|
|
});
|
|
|
|
if (userPendingPayment === null) {
|
|
logger.error("CheckoutCanceled: user pending payment not found");
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
// delete user
|
|
|
|
await User.destroy({
|
|
where: {
|
|
user_id: userPendingPayment.user_id,
|
|
},
|
|
});
|
|
|
|
// delete store
|
|
|
|
await Store.destroy({
|
|
where: {
|
|
owner_user_id: userPendingPayment.user_id,
|
|
},
|
|
});
|
|
|
|
// delete all user sessions
|
|
|
|
await Session.destroy({
|
|
where: {
|
|
user_id: userPendingPayment.user_id,
|
|
},
|
|
});
|
|
|
|
// delete user pending payment
|
|
|
|
await userPendingPayment.destroy();
|
|
|
|
logger.info(`CheckoutCanceled: ${sessionId}`);
|
|
|
|
res.status(200).send({ status: 1 });
|
|
} catch (error) {
|
|
logger.error("CheckoutCanceled", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
export async function StripeWebhook(req: Request, res: Response) {
|
|
try {
|
|
if (req.method !== "POST") {
|
|
logger.error("StripeWebhook: invalid reques. Method", req.method);
|
|
return res.status(405).end(); // Method Not Allowed
|
|
}
|
|
|
|
const payload = req.body;
|
|
const sig = req.headers["stripe-signature"] as string;
|
|
|
|
let event: Stripe.Event;
|
|
|
|
try {
|
|
event = stripe.webhooks.constructEvent(
|
|
payload,
|
|
sig,
|
|
process.env.STRIPE_WEBHOOK_ENDPOINT_SECRET as string
|
|
);
|
|
} catch (error) {
|
|
logger.error("Webhook error", error as string);
|
|
return res.status(400).send(`Webhook Error: ${error}`);
|
|
}
|
|
|
|
let user;
|
|
let userId;
|
|
let customerId;
|
|
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
|
|
if (session === undefined) {
|
|
logger.error(
|
|
"StripeWebhook: checkout.session.completed: session undefined"
|
|
);
|
|
break;
|
|
}
|
|
|
|
userId = session.metadata?.userId;
|
|
|
|
if (userId === undefined) {
|
|
logger.error(
|
|
"StripeWebhook: checkout.session.completed: userId undefined"
|
|
);
|
|
break;
|
|
}
|
|
|
|
const paymentPlanInterval = session.metadata?.payment_plan_interval;
|
|
|
|
if (paymentPlanInterval === undefined) {
|
|
logger.error(
|
|
"StripeWebhook: checkout.session.completed: paymentPlanInterval undefined"
|
|
);
|
|
break;
|
|
}
|
|
|
|
user = await User.findOne({
|
|
where: {
|
|
user_id: userId,
|
|
},
|
|
});
|
|
|
|
if (user !== null) {
|
|
await User.update(
|
|
{
|
|
payment_plan: PAYMENT_PLAN.BASIC,
|
|
stripe_customer_id: (event.data.object as Stripe.Checkout.Session)
|
|
.customer as string,
|
|
payment_plan_interval: parseInt(paymentPlanInterval),
|
|
},
|
|
{
|
|
where: {
|
|
user_id: userId,
|
|
},
|
|
}
|
|
);
|
|
|
|
logger.info(
|
|
`StripeWebhook: checkout.session.completed: ${userId} ${JSON.stringify(
|
|
event.data.object
|
|
)}`
|
|
);
|
|
} else {
|
|
logger.error(
|
|
`StripeWebhook: checkout.session.completed: user not found for userId: ${userId}`
|
|
);
|
|
}
|
|
break;
|
|
case "checkout.session.expired":
|
|
logger.info(
|
|
`StripeWebhook: checkout.session.expired: ${event.data.object.id}`
|
|
);
|
|
break;
|
|
case "billing_portal.session.created":
|
|
customerId = event.data.object.customer as string;
|
|
|
|
user = await User.findOne({
|
|
where: {
|
|
stripe_customer_id: customerId,
|
|
},
|
|
});
|
|
|
|
if (user === null) {
|
|
logger.error(
|
|
`StripeWebhook: customer.subscription.updated: user not found for customerId: ${customerId}`
|
|
);
|
|
break;
|
|
}
|
|
|
|
userLogger.info(
|
|
user.user_id as string,
|
|
`StripeWebhook: billing_portal.session.created: customerId: ${customerId}`
|
|
);
|
|
break;
|
|
case "customer.subscription.updated":
|
|
const subscription = event.data.object as Stripe.Subscription;
|
|
|
|
const status = subscription.status;
|
|
const trialEnd = subscription.trial_end as number | null;
|
|
const canceledAt = subscription.canceled_at;
|
|
|
|
customerId = subscription.customer as string;
|
|
|
|
logger.info(
|
|
`StripeWebhook: customer.subscription.updated: ${subscription.id} status: ${status} trialEnd: ${trialEnd} canceledAt: ${canceledAt} customerId: ${customerId}`
|
|
);
|
|
|
|
user = await User.findOne({
|
|
where: {
|
|
stripe_customer_id: customerId,
|
|
},
|
|
});
|
|
|
|
if (user === null) {
|
|
logger.error(
|
|
`StripeWebhook: customer.subscription.updated: user not found for customerId: ${customerId}`
|
|
);
|
|
break;
|
|
}
|
|
|
|
let updateData: any = {
|
|
payment_plan_status: status,
|
|
/*payment_plan_trial_end:
|
|
trialEnd !== null ? new Date(trialEnd * 1000) : null, */
|
|
payment_plan_canceled_at:
|
|
canceledAt !== null ? new Date(canceledAt * 1000) : null,
|
|
};
|
|
|
|
await User.update(updateData, {
|
|
where: {
|
|
stripe_customer_id: customerId,
|
|
},
|
|
});
|
|
|
|
logger.info(
|
|
`StripeWebhook: customer.subscription.updated: updating user: ${
|
|
user.user_id
|
|
} ${JSON.stringify(updateData)}`
|
|
);
|
|
break;
|
|
default:
|
|
logger.warn(`StripeWebhook: Ignoring unknown event: ${event.type}`);
|
|
break;
|
|
}
|
|
/*
|
|
if (event.type === "checkout.session.completed") {
|
|
const userId = (event.data.object as Stripe.Checkout.Session)?.metadata
|
|
?.userId;
|
|
|
|
// delete user pending payment
|
|
|
|
logger.info(
|
|
"delete user pending payment",
|
|
(event.data.object as Stripe.Checkout.Session).id
|
|
);
|
|
|
|
const pendingPayment = await UserPendingPayment.findOne({
|
|
where: {
|
|
payment_session_id: (event.data.object as Stripe.Checkout.Session).id,
|
|
},
|
|
});
|
|
|
|
if (pendingPayment !== null) {
|
|
await pendingPayment.destroy();
|
|
|
|
const user = await User.findOne({
|
|
where: {
|
|
user_id: userId,
|
|
},
|
|
});
|
|
|
|
if (user !== null && user.state === ACCOUNT_STATE.INIT_PAYMENT) {
|
|
// update user state
|
|
await User.update(
|
|
{
|
|
state: ACCOUNT_STATE.ACTIVE,
|
|
stripe_customer_id: (event.data.object as Stripe.Checkout.Session)
|
|
.customer as string,
|
|
},
|
|
{
|
|
where: {
|
|
user_id: userId,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
} else if (event.type === "checkout.session.expired") {
|
|
logger.info(`webhook checkout.session.expired: ${event.data.object.id}`);
|
|
} else {
|
|
logger.warn(`Ignoring unknown event: ${event.type}`);
|
|
} */
|
|
|
|
res.sendStatus(200);
|
|
} catch (error) {
|
|
logger.error("StripeWebhook err:", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|
|
|
|
export async function GetBillingPortal(req: Request, res: Response) {
|
|
try {
|
|
const userSession = await getUserSession(req);
|
|
|
|
if (userSession === null) {
|
|
return res.status(401).send({ err: "unauthorized" });
|
|
}
|
|
|
|
const user = await User.findOne({
|
|
where: {
|
|
user_id: userSession.user_id,
|
|
},
|
|
attributes: ["stripe_customer_id"],
|
|
});
|
|
|
|
if (user === null || user.stripe_customer_id === null) {
|
|
userLogger.error(
|
|
userSession.user_id,
|
|
"GetBillingPortal: user not found",
|
|
userSession.user_id
|
|
);
|
|
return res.status(400).send({ err: "invalid request" });
|
|
}
|
|
|
|
const session = await stripe.billingPortal.sessions.create({
|
|
customer: user.stripe_customer_id,
|
|
return_url: `${DASHBOARD_URL}/payment-plan`,
|
|
});
|
|
|
|
userLogger.info(userSession.user_id, "GetBillingPortal");
|
|
|
|
res.status(200).send({
|
|
url: session.url,
|
|
});
|
|
} catch (error) {
|
|
logger.error("GetBillingPortal", error as string);
|
|
res.status(500).send({ err: "invalid request" });
|
|
}
|
|
}
|