From 7b96daeafe43f843c8e36b866a40887459afea07 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 26 Nov 2023 16:23:43 +0100 Subject: [PATCH] add finalizeCheckout --- .../__tests__/api/controllers/store.spec.ts | 144 ++++++++++++++++-- backend/__tests__/dal/user.spec.ts | 89 +++++++++++ backend/src/api/controllers/store.ts | 56 ++++++- backend/src/api/controllers/user.ts | 1 + backend/src/api/routes/store.ts | 25 ++- backend/src/dal/user.ts | 56 +++++++ backend/src/services/stripe.ts | 14 ++ frontend/src/ts/ape/endpoints/store.ts | 7 + frontend/src/ts/pages/store.ts | 35 +++-- frontend/static/html/pages/store.html | 6 +- 10 files changed, 402 insertions(+), 31 deletions(-) diff --git a/backend/__tests__/api/controllers/store.spec.ts b/backend/__tests__/api/controllers/store.spec.ts index f562aff01..f99d8e64d 100644 --- a/backend/__tests__/api/controllers/store.spec.ts +++ b/backend/__tests__/api/controllers/store.spec.ts @@ -32,8 +32,19 @@ const dummyUser = { jest.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken); const stripePriceMock = jest.spyOn(Stripe, "getPrices"); -const stripeCreateCheckout = jest.spyOn(Stripe, "createCheckout"); +const stripeCreateCheckoutMock = jest.spyOn(Stripe, "createCheckout"); +const stripeGetCheckoutMock = jest.spyOn(Stripe, "getCheckout"); +const stripeGetSubscriptionMock = jest.spyOn(Stripe, "getSubscription"); + const userGetUserMock = jest.spyOn(UserDal, "getUser"); +const userLinkCustomerByUidMock = jest.spyOn( + UserDal, + "linkStripeCustomerIdByUid" +); +const userUpdatePremiumMock = jest.spyOn( + UserDal, + "updatePremiumByStripeCustomerId" +); const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); @@ -44,8 +55,8 @@ describe("store controller test", () => { await enablePremiumFeatures(true); }); afterEach(async () => { - [stripePriceMock, stripeCreateCheckout, userGetUserMock].forEach((it) => - it.mockReset() + [stripePriceMock, stripeCreateCheckoutMock, userGetUserMock].forEach( + (it) => it.mockReset() ); }); it("should create checkout for single subscription for first time user", async () => { @@ -53,7 +64,7 @@ describe("store controller test", () => { stripePriceMock.mockResolvedValue([ { id: "price_id", type: "recurring" }, ]); - stripeCreateCheckout.mockResolvedValue("http://example.com"); + stripeCreateCheckoutMock.mockResolvedValue("http://example.com"); userGetUserMock.mockResolvedValue(dummyUser); //WHEN @@ -71,7 +82,7 @@ describe("store controller test", () => { expect(checkoutData).toHaveProperty("redirectUrl", "http://example.com"); expect(stripePriceMock).toHaveBeenCalledWith(["prime_monthly"]); - expect(stripeCreateCheckout).toHaveBeenCalledWith({ + expect(stripeCreateCheckoutMock).toHaveBeenCalledWith({ line_items: [{ price: "price_id", quantity: 1 }], billing_address_collection: "auto", success_url: @@ -85,7 +96,7 @@ describe("store controller test", () => { it("should create checkout for single one_time_payment for first time user", async () => { //GIVEN stripePriceMock.mockResolvedValue([{ id: "price_id", type: "one_time" }]); - stripeCreateCheckout.mockResolvedValue("http://example.com"); + stripeCreateCheckoutMock.mockResolvedValue("http://example.com"); userGetUserMock.mockResolvedValue(dummyUser); //WHEN @@ -103,7 +114,7 @@ describe("store controller test", () => { expect(checkoutData).toHaveProperty("redirectUrl", "http://example.com"); expect(stripePriceMock).toHaveBeenCalledWith(["prime_monthly"]); - expect(stripeCreateCheckout).toHaveBeenCalledWith({ + expect(stripeCreateCheckoutMock).toHaveBeenCalledWith({ line_items: [{ price: "price_id", quantity: 1 }], billing_address_collection: "auto", success_url: @@ -120,7 +131,7 @@ describe("store controller test", () => { stripePriceMock.mockResolvedValue([ { id: "price_id", type: "recurring" }, ]); - stripeCreateCheckout.mockResolvedValue("http://example.com"); + stripeCreateCheckoutMock.mockResolvedValue("http://example.com"); const returningUser = _.merge(dummyUser, { stripeData: { customerId: "cust_1234" }, }); @@ -141,7 +152,7 @@ describe("store controller test", () => { expect(checkoutData).toHaveProperty("redirectUrl", "http://example.com"); expect(stripePriceMock).toHaveBeenCalledWith(["prime_monthly"]); - expect(stripeCreateCheckout).toHaveBeenCalledWith({ + expect(stripeCreateCheckoutMock).toHaveBeenCalledWith({ line_items: [{ price: "price_id", quantity: 1 }], billing_address_collection: "auto", success_url: @@ -250,6 +261,121 @@ describe("store controller test", () => { }); }); }); + describe("finalizeCheckout", () => { + beforeEach(async () => { + await enablePremiumFeatures(true); + userLinkCustomerByUidMock.mockResolvedValue(); + userUpdatePremiumMock.mockResolvedValue(); + }); + afterEach(async () => { + [ + userLinkCustomerByUidMock, + userUpdatePremiumMock, + stripeGetCheckoutMock, + stripeGetSubscriptionMock, + ].forEach((it) => it.mockReset()); + }); + + it("should update premium for subscriptions", async () => { + //GIVEN + stripeGetCheckoutMock.mockResolvedValue({ + client_reference_id: uid, + customer: "customerId", + payment_status: "paid", + mode: "subscription", + subscription: "subscriptionId", + } as Stripe.Session); + stripeGetSubscriptionMock.mockResolvedValue({ + status: "active", + customer: "customerId", + start_date: 10, + current_period_end: 20, + } as Stripe.Subscription); + + //WHEN + await mockApp + .post("/store/checkouts/sessionId") + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + expect(stripeGetCheckoutMock).toHaveBeenCalledWith("sessionId"); + expect(userLinkCustomerByUidMock).toHaveBeenCalledWith(uid, "customerId"); + expect(stripeGetSubscriptionMock).toHaveBeenCalledWith("subscriptionId"); + expect(userUpdatePremiumMock).toHaveBeenCalledWith( + "customerId", + 10000, + 20000 + ); + }); + + it("should fail for mismatch user", async () => { + //the MT user in the stripe session is not the same as in the request + //GIVEN + stripeGetCheckoutMock.mockResolvedValue({ + client_reference_id: "anotherUser", + } as Stripe.Session); + + //WHEN /THEN + await mockApp + .post("/store/checkouts/theSessionId") + .set("Authorization", "Bearer 123456789") + .send() + .expect(400) + .expect(expectErrorMessage("Invalid checkout for the current user.")); + }); + it("should fail for unpaid subscriptions", async () => { + //GIVEN + stripeGetCheckoutMock.mockResolvedValue({ + client_reference_id: uid, + customer: "customerId", + payment_status: "unpaid", + } as Stripe.Session); + + //WHEN + await mockApp + .post("/store/checkouts/sessionId") + .set("Authorization", "Bearer 123456789") + .send() + .expect(500) + .expect(expectErrorMessage("Session is not paid.")); + }); + it("should fail for non subscriptions", async () => { + //GIVEN + stripeGetCheckoutMock.mockResolvedValue({ + client_reference_id: uid, + customer: "customerId", + payment_status: "paid", + mode: "payment", + } as Stripe.Session); + + //WHEN + await mockApp + .post("/store/checkouts/sessionId") + .set("Authorization", "Bearer 123456789") + .send() + .expect(500) + .expect( + expectErrorMessage("Session mode payment is not supported yet.") + ); + }); + + describe("validations", () => { + it("should fail if premium feature is disabled", async () => { + //GIVEN + await enablePremiumFeatures(false); + + //WHEN + await mockApp + .post("/store/checkouts/theSessionId") + .set("Authorization", "Bearer 123456789") + .send() + .expect(503) + .expect(expectErrorMessage("Premium is temporarily disabled.")); + }); + }); + }); }); async function enablePremiumFeatures(premium: boolean): Promise { diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index afb012e55..d9485f2e6 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -2,6 +2,7 @@ import _ from "lodash"; import { ObjectId } from "mongodb"; import { updateStreak } from "../../src/dal/user"; import * as UserDAL from "../../src/dal/user"; +import MonkeyError from "../../src/utils/error"; const mockPersonalBest = { acc: 1, @@ -781,4 +782,92 @@ describe("UserDal", () => { await expect(streak).toBe(expectedStreak); } }); + describe("linkStripeCustomerIdByUid", () => { + it("should link user by uid", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const customerId = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + + //WHEN + await UserDAL.linkStripeCustomerIdByUid(uid, customerId); + + //THEN + const readUser = await UserDAL.getUser(uid, "test"); + expect(readUser).toHaveProperty("stripeData.customerId", customerId); + }); + it("should link user by email", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const email = uid + "@example.com"; + const customerId = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, email, uid); + + //WHEN + await UserDAL.linkStripeCustomerIdByEmail(email, customerId); + + //THEN + const readUser = await UserDAL.getUser(uid, "test"); + expect(readUser).toHaveProperty("stripeData.customerId", customerId); + }); + it("should link user by uid if already linked", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const customerId = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + await UserDAL.linkStripeCustomerIdByUid(uid, customerId); + + //WHEN + await UserDAL.linkStripeCustomerIdByUid(uid, customerId); + + //THEN + const readUser = await UserDAL.getUser(uid, "test"); + expect(readUser).toHaveProperty("stripeData.customerId", customerId); + }); + + it("should fail linking user by uid if already linked with a different customerId", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const customerId = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + await UserDAL.linkStripeCustomerIdByUid(uid, "diffenentCustomerId"); + + //WHEN / THEN + await expect( + UserDAL.linkStripeCustomerIdByUid(uid, customerId) + ).rejects.toThrow(new MonkeyError(404, "Cannot link customer to user.")); + }); + it("should fail for unknown uid", async () => { + await expect( + UserDAL.linkStripeCustomerIdByUid("unknownUid", "customerId") + ).rejects.toThrow(new MonkeyError(404, "Cannot link customer to user.")); + }); + it("should fail for unknown email", async () => { + await expect( + UserDAL.linkStripeCustomerIdByEmail("unknown@example.com", "customerId") + ).rejects.toThrow(new MonkeyError(404, "Cannot link customer to user.")); + }); + }); + describe("updatePremiumByStripeCustomerId", () => { + it("should set premium by customerId", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + const customerId = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + await UserDAL.linkStripeCustomerIdByUid(uid, customerId); + + //WHEN + await UserDAL.updatePremiumByStripeCustomerId(customerId, 10, 20); + + //THEN + const readUser = await UserDAL.getUser(uid, "test"); + expect(readUser).toHaveProperty("premium.startTimestamp", 10); + expect(readUser).toHaveProperty("premium.expirationTimestamp", 20); + }); + it("should fail for unknown customerId", async () => { + await expect( + UserDAL.updatePremiumByStripeCustomerId("unknownCustomerId", 10, 20) + ).rejects.toThrow(new MonkeyError(404, "Cannot update premium info.")); + }); + }); }); diff --git a/backend/src/api/controllers/store.ts b/backend/src/api/controllers/store.ts index ba07c554e..6a9fc03a6 100644 --- a/backend/src/api/controllers/store.ts +++ b/backend/src/api/controllers/store.ts @@ -1,6 +1,5 @@ import { MonkeyResponse } from "../../utils/monkey-response"; import * as UserDal from "../../dal/user"; - import * as Stripe from "../../services/stripe"; import MonkeyError from "../../utils/error"; import { getFrontendUrl } from "../../utils/misc"; @@ -50,3 +49,58 @@ export async function createCheckout( return new MonkeyResponse("Checkout created", { redirectUrl }); } + +export async function finalizeCheckout( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const stripeSessionId = req.params.stripeSessionId; + const session = await Stripe.getCheckout(stripeSessionId); + + //check the checkout was for the current user + if (session.client_reference_id !== uid) { + throw new MonkeyError(400, "Invalid checkout for the current user."); + } + + //session must be linked to a stripe customer + await UserDal.linkStripeCustomerIdByUid(uid, session.customer as string); + + //check session payment is not pending + if ( + session.payment_status !== "paid" && + session.payment_status !== "no_payment_required" + ) { + throw new MonkeyError(500, "Session is not paid."); + } + + switch (session.mode) { + case "subscription": + await processSubscription(session.subscription as string); + break; + default: + throw new MonkeyError( + 500, + `Session mode ${session.mode} is not supported yet.` + ); + } + + return new MonkeyResponse("Checkout finalized", {}); +} + +async function processSubscription(subscriptionId: string): Promise { + const subscription = await Stripe.getSubscription(subscriptionId); + + if (subscription.status === "active") { + // + const startDate = subscription.start_date * 1000; + const endDate = subscription.current_period_end * 1000; + + await UserDal.updatePremiumByStripeCustomerId( + subscription.customer as string, + startDate, + endDate + ); + } else { + //we don't need to handle other states as premium validity is calculated based on the expirationTimestamp. + } +} diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 415f777ce..fe9280d01 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -312,6 +312,7 @@ function getRelevantUserInfo( "_id", "lastResultHashes", "note", + "stripeData", ]); } diff --git a/backend/src/api/routes/store.ts b/backend/src/api/routes/store.ts index be58edd87..8f8c809f2 100644 --- a/backend/src/api/routes/store.ts +++ b/backend/src/api/routes/store.ts @@ -10,14 +10,16 @@ import { authenticateRequest } from "../../middlewares/auth"; const router = Router(); +const validateFeatureEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.users.premium.enabled; + }, + invalidMessage: "Premium is temporarily disabled.", +}); + router.post( "/checkouts", - validateConfiguration({ - criteria: (configuration) => { - return configuration.users.premium.enabled; - }, - invalidMessage: "Premium is temporarily disabled.", - }), + validateFeatureEnabled, authenticateRequest(), validateRequest({ body: { @@ -35,5 +37,16 @@ router.post( }), asyncHandler(StoreController.createCheckout) ); +router.post( + "/checkouts/:stripeSessionId", + validateFeatureEnabled, + authenticateRequest(), + validateRequest({ + params: { + stripeSessionId: joi.string().required(), + }, + }), + asyncHandler(StoreController.finalizeCheckout) +); export default router; diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 37c542ff5..9cf158b0a 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -1068,3 +1068,59 @@ export async function logIpAddress( } await getUsersCollection().updateOne({ uid }, { $set: { ips: currentIps } }); } + +export async function linkStripeCustomerIdByUid( + uid: string, + customerId: string +): Promise { + return linkStripeCustomer(customerId, uid, undefined); +} +export async function linkStripeCustomerIdByEmail( + email: string, + customerId: string +): Promise { + return linkStripeCustomer(customerId, undefined, email); +} +async function linkStripeCustomer( + customerId: string, + uid?: string, + email?: string +): Promise { + const customerFilter = { + $or: [ + { "stripeData.customerId": null }, + { "stripeData.customerId": customerId }, + ], + }; + const userFilter = uid !== undefined ? { uid } : { email }; + const filter = { + $and: [userFilter, customerFilter], + }; + + const result = await getUsersCollection().updateOne(filter, { + $set: { stripeData: { customerId: customerId } }, + }); + + if (result.matchedCount !== 1) { + throw new MonkeyError(404, "Cannot link customer to user."); + } +} +export async function updatePremiumByStripeCustomerId( + customerId: string, + startDate: number, + endDate: number +): Promise { + const result = await getUsersCollection().updateOne( + { "stripeData.customerId": customerId }, + { + $set: { + premium: { startTimestamp: startDate, expirationTimestamp: endDate }, + }, + } + ); + + if (result.matchedCount !== 1) { + throw new MonkeyError(404, "Cannot update premium info."); + } +} +>>>>>>> 64af76859 (add finalizeCheckout) diff --git a/backend/src/services/stripe.ts b/backend/src/services/stripe.ts index b97e1a1d2..b31e5ef93 100644 --- a/backend/src/services/stripe.ts +++ b/backend/src/services/stripe.ts @@ -8,6 +8,8 @@ export type Price = { type: Stripe.Price.Type; }; export type SessionCreateParams = Stripe.Checkout.SessionCreateParams; +export type Session = Stripe.Checkout.Session; +export type Subscription = Stripe.Subscription; export async function getPrices( lookupKeys: Array @@ -28,3 +30,15 @@ export async function createCheckout( } return result.url; } + +export async function getCheckout(sessionId: string): Promise { + const session = await stripe.checkout.sessions.retrieve(sessionId); + + return session; +} +export async function getSubscription( + subscriptionId: string +): Promise { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + return subscription; +} diff --git a/frontend/src/ts/ape/endpoints/store.ts b/frontend/src/ts/ape/endpoints/store.ts index f50ec53c2..42203a5dc 100644 --- a/frontend/src/ts/ape/endpoints/store.ts +++ b/frontend/src/ts/ape/endpoints/store.ts @@ -11,4 +11,11 @@ export default class Store { return await this.httpClient.post(`${BASE_PATH}/checkouts`, { payload }); } + + async finalizeCheckout(sessionId: string): Ape.EndpointResponse { + return await this.httpClient.post( + `${BASE_PATH}/checkouts/${sessionId}`, + {} + ); + } } diff --git a/frontend/src/ts/pages/store.ts b/frontend/src/ts/pages/store.ts index b3f9b5dc4..8010d00f7 100644 --- a/frontend/src/ts/pages/store.ts +++ b/frontend/src/ts/pages/store.ts @@ -15,9 +15,6 @@ async function fill(): Promise { if (!user) return; const data = DB.getSnapshot(); - - console.log("++++ fill", { user, data }); - if (!data) return; //TODO check backend config for user.premium.enabled @@ -27,16 +24,22 @@ async function fill(): Promise { const action = urlParams.get("action"); if (action === "success") { - const sessionId = urlParams.get("session_id"); - alert("complete purchase for sessionId " + sessionId); - //TODO: call backend POST /store/checkouts/${sessionId} - //TODO: on success reload user info - //simulate - data.isPremium = true; - data.premium = { - startTimestamp: 1701471599, - expirationTimestamp: 1704149999, - }; + const sessionId = urlParams.get("session_id") || ""; //error handling? + const response = await Ape.store.finalizeCheckout(sessionId); + if (response.status >= 300) { + alert("request failed: " + response.status + " " + response.message); + return; + } + + const userData = await Ape.users.getData(); + //error handling? + data.premium = userData.data.premium; + data.isPremium = userData.data.isPremium; + window.location.href = + window.location.protocol + + "//" + + window.location.host + + window.location.pathname; } else if (action === "cancel") { alert("purchase cancelled."); } @@ -49,7 +52,7 @@ async function fill(): Promise { $("#premium_sub_cancel").attr("disabled", "disabled"); } else { premiumEndDate = new Date( - data.premium?.expirationTimestamp * 1000 + data.premium?.expirationTimestamp ).toDateString(); } $("#premium_until").html(premiumEndDate); @@ -73,6 +76,10 @@ $(".premium_sub").on("click", async (e) => { window.location.href = redirectUrl; }); +$(".premium_sub_cancel").on("click", async (e) => { + alert("cancel subscription"); +}); + export const page = new Page( "store", $(".page.pageStore"), diff --git a/frontend/static/html/pages/store.html b/frontend/static/html/pages/store.html index af8283d3d..0b83a3d92 100644 --- a/frontend/static/html/pages/store.html +++ b/frontend/static/html/pages/store.html @@ -6,7 +6,11 @@ .

- +