add finalizeCheckout

This commit is contained in:
Christian Fehmer 2023-11-26 16:23:43 +01:00
parent 2b2fedc128
commit 64af76859c
No known key found for this signature in database
GPG key ID: 7294582286D5F1F4
10 changed files with 401 additions and 31 deletions

View file

@ -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<void> {

View file

@ -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."));
});
});
});

View file

@ -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<MonkeyResponse> {
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<void> {
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.
}
}

View file

@ -312,6 +312,7 @@ function getRelevantUserInfo(
"_id",
"lastResultHashes",
"note",
"stripeData",
]);
}

View file

@ -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;

View file

@ -1047,3 +1047,58 @@ export async function checkIfUserIsPremium(uid: string): Promise<boolean> {
if (expirationDate === -1) return true; //lifetime
return expirationDate > Date.now();
}
export async function linkStripeCustomerIdByUid(
uid: string,
customerId: string
): Promise<void> {
return linkStripeCustomer(customerId, uid, undefined);
}
export async function linkStripeCustomerIdByEmail(
email: string,
customerId: string
): Promise<void> {
return linkStripeCustomer(customerId, undefined, email);
}
async function linkStripeCustomer(
customerId: string,
uid?: string,
email?: string
): Promise<void> {
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<void> {
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.");
}
}

View file

@ -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<string>
@ -28,3 +30,15 @@ export async function createCheckout(
}
return result.url;
}
export async function getCheckout(sessionId: string): Promise<Session> {
const session = await stripe.checkout.sessions.retrieve(sessionId);
return session;
}
export async function getSubscription(
subscriptionId: string
): Promise<Subscription> {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return subscription;
}

View file

@ -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}`,
{}
);
}
}

View file

@ -15,9 +15,6 @@ async function fill(): Promise<void> {
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<void> {
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<void> {
$("#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"),

View file

@ -6,7 +6,11 @@
<span id="premium_until"></span>
.
</p>
<input type="button" id="premium_sub_cancel" value="Cancel subsciption." />
<input
type="button"
class="premium_sub_cancel"
value="Cancel subsciption."
/>
</div>
<div class="premiumAvailable hidden">