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 logger, { userLogger } from "../logger/logger";
import Stripe from "stripe"; import Stripe from "stripe";
import { import {
ACCOUNT_STATE,
DASHBOARD_URL, DASHBOARD_URL,
PAYMENT_PLAN,
PAYMENT_PLAN_SETTINGS, PAYMENT_PLAN_SETTINGS,
} from "../utils/constants"; } from "../utils/constants";
import UserPendingPayment from "../models/userPendingPayment"; import UserPendingPayment from "../models/userPendingPayment";
@ -12,10 +12,9 @@ import { getUserSession, stripe } from "../utils/utils";
import Store from "../models/store"; import Store from "../models/store";
import Session from "../models/session"; import Session from "../models/session";
const ZeitAdlerBasicMonthly = "zeitadler-basic-monthly"; const ZeitAdlerBasicMonthly = "za-basic-monthly";
const ZeitAdlerBasicYearly = "zeitadler-basic-yearly"; const ZeitAdlerBasicYearly = "za-basic-yearly";
const ZeitAdlerPremiumMonthly = "zeitadler-premium-monthly"; const lookupKeys = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
const ZeitAdlerPremiumYearly = "zeitadler-premium-yearly";
let cachedPrices = [] as any[]; let cachedPrices = [] as any[];
@ -28,12 +27,7 @@ export async function loadPrices() {
// load prices from stripe // load prices from stripe
const prices = await stripe.prices.list({ const prices = await stripe.prices.list({
lookup_keys: [ lookup_keys: lookupKeys,
ZeitAdlerBasicMonthly,
ZeitAdlerBasicYearly,
ZeitAdlerPremiumMonthly,
ZeitAdlerPremiumYearly,
],
expand: ["data.product"], 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) { export async function getPriceId(paymentPlan: number, interval: number) {
await loadPrices(); await loadPrices();
@ -75,13 +129,7 @@ export async function GetPrices(req: Request, res: Response) {
await loadPrices(); await loadPrices();
let lookupKey: any[] = []; let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
if (productId === "0") {
lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
} else if (productId === "1") {
lookupKey = [ZeitAdlerPremiumMonthly, ZeitAdlerPremiumYearly];
}
res.status(200).send({ res.status(200).send({
prices: cachedPrices 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 { try {
if (priceId === undefined || userId === undefined) { const { lookupKey } = req.body;
logger.error("CreateCheckoutSession: invalid request");
return ""; if (lookupKey === undefined) {
return res.status(400).send({ err: "invalid request" });
} }
await loadPrices(); 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({ const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"], payment_method_types: ["card"],
line_items: [ line_items: [
@ -123,31 +244,37 @@ export async function CreateCheckoutSession(priceId: string, userId: string) {
billing_address_collection: "required", billing_address_collection: "required",
mode: "subscription", mode: "subscription",
subscription_data: { 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}`, success_url: `${DASHBOARD_URL}/payment-plan`,
cancel_url: `${DASHBOARD_URL}/checkout/canceled/{CHECKOUT_SESSION_ID}`, cancel_url: `${DASHBOARD_URL}/payment-plan`,
metadata: { metadata: {
userId: userId, userId: userSession.user_id,
}, payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0,
} /*
automatic_tax: { automatic_tax: {
// see https://dashboard.stripe.com/settings/tax // see https://dashboard.stripe.com/settings/tax
enabled: true, enabled: true,
}, },*/,
}); });
/*
await UserPendingPayment.create({ await UserPendingPayment.create({
payment_session_id: session.id, payment_session_id: session.id,
user_id: userId, user_id: userSession.user_id,
payment_session_url: session.url as string, 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) { } catch (error) {
logger.error("CreateCheckoutSession", error as string); 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}`); return res.status(400).send(`Webhook Error: ${error}`);
} }
let user;
let userId; let userId;
let customerId; let customerId;
let user;
switch (event.type) { switch (event.type) {
case "checkout.session.completed": case "checkout.session.completed":
userId = (event.data.object as Stripe.Checkout.Session)?.metadata const session = event.data.object as Stripe.Checkout.Session;
?.userId;
if (session === undefined) {
logger.error(
"StripeWebhook: checkout.session.completed: session undefined"
);
break;
}
userId = session.metadata?.userId;
if (userId === undefined) { if (userId === undefined) {
logger.error( logger.error(
@ -279,32 +414,28 @@ export async function StripeWebhook(req: Request, res: Response) {
break; break;
} }
// delete user pending payment const paymentPlanInterval = session.metadata?.payment_plan_interval;
const pendingPayment = await UserPendingPayment.findOne({ if (paymentPlanInterval === undefined) {
where: { logger.error(
payment_session_id: (event.data.object as Stripe.Checkout.Session) "StripeWebhook: checkout.session.completed: paymentPlanInterval undefined"
.id, );
}, break;
}); }
if (pendingPayment !== null) { user = await User.findOne({
await pendingPayment.destroy();
let user = await User.findOne({
where: { where: {
user_id: userId, user_id: userId,
}, },
}); });
if (user !== null && user.state === ACCOUNT_STATE.INIT_PAYMENT) { if (user !== null) {
// update user state
await User.update( await User.update(
{ {
state: ACCOUNT_STATE.ACTIVE, payment_plan: PAYMENT_PLAN.BASIC,
stripe_customer_id: ( stripe_customer_id: (event.data.object as Stripe.Checkout.Session)
event.data.object as Stripe.Checkout.Session .customer as string,
).customer as string, payment_plan_interval: parseInt(paymentPlanInterval),
}, },
{ {
where: { where: {
@ -313,18 +444,21 @@ export async function StripeWebhook(req: Request, res: Response) {
} }
); );
userLogger.info( logger.info(
userId as string, `StripeWebhook: checkout.session.completed: ${userId} ${JSON.stringify(
"StripeWebhook: user state updated to ACTIVE" event.data.object
)}`
);
} else {
logger.error(
`StripeWebhook: checkout.session.completed: user not found for userId: ${userId}`
); );
}
} }
break; break;
case "checkout.session.expired": case "checkout.session.expired":
logger.info( logger.info(
`StripeWebhook: checkout.session.expired: ${event.data.object.id}` `StripeWebhook: checkout.session.expired: ${event.data.object.id}`
); );
break; break;
case "billing_portal.session.created": case "billing_portal.session.created":
customerId = event.data.object.customer as string; customerId = event.data.object.customer as string;
@ -375,21 +509,23 @@ export async function StripeWebhook(req: Request, res: Response) {
let updateData: any = { let updateData: any = {
payment_plan_status: status, payment_plan_status: status,
payment_plan_trial_end: trialEnd !== null ? trialEnd : null, /*payment_plan_trial_end:
payment_plan_canceled_at: canceledAt !== null ? canceledAt : null, 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, { await User.update(updateData, {
where: { where: {
stripe_customer_id: customerId, stripe_customer_id: customerId,
}, },
}); });
logger.info(
`StripeWebhook: customer.subscription.updated: updating user: ${
user.user_id
} ${JSON.stringify(updateData)}`
);
break; break;
default: default:
logger.warn(`StripeWebhook: Ignoring unknown event: ${event.type}`); 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`, return_url: `${DASHBOARD_URL}/payment-plan`,
}); });
userLogger.info(userSession.user_id, "GetBillingPortal");
res.status(200).send({ res.status(200).send({
url: session.url, url: session.url,
}); });

View File

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

View File

@ -43,7 +43,19 @@ class MyTransport extends Transport {
setImmediate(() => this.emit("logged", info)); setImmediate(() => this.emit("logged", info));
const currentDate = new Date(); 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; let logLevel;

View File

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

View File

@ -2,10 +2,14 @@ import express from "express";
const router = express.Router(); const router = express.Router();
import * as paymentController from "../controllers/paymentController"; import * as paymentController from "../controllers/paymentController";
import { sessionProtection } from "../middleware/authMiddleware";
router.get("/", sessionProtection, paymentController.getUserPlanInfo);
router.get("/prices/:productId", paymentController.GetPrices); router.get("/prices/:productId", paymentController.GetPrices);
// webhook is registered in server.ts as it needs registered before the body parser // 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/success", paymentController.CheckoutSuccess);
router.post("/checkout/canceled", paymentController.CheckoutCanceled); router.post("/checkout/canceled", paymentController.CheckoutCanceled);
router.get("/portal", paymentController.GetBillingPortal); 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 INIT_LOGIN = 2, // account is created but password is not set yet
BANNED = 3, // account is banned, cannot login BANNED = 3, // account is banned, cannot login
PENDING_EMAIL_VERIFICATION = 4, // account is created but email is not verified yet 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; 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 ACCOUNT_EXPORT_URL = `${DASHBOARD_URL}/api/v1/user/profile/export/`;
export const TERMIN_PLANNER_URL = process.env.TERMIN_PLANNER_URL; 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 { export enum PAYMENT_PLAN {
DEMO = 0, TRAILING = 0,
BASIC = 1, BASIC = 1,
PREMIUM = 2,
} }
export const PAYMENT_PLAN_SETTINGS = [ export const PAYMENT_PLAN_SETTINGS = [
{ {
id: "demo", // used in the backend for identifiying the stripe pricing product id: "trailing", // used in the backend for identifiying the stripe pricing product
name: "Demo", // used in the frontend name: "Trailing", // used in the frontend
maxEmployees: 5, maxEmployees: 5,
calendarMaxFutureBookingDays: 7, calendarMaxFutureBookingDays: 7,
}, },
{ {
id: "basic", // used in the backend for identifiying the stripe pricing product id: "basic", // used in the backend for identifiying the stripe pricing product
name: "Basic", // used in the frontend 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, maxEmployees: 20,
calendarMaxFutureBookingDays: 90, calendarMaxFutureBookingDays: 30,
}, },
]; ];
/*
export enum PAYMENT_PLAN_STATUS { export enum PAYMENT_PLAN_STATUS {
TRAILING = "trailing", TRAILING = "trailing",
} }
*/

View File

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