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 @@
.
-
+