From 2be21a4032ff5de3b9944f0809d85e5834270f30 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Wed, 8 Nov 2023 15:49:26 +0100 Subject: [PATCH 1/2] billing: promo codes --- packages/api/src/controllers/stripe.ts | 63 ++++++++++++++++++- packages/api/src/controllers/user.ts | 8 ++- packages/api/src/middleware/auth.ts | 6 ++ packages/api/src/schema/db-schema.yaml | 6 ++ .../components/PaymentMethodDialog/index.tsx | 1 - packages/www/components/PlanForm/index.tsx | 29 ++++++++- packages/www/hooks/use-api/endpoints/user.ts | 27 ++++++++ 7 files changed, 135 insertions(+), 5 deletions(-) diff --git a/packages/api/src/controllers/stripe.ts b/packages/api/src/controllers/stripe.ts index 27d1332f72..ddc08af78d 100644 --- a/packages/api/src/controllers/stripe.ts +++ b/packages/api/src/controllers/stripe.ts @@ -1,4 +1,4 @@ -import { Router, Request } from "express"; +import { Router, Request, Response } from "express"; import { db } from "../store"; import { products } from "../config"; import sql from "sql-template-strings"; @@ -186,6 +186,67 @@ const sendUsageRecordToStripe = async ( ); }; +app.post("/apply-coupon", async (req: Request, res: Response) => { + const { coupon, stripeCustomerId, stripeCustomerSubscriptionId } = req.body; + + const user = req.user; + + if ( + user.stripeCustomerId != stripeCustomerId || + user.stripeCustomerSubscriptionId != stripeCustomerSubscriptionId + ) { + res.status(400); + return res.json({ errors: ["invalid customer or subscription"] }); + } + + const customer = await req.stripe.customers.retrieve(stripeCustomerId); + + if (!customer) { + res.status(404); + return res.json({ errors: ["customer not found"] }); + } + + const subscription = await req.stripe.subscriptions.retrieve( + stripeCustomerSubscriptionId + ); + + if (!subscription) { + res.status(404); + return res.json({ errors: ["subscription not found"] }); + } + + if (subscription.status === "canceled") { + res.status(400); + return res.json({ errors: ["subscription is canceled"] }); + } + + const couponRes = await req.stripe.coupons.retrieve(coupon); + + if (!couponRes) { + res.status(404); + return res.json({ errors: ["coupon not found"] }); + } + + const subscriptionWithCoupon = await req.stripe.subscriptions.update( + stripeCustomerSubscriptionId, + { + coupon: coupon, + } + ); + + if (!subscriptionWithCoupon) { + res.status(400); + return res.json({ errors: ["failed to apply coupon"] }); + } + + await db.user.update(user.id, { + stripeAppliedCouponId: coupon, + }); + + res.status(200); + return res.json(user); +}); + // Webhook handler for asynchronous events called by stripe on invoice generation // https://stripe.com/docs/billing/subscriptions/webhooks app.post("/webhook", async (req, res) => { diff --git a/packages/api/src/controllers/user.ts b/packages/api/src/controllers/user.ts index fce47ca634..db2ce680d3 100644 --- a/packages/api/src/controllers/user.ts +++ b/packages/api/src/controllers/user.ts @@ -438,7 +438,9 @@ app.post("/", validatePost("user"), async (req, res) => { } let isTest = - process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development"; + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "staging"; if ( req.config.requireEmailVerification && @@ -617,7 +619,9 @@ app.post("/token", validatePost("user"), async (req, res) => { ); let isTest = - process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development"; + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "staging"; if ( req.config.requireEmailVerification && diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index a61370a497..50ca546063 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -290,9 +290,15 @@ function authorizer(params: AuthzParams): RequestHandler { ); } + let isTest = + process.env.NODE_ENV === "test" || + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "staging"; + const verifyEmail = req.config.requireEmailVerification && !params.allowUnverified && + !isTest && user.createdAt > EMAIL_VERIFICATION_CUTOFF_DATE; if (verifyEmail && !user.emailValid) { throw new ForbiddenError( diff --git a/packages/api/src/schema/db-schema.yaml b/packages/api/src/schema/db-schema.yaml index ef35ef8003..dfbc03e26f 100644 --- a/packages/api/src/schema/db-schema.yaml +++ b/packages/api/src/schema/db-schema.yaml @@ -290,6 +290,9 @@ components: - prod_O9XuWMU1Up6QKf - prod_OTTbwpzxNLMNSh - prod_OTTwpzjA4U8B2P + stripeAppliedCouponId: + type: string + description: stripe coupon id update-customer-payment-method: type: object required: @@ -1122,6 +1125,9 @@ components: stripeCustomerSubscriptionId: type: string example: sub_I29pdyfOTPBkjb + stripeAppliedCouponId: + type: string + description: stripe coupon id ccLast4: type: string example: 1234 diff --git a/packages/www/components/PaymentMethodDialog/index.tsx b/packages/www/components/PaymentMethodDialog/index.tsx index cfe77c82b7..495ea26320 100644 --- a/packages/www/components/PaymentMethodDialog/index.tsx +++ b/packages/www/components/PaymentMethodDialog/index.tsx @@ -258,7 +258,6 @@ const PaymentMethodDialog = ({ invalidateQuery }) => { /> - { const { user, updateSubscription } = useApi(); + const { applyCoupon } = useApi(); const [status, setStatus] = useState("initial"); const stripe = useStripe(); const elements = useElements(); @@ -46,6 +47,7 @@ const PlanForm = ({ function createPaymentMethod({ cardElement, + coupon, stripeCustomerId, stripeCustomerSubscriptionId, stripeProductId, @@ -63,6 +65,14 @@ const PlanForm = ({ console.log(result.error); setStatus("error"); } else { + if (coupon && coupon !== "") { + applyCoupon({ + stripeCustomerId, + stripeCustomerSubscriptionId, + stripeProductId, + coupon, + }); + } updateSubscription({ stripeCustomerId, stripeCustomerPaymentMethodId: paymentMethod.id, @@ -171,6 +181,7 @@ const PlanForm = ({ cardElement, stripeCustomerId: user.stripeCustomerId, stripeCustomerSubscriptionId: user.stripeCustomerSubscriptionId, + coupon: data.coupon, stripeProductId, billingDetails: { name: data.name, @@ -376,7 +387,23 @@ const PlanForm = ({ /> - + + + + => { + const [res, body] = await context.fetch("/stripe/apply-coupon", { + method: "POST", + body: JSON.stringify({ + stripeCustomerId, + stripeCustomerSubscriptionId, + stripeProductId, + coupon, + }), + headers: { + "content-type": "application/json", + }, + }); + setState((state) => ({ ...state, userRefresh: Date.now() })); + + if (res.status !== 201) { + return body; + } + + return res; +}; + export const getSubscription = async ( stripeCustomerSubscriptionId: string ): Promise<[Response, ApiError]> => { From 64ca1a3247f7c5ef21df6da82859ddb5d15905c1 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Thu, 9 Nov 2023 11:21:16 +0100 Subject: [PATCH 2/2] fix tests --- packages/api/src/middleware/auth.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 50ca546063..a61370a497 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -290,15 +290,9 @@ function authorizer(params: AuthzParams): RequestHandler { ); } - let isTest = - process.env.NODE_ENV === "test" || - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "staging"; - const verifyEmail = req.config.requireEmailVerification && !params.allowUnverified && - !isTest && user.createdAt > EMAIL_VERIFICATION_CUTOFF_DATE; if (verifyEmail && !user.emailValid) { throw new ForbiddenError(