change plan

main
alex 2024-03-31 13:30:45 +02:00
parent d7390e7024
commit a41a0923da
7 changed files with 192 additions and 84 deletions

8
package-lock.json generated
View File

@ -28,7 +28,7 @@
"pino-pretty": "^10.3.1", "pino-pretty": "^10.3.1",
"react-microsoft-clarity": "^1.2.0", "react-microsoft-clarity": "^1.2.0",
"sequelize": "^6.35.2", "sequelize": "^6.35.2",
"stripe": "^14.16.0", "stripe": "^14.23.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
@ -2786,9 +2786,9 @@
} }
}, },
"node_modules/stripe": { "node_modules/stripe": {
"version": "14.16.0", "version": "14.23.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.16.0.tgz", "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.23.0.tgz",
"integrity": "sha512-1gOr2LzafWV84cPIO5Md/QPh4XVPLKULVuRpBVOV3Plq3seiHmg/eeOktX+hDl8jpNZuORHYaUJGrNqrABLwdg==", "integrity": "sha512-OPD7LqBmni6uDdqA05GGgMZyyRWxJOehONBNC9tYgY4Uh089EtXd6QLIgRGrqTDlQH3cA2BXo848nxwa/zsQzw==",
"dependencies": { "dependencies": {
"@types/node": ">=8.1.0", "@types/node": ">=8.1.0",
"qs": "^6.11.0" "qs": "^6.11.0"

View File

@ -30,7 +30,7 @@
"pino-pretty": "^10.3.1", "pino-pretty": "^10.3.1",
"react-microsoft-clarity": "^1.2.0", "react-microsoft-clarity": "^1.2.0",
"sequelize": "^6.35.2", "sequelize": "^6.35.2",
"stripe": "^14.16.0", "stripe": "^14.23.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",

View File

@ -6,11 +6,9 @@ import {
PAYMENT_PLAN, PAYMENT_PLAN,
PAYMENT_PLAN_SETTINGS, PAYMENT_PLAN_SETTINGS,
} from "../utils/constants"; } from "../utils/constants";
import UserPendingPayment from "../models/userPendingPayment";
import User from "../models/user"; import User from "../models/user";
import { getUserSession, stripe } from "../utils/utils"; import { getUserSession, stripe } from "../utils/utils";
import Store from "../models/store"; import { telegramNotification } from "../utils/adminDashboard";
import Session from "../models/session";
const ZeitAdlerBasicMonthly = "za-basic-monthly"; const ZeitAdlerBasicMonthly = "za-basic-monthly";
const ZeitAdlerBasicYearly = "za-basic-yearly"; const ZeitAdlerBasicYearly = "za-basic-yearly";
@ -198,17 +196,57 @@ export async function CreateCheckoutSession(req: Request, res: Response) {
subscriptions.data.length > 0 && subscriptions.data.length > 0 &&
subscriptions.data[0].id !== priceId subscriptions.data[0].id !== priceId
) { ) {
const latestSubscription = subscriptions.data[0]; const currentSubscription = subscriptions.data[0];
const currentItem = currentSubscription.items.data[0];
await stripe.subscriptions.update(latestSubscription.id, { // Calculate the prorated credit for the old subscription
items: [ const endCurrentSub = currentSubscription.current_period_end;
{ const now = Math.floor(Date.now() / 1000);
id: latestSubscription.items.data[0].id, let proratedCredit = 0;
price: priceId, if (currentItem.price.unit_amount !== null) {
}, proratedCredit = Math.round(
], ((endCurrentSub - now) /
(endCurrentSub - currentSubscription.current_period_start)) *
currentItem.price.unit_amount
);
}
// Create an invoice item for the prorated credit
/*await stripe.invoiceItems.create({
customer: user.stripe_customer_id,
amount: -proratedCredit,
currency: currentItem.price.currency,
description: "Prorated credit for plan switch",
}); */
const updatedSubscription = await stripe.subscriptions.update(
currentSubscription.id,
{
cancel_at_period_end: false,
proration_behavior: "always_invoice",
items: [
{
id: currentSubscription.items.data[0].id,
price: priceId,
},
],
}
);
// Attempt to pay any open invoices immediately, ensuring the customer pays for the new plan right away
const invoice = await stripe.invoices.create({
customer: user.stripe_customer_id,
subscription: updatedSubscription.id,
auto_advance: true, // Automatically pay the invoice if the customer has a default payment method
}); });
// Finalize the invoice immediately to collect payment
if (invoice.status !== "paid") {
await stripe.invoices.finalizeInvoice(invoice.id, {
auto_advance: true,
});
}
// update user // update user
await User.update( await User.update(
@ -224,7 +262,12 @@ export async function CreateCheckoutSession(req: Request, res: Response) {
userLogger.info( userLogger.info(
userSession.user_id, userSession.user_id,
`CreateCheckoutSession: update subscription ${latestSubscription.id} to ${priceId}` `CreateCheckoutSession: update subscription ${currentSubscription.id} to ${priceId}`
);
telegramNotification(
1,
`User ${userSession.user_id} updated subscription ${currentSubscription.id} to ${priceId}`
); );
return res.status(200).json({ status: "ok" }); return res.status(200).json({ status: "ok" });
@ -233,7 +276,7 @@ export async function CreateCheckoutSession(req: Request, res: Response) {
// create checkout session // create checkout session
const session = await stripe.checkout.sessions.create({ let sessionData = {
payment_method_types: ["card"], payment_method_types: ["card"],
line_items: [ line_items: [
{ {
@ -243,20 +286,33 @@ export async function CreateCheckoutSession(req: Request, res: Response) {
], ],
billing_address_collection: "required", billing_address_collection: "required",
mode: "subscription", mode: "subscription",
subscription_data: {
trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000,
},
success_url: `${DASHBOARD_URL}/payment-plan`, success_url: `${DASHBOARD_URL}/payment-plan`,
cancel_url: `${DASHBOARD_URL}/payment-plan`, cancel_url: `${DASHBOARD_URL}/payment-plan`,
metadata: { metadata: {
userId: userSession.user_id, userId: userSession.user_id,
payment_plan_interval: lookupKey === ZeitAdlerBasicYearly ? 1 : 0, 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,
},*/, },*/
}); } as any;
if (
user.payment_plan_trial_end !== null &&
new Date(user.payment_plan_trial_end) > new Date()
) {
sessionData = {
...sessionData,
subscription_data: {
trial_end: new Date(user.payment_plan_trial_end).getTime() / 1000,
},
};
}
const session = await stripe.checkout.sessions.create(sessionData);
/* /*
await UserPendingPayment.create({ await UserPendingPayment.create({
payment_session_id: session.id, payment_session_id: session.id,
@ -280,7 +336,7 @@ export async function CreateCheckoutSession(req: Request, res: Response) {
// user is redirected to dashboard checkout page after successful payment // user is redirected to dashboard checkout page after successful payment
// and then makes request to this endpoint to create a user session // and then makes request to this endpoint to create a user session
export async function CheckoutSuccess(req: Request, res: Response) { /*export async function CheckoutSuccess(req: Request, res: Response) {
try { try {
const { sessionId } = req.body; const { sessionId } = req.body;
@ -365,7 +421,7 @@ export async function CheckoutCanceled(req: Request, res: Response) {
logger.error("CheckoutCanceled", error as string); logger.error("CheckoutCanceled", error as string);
res.status(500).send({ err: "invalid request" }); res.status(500).send({ err: "invalid request" });
} }
} }*/
export async function StripeWebhook(req: Request, res: Response) { export async function StripeWebhook(req: Request, res: Response) {
try { try {
@ -449,6 +505,11 @@ export async function StripeWebhook(req: Request, res: Response) {
event.data.object event.data.object
)}` )}`
); );
telegramNotification(
1,
`User ${userId} completed checkout session ${session.id}`
);
} else { } else {
logger.error( logger.error(
`StripeWebhook: checkout.session.completed: user not found for userId: ${userId}` `StripeWebhook: checkout.session.completed: user not found for userId: ${userId}`
@ -511,6 +572,10 @@ export async function StripeWebhook(req: Request, res: Response) {
payment_plan_status: status, payment_plan_status: status,
/*payment_plan_trial_end: /*payment_plan_trial_end:
trialEnd !== null ? new Date(trialEnd * 1000) : null, */ trialEnd !== null ? new Date(trialEnd * 1000) : null, */
payment_plan_cancel_at:
subscription.cancel_at !== null
? new Date(subscription.cancel_at * 1000)
: null, // this is if the user cancels the subscription after the trial period
payment_plan_canceled_at: payment_plan_canceled_at:
canceledAt !== null ? new Date(canceledAt * 1000) : null, canceledAt !== null ? new Date(canceledAt * 1000) : null,
}; };
@ -528,57 +593,13 @@ export async function StripeWebhook(req: Request, res: Response) {
); );
break; break;
default: default:
logger.warn(`StripeWebhook: Ignoring unknown event: ${event.type}`); logger.warn(
`StripeWebhook: Ignoring unknown event: ${
event.type
} data: ${JSON.stringify(event.data)}`
);
break; 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); res.sendStatus(200);
} catch (error) { } catch (error) {
@ -599,7 +620,11 @@ export async function GetBillingPortal(req: Request, res: Response) {
where: { where: {
user_id: userSession.user_id, user_id: userSession.user_id,
}, },
attributes: ["stripe_customer_id"], attributes: [
"payment_plan",
"payment_plan_interval",
"stripe_customer_id",
],
}); });
if (user === null || user.stripe_customer_id === null) { if (user === null || user.stripe_customer_id === null) {
@ -611,6 +636,74 @@ export async function GetBillingPortal(req: Request, res: Response) {
return res.status(400).send({ err: "invalid request" }); return res.status(400).send({ err: "invalid request" });
} }
// after the trial period, the subscribtion is deleted
// so we need to check if the user has a subscription or not
const subscriptions = await stripe.subscriptions.list({
customer: user.stripe_customer_id,
limit: 1,
});
console.log("subs", subscriptions.data.length, user.payment_plan);
if (subscriptions.data.length === 0) {
let lookupKey = [ZeitAdlerBasicMonthly, ZeitAdlerBasicYearly];
console.log("user", user.payment_plan_interval);
await loadPrices();
let priceId = cachedPrices.find(
(price) => price.lookup_key === lookupKey[user.payment_plan_interval]
)?.id;
if (priceId === undefined) {
return res.status(400).send({ err: "invalid request" });
}
console.log(
"priceId",
priceId,
user.payment_plan_interval,
user.payment_plan_trial_end
);
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: user.payment_plan_interval,
},
/*
automatic_tax: {
// see https://dashboard.stripe.com/settings/tax
enabled: true,
},*/
});
userLogger.info(
userSession.user_id,
`GetBillingPortal: create checkout session ${session.id}`
);
return res.status(200).send({
url: session.url,
});
}
const session = await stripe.billingPortal.sessions.create({ const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id, customer: user.stripe_customer_id,
return_url: `${DASHBOARD_URL}/payment-plan`, return_url: `${DASHBOARD_URL}/payment-plan`,

View File

@ -474,6 +474,7 @@ export async function GetUser(req: Request, res: Response) {
"analytics_enabled", "analytics_enabled",
"payment_plan", "payment_plan",
"payment_plan_trial_end", "payment_plan_trial_end",
"payment_plan_cancel_at",
"payment_plan_canceled_at", "payment_plan_canceled_at",
"created_at", "created_at",
], ],
@ -517,7 +518,11 @@ export async function GetUser(req: Request, res: Response) {
user.payment_plan === PAYMENT_PLAN.TRAILING || user.payment_plan === PAYMENT_PLAN.TRAILING ||
user.payment_plan_canceled_at !== null user.payment_plan_canceled_at !== null
) { ) {
respData.user["payment_plan_trial_end"] = user.payment_plan_trial_end; if (user.payment_plan_cancel_at !== null) {
respData.user["payment_plan_trial_end"] = user.payment_plan_cancel_at;
} else {
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
@ -557,6 +562,8 @@ export async function GetUser(req: Request, res: Response) {
user.payment_plan user.payment_plan
}. Possible highest payment plan is ${PAYMENT_PLAN_SETTINGS.length - 1}` }. Possible highest payment plan is ${PAYMENT_PLAN_SETTINGS.length - 1}`
); );
return res.status(400).send({ err: "invalid request" });
} }
// update user session last_used // update user session last_used

View File

@ -17,10 +17,11 @@ interface UserAttributes {
google_account_name?: string; google_account_name?: string;
google_account_picture?: string; google_account_picture?: string;
analytics_enabled: boolean; analytics_enabled: boolean;
payment_plan: number; payment_plan: number; // 0 trailing, 1 basic
payment_plan_interval?: number; payment_plan_interval?: number; // how often the payment plan is charged (e.g. 0 monthly, 1 yearly)
payment_plan_trial_end?: Date; payment_plan_trial_end?: Date; // when the payment plan trial ends
payment_plan_canceled_at?: Date; payment_plan_cancel_at?: Date; // when the payment plan will be canceled (e.g. after trial)
payment_plan_canceled_at?: Date; // when the payment plan was canceled
stripe_customer_id?: string; stripe_customer_id?: string;
} }
@ -42,6 +43,7 @@ class User extends Model<UserAttributes> implements UserAttributes {
declare payment_plan: number; declare payment_plan: number;
declare payment_plan_interval: number; declare payment_plan_interval: number;
declare payment_plan_trial_end: Date; declare payment_plan_trial_end: Date;
declare payment_plan_cancel_at: Date;
declare payment_plan_canceled_at: Date; declare payment_plan_canceled_at: Date;
declare stripe_customer_id: string; declare stripe_customer_id: string;
declare created_at: Date; declare created_at: Date;
@ -119,6 +121,10 @@ User.init(
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,
}, },
payment_plan_cancel_at: {
type: DataTypes.DATE,
allowNull: true,
},
payment_plan_canceled_at: { payment_plan_canceled_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,

View File

@ -10,8 +10,8 @@ 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", 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);
export default router; export default router;

View File

@ -183,7 +183,9 @@ export function isCalendarMinEarliestBookingTimeValid(
} }
export function isPaymentPlanValid(paymentPlan: number) { export function isPaymentPlanValid(paymentPlan: number) {
return paymentPlan >= PAYMENT_PLAN.BASIC && paymentPlan <= PAYMENT_PLAN.BASIC; return (
paymentPlan >= PAYMENT_PLAN.TRAILING && paymentPlan <= PAYMENT_PLAN.BASIC
);
} }
export function isCompanyNameValid(companyName: string) { export function isCompanyNameValid(companyName: string) {