main
alex 2024-03-30 13:47:05 +01:00
parent 6eb711f673
commit 92f6782c21
7 changed files with 273 additions and 122 deletions

View File

@ -2,8 +2,8 @@ import { Request, Response } from "express";
import logger, { userLogger } from "../logger/logger";
import Stripe from "stripe";
import {
ACCOUNT_STATE,
DASHBOARD_URL,
PAYMENT_PLAN,
PAYMENT_PLAN_SETTINGS,
} from "../utils/constants";
import UserPendingPayment from "../models/userPendingPayment";
@ -12,10 +12,9 @@ import { getUserSession, stripe } from "../utils/utils";
import Store from "../models/store";
import Session from "../models/session";
const ZeitAdlerBasicMonthly = "zeitadler-basic-monthly";
const ZeitAdlerBasicYearly = "zeitadler-basic-yearly";
const ZeitAdlerPremiumMonthly = "zeitadler-premium-monthly";
const ZeitAdlerPremiumYearly = "zeitadler-premium-yearly";
const ZeitAdlerBasicMonthly = "za-basic-monthly";
const ZeitAdlerBasicYearly = "za-basic-yearly";
const lookupKeys = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
let cachedPrices = [] as any[];
@ -28,12 +27,7 @@ export async function loadPrices() {
// load prices from stripe
const prices = await stripe.prices.list({
lookup_keys: [
ZeitAdlerBasicMonthly,
ZeitAdlerBasicYearly,
ZeitAdlerPremiumMonthly,
ZeitAdlerPremiumYearly,
],
lookup_keys: lookupKeys,
expand: ["data.product"],
});
@ -48,6 +42,66 @@ export async function loadPrices() {
}
}
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();
@ -75,13 +129,7 @@ export async function GetPrices(req: Request, res: Response) {
await loadPrices();
let lookupKey: any[] = [];
if (productId === "0") {
lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
} else if (productId === "1") {
lookupKey = [ZeitAdlerPremiumMonthly, ZeitAdlerPremiumYearly];
}
let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
res.status(200).send({
prices: cachedPrices
@ -103,15 +151,88 @@ export async function GetPrices(req: Request, res: Response) {
}
}
export async function CreateCheckoutSession(priceId: string, userId: string) {
export async function CreateCheckoutSession(req: Request, res: Response) {
try {
if (priceId === undefined || userId === undefined) {
logger.error("CreateCheckoutSession: invalid request");
return "";
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: [
@ -123,31 +244,37 @@ export async function CreateCheckoutSession(priceId: string, userId: string) {
billing_address_collection: "required",
mode: "subscription",
subscription_data: {
trial_period_days: 7,
trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000,
},
success_url: `${DASHBOARD_URL}/checkout/success/{CHECKOUT_SESSION_ID}`,
cancel_url: `${DASHBOARD_URL}/checkout/canceled/{CHECKOUT_SESSION_ID}`,
success_url: `${DASHBOARD_URL}/payment-plan`,
cancel_url: `${DASHBOARD_URL}/payment-plan`,
metadata: {
userId: userId,
},
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: userId,
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,
});
logger.info(`CreateCheckoutSession: ${session.id}`);
return session.url;
} catch (error) {
logger.error("CreateCheckoutSession", error as string);
return "";
res.status(500).send({ err: "invalid request" });
}
}
@ -263,14 +390,22 @@ export async function StripeWebhook(req: Request, res: Response) {
return res.status(400).send(`Webhook Error: ${error}`);
}
let user;
let userId;
let customerId;
let user;
switch (event.type) {
case "checkout.session.completed":
userId = (event.data.object as Stripe.Checkout.Session)?.metadata
?.userId;
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(
@ -279,52 +414,51 @@ export async function StripeWebhook(req: Request, res: Response) {
break;
}
// delete user pending payment
const paymentPlanInterval = session.metadata?.payment_plan_interval;
const pendingPayment = await UserPendingPayment.findOne({
if (paymentPlanInterval === undefined) {
logger.error(
"StripeWebhook: checkout.session.completed: paymentPlanInterval undefined"
);
break;
}
user = await User.findOne({
where: {
payment_session_id: (event.data.object as Stripe.Checkout.Session)
.id,
user_id: userId,
},
});
if (pendingPayment !== null) {
await pendingPayment.destroy();
let 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),
},
});
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,
},
{
where: {
user_id: userId,
},
}
);
}
);
userLogger.info(
userId as string,
"StripeWebhook: user state updated to ACTIVE"
);
}
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;
@ -375,21 +509,23 @@ export async function StripeWebhook(req: Request, res: Response) {
let updateData: any = {
payment_plan_status: status,
payment_plan_trial_end: trialEnd !== null ? trialEnd : null,
payment_plan_canceled_at: canceledAt !== null ? canceledAt : null,
/*payment_plan_trial_end:
trialEnd !== null ? new Date(trialEnd * 1000) : null, */
payment_plan_canceled_at:
canceledAt !== null ? new Date(canceledAt * 1000) : null,
};
logger.info(
`StripeWebhook: customer.subscription.updated: updating user: ${
user.user_id
} ${JSON.stringify(updateData)}`
);
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}`);
@ -480,6 +616,8 @@ export async function GetBillingPortal(req: Request, res: Response) {
return_url: `${DASHBOARD_URL}/payment-plan`,
});
userLogger.info(userSession.user_id, "GetBillingPortal");
res.status(200).send({
url: session.url,
});

View File

@ -18,6 +18,7 @@ import {
CALENDAR_MAX_SERVICE_DURATION,
CALENDAR_MIN_EARLIEST_BOOKING_TIME,
EMAIL_VERIFICATION_STATE,
PAYMENT_DEMO_TRAILING_DAYS,
PAYMENT_PLAN,
PAYMENT_PLAN_SETTINGS,
Roles,
@ -132,7 +133,8 @@ export async function SignUp(req: Request, res: Response) {
owner_user_id: userId,
name: companyName,
calendar_max_future_booking_days:
PAYMENT_PLAN_SETTINGS[PAYMENT_PLAN.DEMO].calendarMaxFutureBookingDays,
PAYMENT_PLAN_SETTINGS[PAYMENT_PLAN.TRAILING]
.calendarMaxFutureBookingDays,
calendar_min_earliest_booking_time: CALENDAR_MIN_EARLIEST_BOOKING_TIME,
calendar_max_service_duration: CALENDAR_MAX_SERVICE_DURATION,
address: companyAddress,
@ -164,9 +166,9 @@ export async function SignUp(req: Request, res: Response) {
password: hashedPassword,
language: language,
analytics_enabled: USER_ANALYTICS_ENABLED_DEFAULT,
state: ACCOUNT_STATE.INIT_PAYMENT,
payment_plan: PAYMENT_PLAN.DEMO,
// payment_plan_status: PAYMENT_PLAN_STATUS.TRAILING,
state: ACCOUNT_STATE.ACTIVE,
payment_plan: PAYMENT_PLAN.TRAILING,
payment_plan_trial_end: new Date(Date.now() + PAYMENT_DEMO_TRAILING_DAYS),
});
/*
const checkoutSessionUrl = await CreateCheckoutSession(
@ -471,7 +473,6 @@ export async function GetUser(req: Request, res: Response) {
"language",
"analytics_enabled",
"payment_plan",
"payment_plan_status",
"payment_plan_trial_end",
"payment_plan_canceled_at",
"created_at",
@ -495,21 +496,29 @@ export async function GetUser(req: Request, res: Response) {
let respData = {
user: {
user_id: user.user_id,
// user_id: user.user_id,
username: user.username,
//store_id: user.store_id,
language: user.language,
analytics_enabled: user.analytics_enabled,
payment_plan: user.payment_plan,
// payment_plan_status: user.payment_plan_status,
payment_plan_trial_end: user.payment_plan_trial_end,
payment_plan_canceled_at: user.payment_plan_canceled_at,
},
stores: stores,
// only temporary until we have a proper permissions system
permissions: [] as string[],
paymentPlanSettings: PAYMENT_PLAN_SETTINGS[user.payment_plan],
};
} as any;
if (user.payment_plan_canceled_at !== null) {
user["payment_plan_canceled_at"] = user.payment_plan_canceled_at;
}
if (
user.payment_plan === PAYMENT_PLAN.TRAILING ||
user.payment_plan_canceled_at !== null
) {
respData.user["payment_plan_trial_end"] = user.payment_plan_trial_end;
}
// if user is not a store master, then check if user is a worker
if (!stores || stores.length === 0) {
@ -528,7 +537,6 @@ export async function GetUser(req: Request, res: Response) {
stores.push(store);
respData.stores = stores;
respData.permissions.push("calendar");
} else {
// user is a store owner

View File

@ -43,7 +43,19 @@ class MyTransport extends Transport {
setImmediate(() => this.emit("logged", info));
const currentDate = new Date();
const formattedDate = `${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}:${currentDate.getMilliseconds()}`;
const formattedDate = `${currentDate
.getHours()
.toString()
.padStart(2, "0")}:${currentDate
.getMinutes()
.toString()
.padStart(2, "0")}:${currentDate
.getSeconds()
.toString()
.padStart(2, "0")}:${currentDate
.getMilliseconds()
.toString()
.padStart(3, "0")}`;
let logLevel;

View File

@ -18,9 +18,9 @@ interface UserAttributes {
google_account_picture?: string;
analytics_enabled: boolean;
payment_plan: number;
// payment_plan_status?: string;
payment_plan_trial_end?: number;
payment_plan_canceled_at?: number;
payment_plan_interval?: number;
payment_plan_trial_end?: Date;
payment_plan_canceled_at?: Date;
stripe_customer_id?: string;
}
@ -40,9 +40,9 @@ class User extends Model<UserAttributes> implements UserAttributes {
declare google_account_picture: string;
declare analytics_enabled: boolean;
declare payment_plan: number;
// declare payment_plan_status: string;
declare payment_plan_trial_end: number;
declare payment_plan_canceled_at: number;
declare payment_plan_interval: number;
declare payment_plan_trial_end: Date;
declare payment_plan_canceled_at: Date;
declare stripe_customer_id: string;
declare created_at: Date;
}
@ -111,16 +111,16 @@ User.init(
type: DataTypes.TINYINT,
allowNull: false,
},
/*payment_plan_status: {
type: DataTypes.STRING,
allowNull: true,
}, */
payment_plan_trial_end: {
payment_plan_interval: {
type: DataTypes.INTEGER,
allowNull: true,
},
payment_plan_trial_end: {
type: DataTypes.DATE,
allowNull: true,
},
payment_plan_canceled_at: {
type: DataTypes.INTEGER,
type: DataTypes.DATE,
allowNull: true,
},
stripe_customer_id: {

View File

@ -2,10 +2,14 @@ import express from "express";
const router = express.Router();
import * as paymentController from "../controllers/paymentController";
import { sessionProtection } from "../middleware/authMiddleware";
router.get("/", sessionProtection, paymentController.getUserPlanInfo);
router.get("/prices/:productId", paymentController.GetPrices);
// webhook is registered in server.ts as it needs registered before the body parser
router.post("/checkout", paymentController.CreateCheckoutSession);
router.post("/checkout/success", paymentController.CheckoutSuccess);
router.post("/checkout/canceled", paymentController.CheckoutCanceled);
router.get("/portal", paymentController.GetBillingPortal);

View File

@ -59,7 +59,6 @@ export enum ACCOUNT_STATE {
INIT_LOGIN = 2, // account is created but password is not set yet
BANNED = 3, // account is banned, cannot login
PENDING_EMAIL_VERIFICATION = 4, // account is created but email is not verified yet
INIT_PAYMENT = 5, // account is created but payment is not set yet
}
export const FEEDBACK_MIN_LENGTH = 3;
@ -88,33 +87,29 @@ export const PASSPORT_SUCCESS_REDIRECT_URL = `${DASHBOARD_URL}/store/calendar/au
export const ACCOUNT_EXPORT_URL = `${DASHBOARD_URL}/api/v1/user/profile/export/`;
export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL;
export const PAYMENT_DEMO_TRAILING_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days
export enum PAYMENT_PLAN {
DEMO = 0,
TRAILING = 0,
BASIC = 1,
PREMIUM = 2,
}
export const PAYMENT_PLAN_SETTINGS = [
{
id: "demo", // used in the backend for identifiying the stripe pricing product
name: "Demo", // used in the frontend
id: "trailing", // used in the backend for identifiying the stripe pricing product
name: "Trailing", // used in the frontend
maxEmployees: 5,
calendarMaxFutureBookingDays: 7,
},
{
id: "basic", // used in the backend for identifiying the stripe pricing product
name: "Basic", // used in the frontend
maxEmployees: 15,
calendarMaxFutureBookingDays: 60,
},
{
id: "premium", // used in the backend for identifiying the stripe pricing product
name: "Premium", // used in the frontend
maxEmployees: 20,
calendarMaxFutureBookingDays: 90,
calendarMaxFutureBookingDays: 30,
},
];
/*
export enum PAYMENT_PLAN_STATUS {
TRAILING = "trailing",
}
*/

View File

@ -183,13 +183,7 @@ export function isCalendarMinEarliestBookingTimeValid(
}
export function isPaymentPlanValid(paymentPlan: number) {
return (
paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.PREMIUM
);
}
export function isPaymentIntervalValid(paymentInterval: number) {
return paymentInterval === 0 || paymentInterval === 1;
return paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.BASIC;
}
export function isCompanyNameValid(companyName: string) {