+
+
{membershipStatus ===
Constants.MembershipStatus.ACTIVE ||
@@ -548,7 +364,6 @@ export default function Checkout({
);
}
}}
- className="bg-black text-white hover:bg-black/90"
theme={theme.theme}
>
Go to the resource
@@ -591,13 +406,13 @@ export default function Checkout({
>
- Select Payment Plan
+ Select Your Plan
(
-
+
{paymentPlans.map(
- (plan) => (
-
-
-
-
-
-
- {
- plan.name
- }
-
-
- {getPlanDescription(
- plan,
- currencySymbol,
- )}
-
-
-
- ),
+ (plan) => {
+ const isRecommended =
+ paymentPlans.length >
+ 1 &&
+ plan.planId ===
+ product.defaultPaymentPlanId;
+
+ return (
+
+ );
+ },
)}
@@ -713,9 +529,12 @@ export default function Checkout({
>
)}
-
-
-
+
diff --git a/apps/web/components/public/payments/order-summary.tsx b/apps/web/components/public/payments/order-summary.tsx
new file mode 100644
index 000000000..56feb9bce
--- /dev/null
+++ b/apps/web/components/public/payments/order-summary.tsx
@@ -0,0 +1,262 @@
+"use client";
+
+import Image from "next/image";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { ShoppingCart, ChevronUp } from "lucide-react";
+import { PageCard, PageCardContent } from "@courselit/page-primitives";
+import { Text1, Header3, Header4 } from "@courselit/page-primitives";
+import { PaymentPlan, Constants } from "@courselit/common-models";
+import { getPlanPrice } from "@ui-lib/utils";
+import { CHECKOUT_PAGE_ORDER_SUMMARY } from "@ui-config/strings";
+
+const { PaymentPlanType: paymentPlanType } = Constants;
+
+function getPlanDescription(plan: PaymentPlan, currencySymbol: string): string {
+ if (!plan) {
+ return "N/A";
+ }
+
+ switch (plan.type) {
+ case paymentPlanType.FREE:
+ return "Free plan";
+ case paymentPlanType.ONE_TIME:
+ return `One-time payment of ${currencySymbol}${plan.oneTimeAmount?.toFixed(2)}`;
+ case paymentPlanType.SUBSCRIPTION:
+ if (plan.subscriptionYearlyAmount) {
+ return `Billed annually at ${currencySymbol}${plan.subscriptionYearlyAmount.toFixed(2)}`;
+ }
+ return `${currencySymbol}${plan.subscriptionMonthlyAmount?.toFixed(2)} per month`;
+ case paymentPlanType.EMI:
+ return `${currencySymbol}${plan.emiAmount?.toFixed(2)} per month for ${plan.emiTotalInstallments} months`;
+ default:
+ return "N/A";
+ }
+}
+
+export interface Product {
+ id: string;
+ name: string;
+ type: string;
+ defaultPaymentPlanId: string;
+ slug?: string;
+ featuredImage?: string;
+ description?: string;
+ autoAcceptMembers?: boolean;
+ joiningReasonText?: string;
+}
+
+export interface OrderSummaryProps {
+ product: Product;
+ selectedPlan: PaymentPlan | null;
+ paymentPlans: PaymentPlan[];
+ currencySymbol: string;
+ theme: any;
+ isOrderSummaryOpen: boolean;
+ setIsOrderSummaryOpen: (open: boolean) => void;
+}
+
+export function MobileOrderSummary({
+ product,
+ selectedPlan,
+ paymentPlans,
+ currencySymbol,
+ theme,
+ isOrderSummaryOpen,
+ setIsOrderSummaryOpen,
+}: OrderSummaryProps) {
+ return (
+
+
+
+
+
+
+
+
+ setIsOrderSummaryOpen(
+ !isOrderSummaryOpen,
+ )
+ }
+ theme={theme.theme}
+ >
+ {isOrderSummaryOpen ? "Hide" : "Show"} order
+ summary
+
+
+
+
+ {currencySymbol}
+ {getPlanPrice(
+ selectedPlan || paymentPlans[0],
+ ).amount.toFixed(2)}
+
+ {
+ getPlanPrice(
+ selectedPlan || paymentPlans[0],
+ ).period
+ }
+
+
+
+
+
+
+
+ Toggle order summary
+
+
+
+
+
+
+
+
+
+ {product.name}
+
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+
+
+ {selectedPlan && (
+
+
+
+ Total
+
+
+
+ {currencySymbol}
+ {getPlanPrice(
+ selectedPlan,
+ ).amount.toFixed(2)}
+
+
+ {
+ getPlanPrice(
+ selectedPlan,
+ ).period
+ }
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export function DesktopOrderSummary({
+ product,
+ selectedPlan,
+ currencySymbol,
+ theme,
+}: Omit<
+ OrderSummaryProps,
+ "paymentPlans" | "isOrderSummaryOpen" | "setIsOrderSummaryOpen"
+>) {
+ return (
+
+
+
+
+ {CHECKOUT_PAGE_ORDER_SUMMARY}
+
+
+
+
+
+
+
+ {product.name}
+
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+
+ {selectedPlan && (
+
+
+ Total
+
+ {currencySymbol}
+ {getPlanPrice(selectedPlan).amount.toFixed(
+ 2,
+ )}
+
+ {getPlanPrice(selectedPlan).period}
+
+
+
+
+ {getPlanDescription(
+ selectedPlan,
+ currencySymbol,
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/public/payments/payment-plan-card.tsx b/apps/web/components/public/payments/payment-plan-card.tsx
new file mode 100644
index 000000000..5802f7c6f
--- /dev/null
+++ b/apps/web/components/public/payments/payment-plan-card.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { RadioGroupItem } from "@/components/ui/radio-group";
+import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
+import {
+ Badge,
+ Header3,
+ PageCard,
+ PageCardContent,
+ PageCardHeader,
+ Text2,
+} from "@courselit/page-primitives";
+import { Text1 } from "@courselit/page-primitives";
+import { Star, Package } from "lucide-react";
+import { PaymentPlan, Course, Constants } from "@courselit/common-models";
+import { getPlanPrice } from "@ui-lib/utils";
+
+const { PaymentPlanType: paymentPlanType } = Constants;
+
+export interface PaymentPlanCardProps {
+ plan: PaymentPlan;
+ isSelected: boolean;
+ isRecommended: boolean;
+ isLoggedIn: boolean;
+ currencySymbol: string;
+ includedProducts: Course[];
+ theme: any;
+}
+
+function getIncludedProductsDescription(
+ plan: PaymentPlan,
+ includedProducts: Course[],
+): Course[] {
+ if (!plan) {
+ return [];
+ }
+
+ if (plan.includedProducts && plan.includedProducts.length > 0) {
+ const includedProductsList = includedProducts.filter((product) =>
+ plan.includedProducts?.includes(product.courseId),
+ );
+ return includedProductsList;
+ }
+
+ return [];
+}
+
+export function PaymentPlanCard({
+ plan,
+ isSelected,
+ isRecommended,
+ isLoggedIn,
+ currencySymbol,
+ includedProducts,
+ theme,
+}: PaymentPlanCardProps) {
+ const planPrice = getPlanPrice(plan);
+ const planIncludedProducts = getIncludedProductsDescription(
+ plan,
+ includedProducts,
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {plan.name}
+ {isRecommended && (
+
+
+ Recommended
+
+ )}
+ {/* {isRecommended && (
+
+
+ Recommended
+
+ )} */}
+
+
+ {/*
+
+
+ {plan.name}
+
+ {isRecommended && (
+
+
+ Recommended
+
+ )}
+
+
*/}
+
+
+
+
+ {currencySymbol}
+ {planPrice.amount.toFixed(2)}
+
+ {planPrice.period && (
+
+ {planPrice.period}
+
+ )}
+
+ {plan.type === paymentPlanType.ONE_TIME && (
+
+ one-time
+
+ )}
+
+
+
+ {planIncludedProducts.length > 0 && (
+
+
+
+ {planIncludedProducts.length}{" "}
+ products included
+
+
+ )}
+
+ {planIncludedProducts.length > 0 && (
+
+ {planIncludedProducts.map((product) => (
+
+ {product.title}
+
+ ))}
+
+ )}
+
+
+ {plan.description && (
+
+
+ {plan.description}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts
index a7a00356d..80b7aad5d 100644
--- a/apps/web/graphql/communities/logic.ts
+++ b/apps/web/graphql/communities/logic.ts
@@ -16,6 +16,7 @@ import {
CommunityReportType,
CommunityReportStatus,
MembershipRole,
+ PaymentPlan,
} from "@courselit/common-models";
import CommunityPostModel from "@models/CommunityPost";
import {
@@ -27,7 +28,12 @@ import CommunityCommentModel from "@models/CommunityComment";
import PageModel from "@models/Page";
import PaymentPlanModel from "@models/PaymentPlan";
import MembershipModel from "@models/Membership";
-import { getPlans } from "../paymentplans/logic";
+import {
+ addIncludedProductsMemberships,
+ deleteMembershipsActivatedViaPaymentPlan,
+ getInternalPaymentPlan,
+ getPlans,
+} from "../paymentplans/logic";
import { getPaymentMethodFromSettings } from "@/payments-new";
import CommunityReportModel, {
InternalCommunityReport,
@@ -49,6 +55,7 @@ import { addNotification } from "@/services/queue";
import { hasActiveSubscription } from "../users/logic";
import { internal } from "@config/strings";
import { hasCommunityPermission as hasPermission } from "@ui-lib/utils";
+import ActivityModel from "@models/Activity";
const { permissions, communityPage } = constants;
@@ -111,6 +118,7 @@ export async function createCommunity({
pageId,
});
+ const paymentPlan = await getInternalPaymentPlan(ctx);
await MembershipModel.create({
domain: ctx.subdomain._id,
userId: ctx.user.userId,
@@ -119,6 +127,7 @@ export async function createCommunity({
status: Constants.MembershipStatus.ACTIVE,
joiningReason: internal.joining_reason_creator,
role: Constants.MembershipRole.MODERATE,
+ paymentPlanId: paymentPlan.planId,
});
return community;
@@ -194,7 +203,8 @@ export async function getCommunities({
products: community.products,
joiningReasonText: community.joiningReasonText,
paymentPlans: await getPlans({
- planIds: community.paymentPlans,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
ctx,
}),
defaultPaymentPlan: community.defaultPaymentPlan,
@@ -297,7 +307,8 @@ export async function updateCommunity({
}
const plans = await getPlans({
- planIds: community.paymentPlans,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
ctx,
});
if (enabled !== undefined) {
@@ -427,12 +438,20 @@ export async function joinCommunity({
throw new Error(responses.item_not_found);
}
- if (community.paymentPlans.length === 0) {
+ const communityPaymentPlans = await getPlans({
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ ctx,
+ });
+
+ if (communityPaymentPlans.length === 0) {
throw new Error(responses.community_has_no_payment_plans);
}
const freePaymentPlanOfCommunity = await PaymentPlanModel.findOne({
- planId: { $in: community.paymentPlans },
+ domain: ctx.subdomain._id,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
type: Constants.PaymentPlanType.FREE,
archived: false,
});
@@ -895,10 +914,6 @@ async function formatCommunity(
pageId: community.pageId,
products: community.products,
joiningReasonText: community.joiningReasonText,
- paymentPlans: await getPlans({
- planIds: community.paymentPlans,
- ctx,
- }),
defaultPaymentPlan: community.defaultPaymentPlan,
featuredImage: community.featuredImage,
membersCount: await getMembersCount({
@@ -946,10 +961,6 @@ export async function updateMemberStatus({
}): Promise
{
checkIfAuthenticated(ctx);
- // if (!checkPermission(ctx.user.permissions, [permissions.manageCommunity])) {
- // throw new Error(responses.action_not_allowed);
- // }
-
if (ctx.user.userId === userId) {
throw new Error(responses.action_not_allowed);
}
@@ -1005,12 +1016,33 @@ export async function updateMemberStatus({
);
}
targetMember.rejectionReason = rejectionReason;
+
+ if (targetMember.paymentPlanId) {
+ await deleteMembershipsActivatedViaPaymentPlan({
+ domain: ctx.subdomain._id,
+ userId: targetMember.userId,
+ paymentPlanId: targetMember.paymentPlanId,
+ });
+ }
}
targetMember.status = nextStatus;
if (targetMember.status === Constants.MembershipStatus.ACTIVE) {
targetMember.rejectionReason = undefined;
+ const paymentPlan = (await PaymentPlanModel.findOne({
+ domain: ctx.subdomain._id,
+ planId: targetMember.paymentPlanId,
+ })) as PaymentPlan;
+
+ if (targetMember.paymentPlanId) {
+ await addIncludedProductsMemberships({
+ domain: ctx.subdomain._id,
+ userId: targetMember.userId,
+ paymentPlan,
+ sessionId: targetMember.sessionId,
+ });
+ }
await addNotification({
domain: ctx.subdomain._id.toString(),
@@ -1711,6 +1743,14 @@ export async function leaveCommunity({
await paymentMethod?.cancel(member.subscriptionId);
}
+ if (member.paymentPlanId) {
+ await deleteMembershipsActivatedViaPaymentPlan({
+ domain: ctx.subdomain._id,
+ userId: member.userId,
+ paymentPlanId: member.paymentPlanId,
+ });
+ }
+
await member.deleteOne();
return true;
@@ -1737,30 +1777,56 @@ export async function deleteCommunity({
throw new Error(responses.item_not_found);
}
- await PageModel.updateOne(
- {
- domain: ctx.subdomain._id,
- pageId: community.pageId,
- entityId: community.communityId,
- },
- {
- deleted: true,
- },
- );
-
- await CommunityModel.updateOne(
- {
- domain: ctx.subdomain._id,
- communityId: id,
- },
- {
- deleted: true,
- },
- );
+ await deleteMemberships(community, ctx);
+ await PageModel.deleteOne({
+ domain: ctx.subdomain._id,
+ pageId: community.pageId,
+ entityId: community.communityId,
+ });
+ await CommunityModel.deleteOne({
+ domain: ctx.subdomain._id,
+ communityId: id,
+ });
return await formatCommunity(community, ctx);
}
+async function deleteMemberships(
+ community: InternalCommunity,
+ ctx: GQLContext,
+) {
+ const paymentPlans = await PaymentPlanModel.find({
+ domain: ctx.subdomain._id,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ });
+
+ for (const paymentPlan of paymentPlans) {
+ if (
+ paymentPlan.includedProducts &&
+ paymentPlan.includedProducts.length > 0
+ ) {
+ await ActivityModel.deleteMany({
+ domain: ctx.subdomain._id,
+ type: constants.activityTypes[0],
+ "metadata.isIncludedInPlan": true,
+ "metadata.paymentPlanId": paymentPlan.planId,
+ });
+ await MembershipModel.deleteMany({
+ domain: ctx.subdomain._id,
+ paymentPlanId: paymentPlan.planId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ isIncludedInPlan: true,
+ });
+ }
+ await MembershipModel.deleteMany({
+ domain: ctx.subdomain._id,
+ paymentPlanId: paymentPlan.planId,
+ });
+ await paymentPlan.deleteOne();
+ }
+}
+
export async function reportCommunityContent({
communityId,
contentId,
diff --git a/apps/web/graphql/communities/types.ts b/apps/web/graphql/communities/types.ts
index 583a9b174..57822eed5 100644
--- a/apps/web/graphql/communities/types.ts
+++ b/apps/web/graphql/communities/types.ts
@@ -15,6 +15,7 @@ import userTypes from "../users/types";
import { getUser } from "../users/logic";
import GQLContext from "@models/GQLContext";
import paymentPlansTypes from "../paymentplans/types";
+import { getPlans } from "../paymentplans/logic";
const communityReportContentType = new GraphQLEnumType({
name: "CommunityReportContentType",
@@ -50,6 +51,12 @@ const community = new GraphQLObjectType({
pageId: { type: new GraphQLNonNull(GraphQLString) },
paymentPlans: {
type: new GraphQLList(paymentPlansTypes.paymentPlan),
+ resolve: (community, _, ctx: GQLContext, __) =>
+ getPlans({
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ ctx,
+ }),
},
defaultPaymentPlan: { type: GraphQLString },
featuredImage: { type: mediaTypes.mediaType },
diff --git a/apps/web/graphql/courses/helpers.ts b/apps/web/graphql/courses/helpers.ts
index ee5a60c76..56a981fc4 100644
--- a/apps/web/graphql/courses/helpers.ts
+++ b/apps/web/graphql/courses/helpers.ts
@@ -60,8 +60,15 @@ export const validateCourse = async (
courseData.type === Constants.CourseType.COURSE ||
courseData.type === Constants.CourseType.DOWNLOAD
) {
- if (courseData.published && courseData.paymentPlans.length === 0) {
- throw new Error(responses.payment_plan_required);
+ if (courseData.published) {
+ const existingPaymentPlans = await getPlans({
+ entityId: courseData.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ ctx,
+ });
+ if (existingPaymentPlans.length === 0) {
+ throw new Error(responses.payment_plan_required);
+ }
}
if (
@@ -69,7 +76,8 @@ export const validateCourse = async (
courseData.leadMagnet
) {
const paymentPlans = await getPlans({
- planIds: courseData.paymentPlans,
+ entityId: courseData.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
ctx,
});
if (
diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts
index 800c9e7c4..1fac4c502 100644
--- a/apps/web/graphql/courses/logic.ts
+++ b/apps/web/graphql/courses/logic.ts
@@ -1,23 +1,25 @@
/**
* Business logic for managing courses.
*/
-import CourseModel, { InternalCourse } from "../../models/Course";
-import UserModel, { User } from "../../models/User";
-import { responses } from "../../config/strings";
+import CourseModel from "@/models/Course";
+import { InternalCourse } from "@courselit/common-logic";
+import UserModel from "@/models/User";
+import { User } from "@courselit/common-models";
+import { responses } from "@/config/strings";
import {
checkIfAuthenticated,
validateOffset,
checkOwnershipWithoutModel,
-} from "../../lib/graphql";
-import constants from "../../config/constants";
+} from "@/lib/graphql";
+import constants from "@/config/constants";
import {
getPaginatedCoursesForAdmin,
setupBlog,
setupCourse,
validateCourse,
} from "./helpers";
-import Lesson from "../../models/Lesson";
-import GQLContext from "../../models/GQLContext";
+import Lesson from "@/models/Lesson";
+import GQLContext from "@/models/GQLContext";
import Filter from "./models/filter";
import mongoose from "mongoose";
import {
@@ -28,17 +30,21 @@ import {
Progress,
} from "@courselit/common-models";
import { deleteAllLessons } from "../lessons/logic";
-import { deleteMedia } from "../../services/medialit";
-import PageModel from "../../models/Page";
+import { deleteMedia } from "@/services/medialit";
+import PageModel from "@/models/Page";
import { getPrevNextCursor } from "../lessons/helpers";
import { checkPermission } from "@courselit/utils";
-import { error } from "../../services/logger";
-import { getPlans } from "../paymentplans/logic";
+import { error } from "@/services/logger";
+import {
+ deleteProductsFromPaymentPlans,
+ getPlans,
+} from "../paymentplans/logic";
import MembershipModel from "@models/Membership";
import { getActivities } from "../activities/logic";
import { ActivityType } from "@courselit/common-models/dist/constants";
import { verifyMandatoryTags } from "../mails/helpers";
import { Email } from "@courselit/email-editor";
+import PaymentPlanModel from "@models/PaymentPlan";
const { open, itemsPerPage, blogPostSnippetLength, permissions } = constants;
@@ -84,13 +90,14 @@ export const getCourseOrThrow = async (
};
async function formatCourse(courseId: string, ctx: GQLContext) {
- const course: InternalCourse | null = await CourseModel.findOne({
+ const course: InternalCourse | null = (await CourseModel.findOne({
courseId,
domain: ctx.subdomain._id,
- }).lean();
+ }).lean()) as unknown as InternalCourse;
const paymentPlans = await getPlans({
- planIds: course!.paymentPlans,
+ entityId: course!.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
ctx,
});
@@ -222,6 +229,20 @@ export const updateCourse = async (
export const deleteCourse = async (id: string, ctx: GQLContext) => {
const course = await getCourseOrThrow(undefined, ctx, id);
+ await MembershipModel.deleteMany({
+ domain: ctx.subdomain._id,
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ });
+ await PaymentPlanModel.deleteMany({
+ domain: ctx.subdomain._id,
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ });
+ await deleteProductsFromPaymentPlans({
+ domain: ctx.subdomain._id,
+ courseId: course.courseId,
+ });
await deleteAllLessons(course.courseId, ctx);
if (course.featuredImage) {
try {
@@ -502,7 +523,8 @@ export const getProducts = async ({
const paymentPlans =
course.type !== constants.blog
? await getPlans({
- planIds: course.paymentPlans,
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
ctx,
})
: undefined;
diff --git a/apps/web/graphql/courses/types/index.ts b/apps/web/graphql/courses/types/index.ts
index f6bdfe681..69c7550cc 100644
--- a/apps/web/graphql/courses/types/index.ts
+++ b/apps/web/graphql/courses/types/index.ts
@@ -23,6 +23,8 @@ import { getUser } from "../../users/logic";
const { lessonMetaType } = lessonTypes;
const { course, download, blog, costPaid, costEmail, costFree } = constants;
import sequenceTypes from "../../mails/types";
+import { getPlans } from "@/graphql/paymentplans/logic";
+import GQLContext from "@/models/GQLContext";
const courseStatusType = new GraphQLEnumType({
name: "CoursePrivacyType",
@@ -154,6 +156,12 @@ const courseType = new GraphQLObjectType({
firstLesson: { type: GraphQLString },
paymentPlans: {
type: new GraphQLList(paymentPlansTypes.paymentPlan),
+ resolve: (course, _, ctx: GQLContext, __) =>
+ getPlans({
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ ctx,
+ }),
},
defaultPaymentPlan: { type: GraphQLString },
sales: { type: GraphQLFloat },
diff --git a/apps/web/graphql/mails/logic.ts b/apps/web/graphql/mails/logic.ts
index 72351d47c..61a7df07c 100644
--- a/apps/web/graphql/mails/logic.ts
+++ b/apps/web/graphql/mails/logic.ts
@@ -595,19 +595,20 @@ export async function sendCourseOverMail(
email: string,
ctx: GQLContext,
): Promise {
- const course = await CourseModel.findOne({
+ const course = (await CourseModel.findOne({
courseId,
domain: ctx.subdomain._id,
published: true,
leadMagnet: true,
- }).lean();
+ }).lean()) as unknown as InternalCourse;
if (!course) {
throw new Error(responses.item_not_found);
}
const paymentPlans = await getPlans({
- planIds: course.paymentPlans,
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
ctx,
});
@@ -634,7 +635,7 @@ export async function sendCourseOverMail(
const membership = await getMembership({
domainId: ctx.subdomain._id,
- userId: dbUser.userId,
+ userId: dbUser!.userId,
entityType: Constants.MembershipEntityType.COURSE,
entityId: course.courseId,
planId: paymentPlans[0].planId,
@@ -646,7 +647,7 @@ export async function sendCourseOverMail(
return true;
}
- await createTemplateAndSendMail({ course, ctx, user: dbUser });
+ await createTemplateAndSendMail({ course, ctx, user: dbUser! });
return true;
}
diff --git a/apps/web/graphql/pages/helpers.ts b/apps/web/graphql/pages/helpers.ts
index c12eeedf7..2723b7b5c 100644
--- a/apps/web/graphql/pages/helpers.ts
+++ b/apps/web/graphql/pages/helpers.ts
@@ -5,6 +5,7 @@ import { Page } from "../../models/Page";
import { getCommunity } from "../communities/logic";
import { getCourse } from "../courses/logic";
import { generateUniqueId } from "@courselit/utils";
+import { getPlans } from "../paymentplans/logic";
export async function getPageResponse(
page: Page,
@@ -35,7 +36,13 @@ export async function getPageResponse(
courseId: course.courseId,
leadMagnet: course.leadMagnet,
defaultPaymentPlan: course.defaultPaymentPlan,
- paymentPlans: course.paymentPlans.map((p) => ({
+ paymentPlans: (
+ await getPlans({
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ ctx,
+ })
+ ).map((p) => ({
emiAmount: p.emiAmount,
emiTotalInstallments: p.emiTotalInstallments,
subscriptionMonthlyAmount: p.subscriptionMonthlyAmount,
@@ -60,7 +67,16 @@ export async function getPageResponse(
description: community.description,
communityId: community.communityId,
defaultPaymentPlan: community.defaultPaymentPlan,
- paymentPlans: community.paymentPlans.map((p) => ({
+ membersCount: community.membersCount,
+ featuredImage: community.featuredImage,
+ paymentPlans: (
+ await getPlans({
+ entityId: community.communityId,
+ entityType:
+ Constants.MembershipEntityType.COMMUNITY,
+ ctx,
+ })
+ ).map((p) => ({
emiAmount: p.emiAmount,
emiTotalInstallments: p.emiTotalInstallments,
subscriptionMonthlyAmount: p.subscriptionMonthlyAmount,
@@ -70,19 +86,10 @@ export async function getPageResponse(
name: p.name,
planId: p.planId,
})),
- membersCount: community.membersCount,
- featuredImage: community.featuredImage,
};
}
break;
}
- // const pageData =
- // page.type.toLowerCase() === constants.product
- // ? await getCourse(
- // page.entityId!,
- // ctx.subdomain._id as unknown as string,
- // )
- // : {};
const sharedWidgetsToDraftSharedWidgets = (widget) =>
widget.shared
diff --git a/apps/web/graphql/paymentplans/helpers.ts b/apps/web/graphql/paymentplans/helpers.ts
new file mode 100644
index 000000000..637a115ab
--- /dev/null
+++ b/apps/web/graphql/paymentplans/helpers.ts
@@ -0,0 +1,136 @@
+import { PaymentPlan, Constants, Course } from "@courselit/common-models";
+import { responses } from "@config/strings";
+import { getPaymentMethodFromSettings } from "@/payments-new";
+import GQLContext from "@models/GQLContext";
+import PaymentPlanModel, { InternalPaymentPlan } from "@models/PaymentPlan";
+import CourseModel from "@models/Course";
+import { ObjectId } from "mongodb";
+
+export const validatePaymentPlan = async (
+ paymentPlan: Partial,
+ settings: GQLContext["subdomain"]["settings"],
+) => {
+ if (!paymentPlan.name || paymentPlan.name.trim() === "") {
+ throw new Error("Payment plan name is required");
+ }
+
+ if (!paymentPlan.type) {
+ throw new Error("Payment plan type is required");
+ }
+
+ if (
+ paymentPlan.entityType === Constants.MembershipEntityType.COURSE &&
+ paymentPlan.includedProducts &&
+ paymentPlan.includedProducts.length > 0
+ ) {
+ throw new Error("Included products are not allowed for course");
+ }
+
+ const paymentMethod = await getPaymentMethodFromSettings(settings);
+ if (!paymentMethod && paymentPlan.type !== Constants.PaymentPlanType.FREE) {
+ throw new Error(responses.payment_info_required);
+ }
+
+ if (
+ paymentPlan.type === Constants.PaymentPlanType.ONE_TIME &&
+ !paymentPlan.oneTimeAmount
+ ) {
+ throw new Error(
+ "One-time amount is required for one-time payment plan",
+ );
+ }
+ if (
+ paymentPlan.type === Constants.PaymentPlanType.EMI &&
+ (!paymentPlan.emiAmount || !paymentPlan.emiTotalInstallments)
+ ) {
+ throw new Error(
+ "EMI amounts and total installments are required for EMI payment plan",
+ );
+ }
+ if (
+ paymentPlan.type === Constants.PaymentPlanType.SUBSCRIPTION &&
+ ((!paymentPlan.subscriptionMonthlyAmount &&
+ !paymentPlan.subscriptionYearlyAmount) ||
+ (paymentPlan.subscriptionMonthlyAmount &&
+ paymentPlan.subscriptionYearlyAmount))
+ ) {
+ throw new Error(
+ "Either monthly or yearly amount is required for subscription payment plan, but not both",
+ );
+ }
+};
+
+export const checkDuplicatePlan = async (
+ currentPlan: Partial,
+ isUpdate: boolean = false,
+) => {
+ const existingPlans = (await PaymentPlanModel.find({
+ domain: currentPlan.domain,
+ entityId: currentPlan.entityId,
+ entityType: currentPlan.entityType,
+ archived: false,
+ }).lean()) as unknown as PaymentPlan[];
+
+ const plansToCheck = isUpdate
+ ? existingPlans.filter((plan) => plan.planId !== currentPlan.planId)
+ : existingPlans;
+
+ for (const plan of plansToCheck) {
+ if (plan.type === currentPlan.type) {
+ if (plan.type !== Constants.PaymentPlanType.SUBSCRIPTION) {
+ throw new Error(responses.duplicate_payment_plan);
+ }
+ if (
+ currentPlan.subscriptionMonthlyAmount &&
+ plan.subscriptionMonthlyAmount
+ ) {
+ throw new Error(responses.duplicate_payment_plan);
+ }
+ if (
+ currentPlan.subscriptionYearlyAmount &&
+ plan.subscriptionYearlyAmount
+ ) {
+ throw new Error(responses.duplicate_payment_plan);
+ }
+ }
+ }
+};
+
+export const checkIncludedProducts = async (
+ domain: ObjectId,
+ paymentPlan: Partial,
+) => {
+ if (
+ !paymentPlan.includedProducts ||
+ paymentPlan.includedProducts?.length === 0
+ )
+ return;
+
+ const products = (await CourseModel.find(
+ {
+ domain,
+ courseId: { $in: paymentPlan.includedProducts },
+ type: {
+ $in: [
+ Constants.CourseType.COURSE,
+ Constants.CourseType.DOWNLOAD,
+ ],
+ },
+ },
+ {
+ courseId: 1,
+ },
+ ).lean()) as unknown as Course[];
+
+ let nonExistingProducts: string[] = [];
+ for (const product of paymentPlan.includedProducts) {
+ if (!products.some((p) => p.courseId === product)) {
+ nonExistingProducts.push(product);
+ }
+ }
+ if (nonExistingProducts.length > 0) {
+ throw new Error(
+ `Products ${nonExistingProducts.join(", ")} do not exist`,
+ );
+ }
+};
diff --git a/apps/web/graphql/paymentplans/logic.ts b/apps/web/graphql/paymentplans/logic.ts
index 93cf94fb2..9bd6c05a3 100644
--- a/apps/web/graphql/paymentplans/logic.ts
+++ b/apps/web/graphql/paymentplans/logic.ts
@@ -4,16 +4,25 @@ import {
MembershipEntityType,
PaymentPlan,
Constants,
- Community,
PaymentPlanType,
} from "@courselit/common-models";
-import CourseModel, { Course } from "@models/Course";
+import CourseModel from "@models/Course";
import CommunityModel, { InternalCommunity } from "@models/Community";
import constants from "@config/constants";
import { checkPermission } from "@courselit/utils";
-import PaymentPlanModel from "@models/PaymentPlan";
-import { getPaymentMethodFromSettings } from "@/payments-new";
+import PaymentPlanModel, { InternalPaymentPlan } from "@models/PaymentPlan";
import { Domain } from "@models/Domain";
+import { InternalCourse } from "@courselit/common-logic";
+import GQLContext from "@models/GQLContext";
+import {
+ checkDuplicatePlan,
+ checkIncludedProducts,
+ validatePaymentPlan,
+} from "./helpers";
+import mongoose from "mongoose";
+import MembershipModel from "@models/Membership";
+import { runPostMembershipTasks } from "../users/logic";
+import ActivityModel from "@models/Activity";
const { MembershipEntityType: membershipEntityType } = Constants;
const { permissions } = constants;
@@ -21,12 +30,12 @@ async function fetchEntity(
entityType: MembershipEntityType,
entityId: string,
ctx: any,
-): Promise {
+): Promise {
if (entityType === membershipEntityType.COURSE) {
return (await CourseModel.findOne({
domain: ctx.subdomain._id,
courseId: entityId,
- })) as Course;
+ })) as InternalCourse;
} else if (entityType === membershipEntityType.COMMUNITY) {
return (await CommunityModel.findOne({
domain: ctx.subdomain._id,
@@ -37,7 +46,10 @@ async function fetchEntity(
return null;
}
-function checkEntityPermission(entityType: MembershipEntityType, ctx: any) {
+function checkEntityManagementPermission(
+ entityType: MembershipEntityType,
+ ctx: any,
+) {
if (entityType === membershipEntityType.COURSE) {
if (
!checkPermission(ctx.user.permissions, [permissions.manageCourse])
@@ -55,19 +67,74 @@ function checkEntityPermission(entityType: MembershipEntityType, ctx: any) {
}
}
+export async function getPlan({ planId, ctx }: { planId: string; ctx: any }) {
+ checkIfAuthenticated(ctx);
+
+ const plan = await PaymentPlanModel.findOne({
+ domain: ctx.subdomain._id,
+ planId,
+ archived: false,
+ });
+
+ if (!plan) {
+ throw new Error(responses.item_not_found);
+ }
+
+ const entity = await fetchEntity(plan.entityType, plan.entityId, ctx);
+
+ if (!entity) {
+ throw new Error(responses.item_not_found);
+ }
+
+ checkEntityManagementPermission(plan.entityType, ctx);
+
+ return plan;
+}
+
export async function getPlans({
- planIds,
+ entityId,
+ entityType,
ctx,
}: {
- planIds: string[];
+ entityId: string;
+ entityType: MembershipEntityType;
ctx: any;
}): Promise {
return PaymentPlanModel.find({
domain: ctx.subdomain._id,
- planId: { $in: planIds },
+ entityId,
+ entityType,
+ archived: false,
+ internal: false,
+ }).lean() as unknown as PaymentPlan[];
+}
+
+export async function getPlansForEntity({
+ entityId,
+ entityType,
+ ctx,
+}: {
+ entityId: string;
+ entityType: MembershipEntityType;
+ ctx: any;
+}): Promise {
+ checkIfAuthenticated(ctx);
+
+ const entity = await fetchEntity(entityType, entityId, ctx);
+
+ if (!entity) {
+ throw new Error(responses.item_not_found);
+ }
+
+ checkEntityManagementPermission(entityType, ctx);
+
+ return (await PaymentPlanModel.find({
+ domain: ctx.subdomain._id,
+ entityId,
+ entityType,
archived: false,
internal: false,
- }).lean();
+ }).lean()) as unknown as PaymentPlan[];
}
export async function createPlan({
@@ -80,7 +147,9 @@ export async function createPlan({
subscriptionYearlyAmount,
entityId,
entityType,
+ description,
ctx,
+ includedProducts,
}: {
name: string;
type: PaymentPlanType;
@@ -91,71 +160,24 @@ export async function createPlan({
subscriptionYearlyAmount?: number;
entityId: string;
entityType: MembershipEntityType;
- ctx: any;
+ description?: string;
+ ctx: GQLContext;
+ includedProducts?: string[];
}): Promise {
checkIfAuthenticated(ctx);
- if (type === Constants.PaymentPlanType.ONE_TIME && !oneTimeAmount) {
- throw new Error(
- "One-time amount is required for one-time payment plan",
- );
- }
- if (
- type === Constants.PaymentPlanType.EMI &&
- (!emiAmount || !emiTotalInstallments)
- ) {
- throw new Error(
- "EMI amounts and total installments are required for EMI payment plan",
- );
- }
- if (
- type === Constants.PaymentPlanType.SUBSCRIPTION &&
- ((!subscriptionMonthlyAmount && !subscriptionYearlyAmount) ||
- (subscriptionMonthlyAmount && subscriptionYearlyAmount))
- ) {
- throw new Error(
- "Either monthly or yearly amount is required for subscription payment plan, but not both",
- );
- }
-
const entity = await fetchEntity(entityType, entityId, ctx);
-
if (!entity) {
throw new Error(responses.item_not_found);
}
- checkEntityPermission(entityType, ctx);
+ checkEntityManagementPermission(entityType, ctx);
- const existingPlansForEntity = await PaymentPlanModel.find({
- domain: ctx.subdomain._id,
- planId: { $in: (entity as Course | Community).paymentPlans },
- archived: false,
- });
-
- for (const plan of existingPlansForEntity) {
- if (plan.type === type) {
- if (plan.type !== Constants.PaymentPlanType.SUBSCRIPTION) {
- throw new Error(responses.duplicate_payment_plan);
- }
- if (subscriptionMonthlyAmount && plan.subscriptionMonthlyAmount) {
- throw new Error(responses.duplicate_payment_plan);
- }
- if (subscriptionYearlyAmount && plan.subscriptionYearlyAmount) {
- throw new Error(responses.duplicate_payment_plan);
- }
- }
- }
-
- const paymentMethod = await getPaymentMethodFromSettings(
- ctx.subdomain.settings,
- );
- if (!paymentMethod && type !== Constants.PaymentPlanType.FREE) {
- throw new Error(responses.payment_info_required);
- }
-
- const paymentPlan = await PaymentPlanModel.create({
+ const paymentPlanPayload: Partial = {
domain: ctx.subdomain._id,
userId: ctx.user.userId,
+ entityId,
+ entityType,
name,
type,
oneTimeAmount,
@@ -163,50 +185,131 @@ export async function createPlan({
emiTotalInstallments,
subscriptionMonthlyAmount,
subscriptionYearlyAmount,
- });
+ description,
+ includedProducts,
+ };
- if (entity.paymentPlans.length === 0) {
- (entity as Course | Community).defaultPaymentPlan = paymentPlan.planId;
- }
+ await validatePaymentPlan(paymentPlanPayload, ctx.subdomain.settings);
+ await checkDuplicatePlan(paymentPlanPayload);
+ await checkIncludedProducts(ctx.subdomain._id, paymentPlanPayload);
- (entity as Course | Community).paymentPlans.push(paymentPlan.planId);
+ const paymentPlan = await PaymentPlanModel.create(paymentPlanPayload);
+
+ if (!entity.defaultPaymentPlan) {
+ (entity as InternalCourse | InternalCommunity).defaultPaymentPlan =
+ paymentPlan.planId;
+ }
await (entity as any).save();
return paymentPlan;
}
-export async function archivePaymentPlan({
+export async function updatePlan({
planId,
- entityId,
- entityType,
+ name,
+ type,
+ oneTimeAmount,
+ emiAmount,
+ emiTotalInstallments,
+ subscriptionMonthlyAmount,
+ subscriptionYearlyAmount,
+ description,
ctx,
+ includedProducts,
}: {
planId: string;
- entityId: string;
- entityType: MembershipEntityType;
- ctx: any;
+ name?: string;
+ type?: PaymentPlanType;
+ oneTimeAmount?: number;
+ emiAmount?: number;
+ emiTotalInstallments?: number;
+ subscriptionMonthlyAmount?: number;
+ subscriptionYearlyAmount?: number;
+ description?: string;
+ ctx: GQLContext;
+ includedProducts?: string[];
}): Promise {
checkIfAuthenticated(ctx);
- const entity = await fetchEntity(entityType, entityId, ctx);
+ const paymentPlan = await PaymentPlanModel.findOne({
+ domain: ctx.subdomain._id,
+ planId,
+ archived: false,
+ });
+
+ if (!paymentPlan) {
+ throw new Error(responses.item_not_found);
+ }
+
+ const entity = await fetchEntity(
+ paymentPlan.entityType,
+ paymentPlan.entityId,
+ ctx,
+ );
if (!entity) {
throw new Error(responses.item_not_found);
}
- checkEntityPermission(entityType, ctx);
+ checkEntityManagementPermission(paymentPlan.entityType, ctx);
+
+ if (name !== undefined) paymentPlan.name = name;
+ if (type !== undefined) paymentPlan.type = type;
+ if (oneTimeAmount !== undefined) paymentPlan.oneTimeAmount = oneTimeAmount;
+ if (emiAmount !== undefined) paymentPlan.emiAmount = emiAmount;
+ if (emiTotalInstallments !== undefined)
+ paymentPlan.emiTotalInstallments = emiTotalInstallments;
+ if (subscriptionMonthlyAmount !== undefined)
+ paymentPlan.subscriptionMonthlyAmount = subscriptionMonthlyAmount;
+ if (subscriptionYearlyAmount !== undefined)
+ paymentPlan.subscriptionYearlyAmount = subscriptionYearlyAmount;
+ if (description !== undefined) paymentPlan.description = description;
+ if (includedProducts !== undefined)
+ paymentPlan.includedProducts = includedProducts;
+
+ await validatePaymentPlan(paymentPlan, ctx.subdomain.settings);
+ await checkDuplicatePlan(paymentPlan, true);
+ await checkIncludedProducts(ctx.subdomain._id, paymentPlan);
+
+ await paymentPlan.save();
+
+ return paymentPlan;
+}
+
+export async function archivePaymentPlan({
+ planId,
+ ctx,
+}: {
+ planId: string;
+ ctx: any;
+}): Promise {
+ checkIfAuthenticated(ctx);
const paymentPlan = await PaymentPlanModel.findOne({
domain: ctx.subdomain._id,
planId,
+ archived: false,
});
if (!paymentPlan) {
throw new Error(responses.item_not_found);
}
+ const entity = await fetchEntity(
+ paymentPlan.entityType,
+ paymentPlan.entityId,
+ ctx,
+ );
+
+ if (!entity) {
+ throw new Error(responses.item_not_found);
+ }
+
+ checkEntityManagementPermission(paymentPlan.entityType, ctx);
+
if (
- (entity as Community | Course).defaultPaymentPlan === paymentPlan.planId
+ (entity as InternalCommunity | InternalCourse).defaultPaymentPlan ===
+ paymentPlan.planId
) {
throw new Error(responses.default_payment_plan_cannot_be_archived);
}
@@ -236,7 +339,7 @@ export async function changeDefaultPlan({
throw new Error(responses.item_not_found);
}
- checkEntityPermission(entityType, ctx);
+ checkEntityManagementPermission(entityType, ctx);
const paymentPlan = await PaymentPlanModel.findOne({
domain: ctx.subdomain._id,
@@ -248,7 +351,8 @@ export async function changeDefaultPlan({
throw new Error(responses.item_not_found);
}
- (entity as Community | Course).defaultPaymentPlan = paymentPlan.planId;
+ (entity as InternalCommunity | InternalCourse).defaultPaymentPlan =
+ paymentPlan.planId;
await (entity as any).save();
return paymentPlan;
@@ -271,5 +375,112 @@ export async function createInternalPaymentPlan(
type: Constants.PaymentPlanType.FREE,
internal: true,
userId: userId,
+ entityId: "internal",
+ entityType: membershipEntityType.COURSE,
+ });
+}
+
+export async function getIncludedProducts({
+ entityId,
+ entityType,
+ ctx,
+}: {
+ entityId: string;
+ entityType: MembershipEntityType;
+ ctx: GQLContext;
+}) {
+ const paymentPlans = (await PaymentPlanModel.find(
+ {
+ domain: ctx.subdomain._id,
+ entityId,
+ entityType,
+ archived: false,
+ },
+ {
+ includedProducts: 1,
+ },
+ ).lean()) as unknown as PaymentPlan[];
+
+ const allIncludedProducts = paymentPlans.flatMap(
+ (plan) => plan.includedProducts || [],
+ );
+
+ const products = (await CourseModel.find({
+ domain: ctx.subdomain._id,
+ courseId: { $in: allIncludedProducts },
+ published: true,
+ }).lean()) as unknown as InternalCourse[];
+
+ return products;
+}
+
+export async function addIncludedProductsMemberships({
+ domain,
+ userId,
+ paymentPlan,
+ sessionId,
+}: {
+ domain: mongoose.Types.ObjectId;
+ userId: string;
+ paymentPlan: PaymentPlan;
+ sessionId: string;
+}) {
+ const courses = await CourseModel.find({
+ domain,
+ courseId: { $in: paymentPlan.includedProducts },
+ published: true,
});
+
+ for (const course of courses) {
+ const membership = await MembershipModel.create({
+ domain,
+ userId,
+ entityId: course.courseId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ paymentPlanId: paymentPlan.planId,
+ status: Constants.MembershipStatus.ACTIVE,
+ sessionId,
+ isIncludedInPlan: true,
+ });
+
+ await runPostMembershipTasks({ domain, membership, paymentPlan });
+ }
+}
+
+export async function deleteMembershipsActivatedViaPaymentPlan({
+ domain,
+ userId,
+ paymentPlanId,
+}: {
+ domain: mongoose.Types.ObjectId;
+ userId: string;
+ paymentPlanId: string;
+}) {
+ await ActivityModel.deleteMany({
+ domain,
+ userId,
+ type: constants.activityTypes[0],
+ "metadata.isIncludedInPlan": true,
+ "metadata.paymentPlanId": paymentPlanId,
+ });
+ await MembershipModel.deleteMany({
+ domain,
+ userId,
+ paymentPlanId: paymentPlanId,
+ entityType: Constants.MembershipEntityType.COURSE,
+ isIncludedInPlan: true,
+ });
+}
+
+export async function deleteProductsFromPaymentPlans({
+ domain,
+ courseId,
+}: {
+ domain: mongoose.Types.ObjectId;
+ courseId: string;
+}) {
+ await PaymentPlanModel.updateMany(
+ { domain, includedProducts: { $in: [courseId] } },
+ { $pull: { includedProducts: courseId } },
+ );
}
diff --git a/apps/web/graphql/paymentplans/mutation.ts b/apps/web/graphql/paymentplans/mutation.ts
index 0cf9a71a2..c8894eaaa 100644
--- a/apps/web/graphql/paymentplans/mutation.ts
+++ b/apps/web/graphql/paymentplans/mutation.ts
@@ -1,6 +1,16 @@
-import { GraphQLInt, GraphQLNonNull, GraphQLString } from "graphql";
+import {
+ GraphQLInt,
+ GraphQLList,
+ GraphQLNonNull,
+ GraphQLString,
+} from "graphql";
import types from "./types";
-import { archivePaymentPlan, changeDefaultPlan, createPlan } from "./logic";
+import {
+ archivePaymentPlan,
+ changeDefaultPlan,
+ createPlan,
+ updatePlan,
+} from "./logic";
import {
MembershipEntityType,
PaymentPlanType,
@@ -22,6 +32,8 @@ const mutations = {
emiTotalInstallments: { type: GraphQLInt },
subscriptionMonthlyAmount: { type: GraphQLInt },
subscriptionYearlyAmount: { type: GraphQLInt },
+ description: { type: GraphQLString },
+ includedProducts: { type: new GraphQLList(GraphQLString) },
},
resolve: async (
_: any,
@@ -35,6 +47,8 @@ const mutations = {
subscriptionYearlyAmount,
entityId,
entityType,
+ description,
+ includedProducts,
}: {
name: string;
type: PaymentPlanType;
@@ -45,6 +59,8 @@ const mutations = {
subscriptionYearlyAmount?: number;
entityId: string;
entityType: MembershipEntityType;
+ description?: string;
+ includedProducts?: string[];
},
ctx: any,
) =>
@@ -58,31 +74,73 @@ const mutations = {
subscriptionYearlyAmount,
entityId,
entityType,
+ description,
ctx,
+ includedProducts,
}),
},
- archivePlan: {
+ updatePlan: {
type: types.paymentPlan,
args: {
planId: { type: new GraphQLNonNull(GraphQLString) },
- entityId: { type: new GraphQLNonNull(GraphQLString) },
- entityType: {
- type: new GraphQLNonNull(userTypes.membershipEntityType),
- },
+ name: { type: GraphQLString },
+ type: { type: types.paymentPlanType },
+ oneTimeAmount: { type: GraphQLInt },
+ emiAmount: { type: GraphQLInt },
+ emiTotalInstallments: { type: GraphQLInt },
+ subscriptionMonthlyAmount: { type: GraphQLInt },
+ subscriptionYearlyAmount: { type: GraphQLInt },
+ description: { type: GraphQLString },
+ includedProducts: { type: new GraphQLList(GraphQLString) },
},
resolve: async (
_: any,
{
planId,
- entityId,
- entityType,
+ name,
+ type,
+ oneTimeAmount,
+ emiAmount,
+ emiTotalInstallments,
+ subscriptionMonthlyAmount,
+ subscriptionYearlyAmount,
+ description,
+ includedProducts,
}: {
planId: string;
- entityId: string;
- entityType: MembershipEntityType;
+ type?: PaymentPlanType;
+ name?: string;
+ oneTimeAmount?: number;
+ emiAmount?: number;
+ emiTotalInstallments?: number;
+ subscriptionMonthlyAmount?: number;
+ subscriptionYearlyAmount?: number;
+ description?: string;
+ includedProducts?: string[];
},
ctx: any,
- ) => archivePaymentPlan({ planId, entityId, entityType, ctx }),
+ ) =>
+ updatePlan({
+ planId,
+ name,
+ type,
+ oneTimeAmount,
+ emiAmount,
+ emiTotalInstallments,
+ subscriptionMonthlyAmount,
+ subscriptionYearlyAmount,
+ description,
+ ctx,
+ includedProducts,
+ }),
+ },
+ archivePlan: {
+ type: types.paymentPlan,
+ args: {
+ planId: { type: new GraphQLNonNull(GraphQLString) },
+ },
+ resolve: async (_: any, { planId }: { planId: string }, ctx: any) =>
+ archivePaymentPlan({ planId, ctx }),
},
changeDefaultPlan: {
type: types.paymentPlan,
diff --git a/apps/web/graphql/paymentplans/query.ts b/apps/web/graphql/paymentplans/query.ts
index e1ba762d4..58115fda7 100644
--- a/apps/web/graphql/paymentplans/query.ts
+++ b/apps/web/graphql/paymentplans/query.ts
@@ -1,3 +1,60 @@
-const queries = {};
+import { GraphQLList, GraphQLNonNull, GraphQLString } from "graphql";
+import types from "./types";
+import GQLContext from "../../models/GQLContext";
+import { getIncludedProducts, getPlan, getPlansForEntity } from "./logic";
+import { MembershipEntityType } from "@courselit/common-models";
+import userTypes from "../users/types";
+import courseTypes from "../courses/types";
+
+const queries = {
+ getPaymentPlan: {
+ type: types.paymentPlan,
+ args: {
+ id: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ },
+ resolve: (_: any, { id }: { id: string }, context: GQLContext) =>
+ getPlan({ planId: id, ctx: context }),
+ },
+ getPaymentPlans: {
+ type: new GraphQLList(types.paymentPlan),
+ args: {
+ entityId: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ entityType: {
+ type: new GraphQLNonNull(userTypes.membershipEntityType),
+ },
+ },
+ resolve: (
+ _: any,
+ {
+ entityId,
+ entityType,
+ }: { entityId: string; entityType: MembershipEntityType },
+ context: GQLContext,
+ ) => getPlansForEntity({ entityId, entityType, ctx: context }),
+ },
+ getIncludedProducts: {
+ type: new GraphQLList(courseTypes.courseType),
+ args: {
+ entityId: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ entityType: {
+ type: new GraphQLNonNull(userTypes.membershipEntityType),
+ },
+ },
+ resolve: (
+ _: any,
+ {
+ entityId,
+ entityType,
+ }: { entityId: string; entityType: MembershipEntityType },
+ context: GQLContext,
+ ) => getIncludedProducts({ entityId, entityType, ctx: context }),
+ },
+};
export default queries;
diff --git a/apps/web/graphql/paymentplans/types.ts b/apps/web/graphql/paymentplans/types.ts
index 3631ef7bc..52417c616 100644
--- a/apps/web/graphql/paymentplans/types.ts
+++ b/apps/web/graphql/paymentplans/types.ts
@@ -2,11 +2,13 @@ import {
GraphQLEnumType,
GraphQLFloat,
GraphQLInt,
+ GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from "graphql";
import { Constants } from "@courselit/common-models";
+import userTypes from "../users/types";
const { PaymentPlanType } = Constants;
const paymentPlanType = new GraphQLEnumType({
@@ -25,11 +27,17 @@ const paymentPlan = new GraphQLObjectType({
planId: { type: new GraphQLNonNull(GraphQLString) },
name: { type: new GraphQLNonNull(GraphQLString) },
type: { type: new GraphQLNonNull(paymentPlanType) },
+ entityId: { type: new GraphQLNonNull(GraphQLString) },
+ entityType: {
+ type: new GraphQLNonNull(userTypes.membershipEntityType),
+ },
oneTimeAmount: { type: GraphQLFloat },
emiAmount: { type: GraphQLInt },
emiTotalInstallments: { type: GraphQLInt },
subscriptionMonthlyAmount: { type: GraphQLInt },
subscriptionYearlyAmount: { type: GraphQLInt },
+ description: { type: GraphQLString },
+ includedProducts: { type: new GraphQLList(GraphQLString) },
},
});
diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts
index a5d113695..b06cded57 100644
--- a/apps/web/graphql/users/logic.ts
+++ b/apps/web/graphql/users/logic.ts
@@ -1,16 +1,17 @@
-import UserModel, { User } from "../../models/User";
-import { responses } from "../../config/strings";
-import {
- makeModelTextSearchable,
- checkIfAuthenticated,
-} from "../../lib/graphql";
-import constants from "../../config/constants";
-import GQLContext from "../../models/GQLContext";
+"use server";
+
+import UserModel from "@models/User";
+import { responses } from "@/config/strings";
+import { makeModelTextSearchable, checkIfAuthenticated } from "@/lib/graphql";
+import constants from "@/config/constants";
+import GQLContext from "@/models/GQLContext";
const { permissions } = constants;
import { initMandatoryPages } from "../pages/logic";
-import { Domain } from "../../models/Domain";
+import { Domain } from "@models/Domain";
import { checkPermission } from "@courselit/utils";
-import UserSegmentModel, { UserSegment } from "../../models/UserSegment";
+import UserSegmentModel from "@models/UserSegment";
+import { InternalCourse, UserSegment } from "@courselit/common-logic";
+import { User } from "@courselit/common-models";
import mongoose from "mongoose";
import {
Constants,
@@ -18,11 +19,13 @@ import {
Membership,
MembershipEntityType,
MembershipStatus,
+ PaymentPlan,
Progress,
UserFilterWithAggregator,
+ type Event,
} from "@courselit/common-models";
-import { recordActivity } from "../../lib/record-activity";
-import { triggerSequences } from "../../lib/trigger-sequences";
+import { recordActivity } from "@/lib/record-activity";
+import { triggerSequences } from "@/lib/trigger-sequences";
import { getCourseOrThrow } from "../courses/logic";
import pug from "pug";
import courseEnrollTemplate from "@/templates/course-enroll";
@@ -42,8 +45,11 @@ import {
convertFiltersToDBConditions,
InternalMembership,
} from "@courselit/common-logic";
+import { getPlanPrice } from "@courselit/utils";
-const removeAdminFieldsFromUserObject = (user: User) => ({
+const removeAdminFieldsFromUserObject = (
+ user: User & { _id: mongoose.Types.ObjectId },
+) => ({
id: user._id,
name: user.name,
userId: user.userId,
@@ -53,7 +59,7 @@ const removeAdminFieldsFromUserObject = (user: User) => ({
});
export const getUser = async (userId = null, ctx: GQLContext) => {
- let user: User | undefined | null;
+ let user: (User & { _id: mongoose.Types.ObjectId }) | undefined | null;
user = ctx.user;
if (userId) {
@@ -168,7 +174,6 @@ export const inviteCustomer = async (
}
const paymentPlan = await getInternalPaymentPlan(ctx);
-
const membership = await getMembership({
domainId: ctx.subdomain._id,
userId: user.userId,
@@ -626,6 +631,16 @@ async function getUserContentInternal(ctx: GQLContext, user: User) {
for (const membership of memberships) {
if (membership.entityType === Constants.MembershipEntityType.COURSE) {
+ const distinctCourse = content.some(
+ (item: any) =>
+ item.entityType === Constants.MembershipEntityType.COURSE &&
+ item.entity.id === membership.entityId,
+ );
+
+ if (distinctCourse) {
+ continue;
+ }
+
const course = await CourseModel.findOne({
courseId: membership.entityId,
domain: ctx.subdomain._id,
@@ -707,9 +722,9 @@ export const hasActiveSubscription = async (
ctx.subdomain.settings,
member.subscriptionMethod,
);
- const isSubscriptionActive = await paymentMethod.validateSubscription(
- member.subscriptionId,
- );
+ const isSubscriptionActive = paymentMethod
+ ? await paymentMethod.validateSubscription(member.subscriptionId)
+ : false;
return isSubscriptionActive;
};
@@ -748,3 +763,93 @@ export const getMembership = async ({
return membership;
};
+
+export async function runPostMembershipTasks({
+ domain,
+ membership,
+ paymentPlan,
+}: {
+ domain: mongoose.Types.ObjectId;
+ membership: Membership;
+ paymentPlan: PaymentPlan;
+}) {
+ const user = await UserModel.findOne({ userId: membership.userId });
+ if (!user) {
+ return;
+ }
+
+ let event: Event | undefined = undefined;
+ if (
+ paymentPlan.type !== Constants.PaymentPlanType.FREE &&
+ !membership.isIncludedInPlan
+ ) {
+ await recordActivity({
+ domain,
+ userId: user.userId,
+ type: constants.activityTypes[1],
+ entityId: membership.entityId,
+ metadata: {
+ cost: getPlanPrice(paymentPlan).amount,
+ purchaseId: membership.sessionId,
+ },
+ });
+ }
+ if (membership.entityType === Constants.MembershipEntityType.COMMUNITY) {
+ await recordActivity({
+ domain,
+ userId: user.userId,
+ type: constants.activityTypes[15],
+ entityId: membership.entityId,
+ });
+
+ event = Constants.EventType.COMMUNITY_JOINED as unknown as Event;
+ }
+ if (membership.entityType === Constants.MembershipEntityType.COURSE) {
+ const product = await CourseModel.findOne({
+ courseId: membership.entityId,
+ });
+ if (product) {
+ await addProductToUser({
+ user,
+ product,
+ });
+ }
+ await recordActivity({
+ domain,
+ userId: user.userId,
+ type: constants.activityTypes[0],
+ entityId: membership.entityId,
+ metadata: {
+ isIncludedInPlan: true,
+ paymentPlanId: paymentPlan.planId,
+ },
+ });
+
+ event = Constants.EventType.PRODUCT_PURCHASED as unknown as Event;
+ }
+
+ if (event) {
+ await triggerSequences({ user, event, data: membership.entityId });
+ }
+}
+
+async function addProductToUser({
+ user,
+ product,
+}: {
+ user: User;
+ product: InternalCourse;
+}) {
+ if (
+ !user.purchases.some(
+ (purchase: Progress) => purchase.courseId === product.courseId,
+ )
+ ) {
+ user.purchases.push({
+ courseId: product.courseId,
+ completedLessons: [],
+ accessibleGroups: [],
+ });
+ await (user as any).save();
+ }
+}
diff --git a/apps/web/hooks/use-community.ts b/apps/web/hooks/use-community.ts
index a8b306dae..df38a40e7 100644
--- a/apps/web/hooks/use-community.ts
+++ b/apps/web/hooks/use-community.ts
@@ -37,11 +37,14 @@ export const useCommunity = (id?: string | null) => {
planId
name
type
+ entityId
+ entityType
oneTimeAmount
emiAmount
emiTotalInstallments
subscriptionMonthlyAmount
subscriptionYearlyAmount
+ includedProducts
}
defaultPaymentPlan
featuredImage {
@@ -73,6 +76,11 @@ export const useCommunity = (id?: string | null) => {
}
} catch (err: any) {
setError(err.message);
+ // toast({
+ // title: TOAST_TITLE_ERROR,
+ // description: err.message,
+ // variant: "destructive",
+ // });
} finally {
setLoaded(true);
}
diff --git a/apps/web/hooks/use-payment-plan-operations.ts b/apps/web/hooks/use-payment-plan-operations.ts
index d5213110c..5268794fc 100644
--- a/apps/web/hooks/use-payment-plan-operations.ts
+++ b/apps/web/hooks/use-payment-plan-operations.ts
@@ -31,6 +31,8 @@ export function usePaymentPlanOperations({
$emiTotalInstallments: Int
$subscriptionMonthlyAmount: Int
$subscriptionYearlyAmount: Int
+ $description: String
+ $includedProducts: [String]
) {
plan: createPlan(
name: $name
@@ -42,6 +44,8 @@ export function usePaymentPlanOperations({
emiTotalInstallments: $emiTotalInstallments
subscriptionMonthlyAmount: $subscriptionMonthlyAmount
subscriptionYearlyAmount: $subscriptionYearlyAmount
+ description: $description
+ includedProducts: $includedProducts
) {
planId
name
@@ -51,6 +55,8 @@ export function usePaymentPlanOperations({
emiTotalInstallments
subscriptionMonthlyAmount
subscriptionYearlyAmount
+ description
+ includedProducts
}
}
`;
@@ -76,8 +82,8 @@ export function usePaymentPlanOperations({
const onPlanArchived = async (planId: string) => {
const query = `
- mutation ArchivePlan($planId: String!, $entityId: String!, $entityType: MembershipEntityType!) {
- plan: archivePlan(planId: $planId, entityId: $entityId, entityType: $entityType) {
+ mutation ArchivePlan($planId: String!) {
+ plan: archivePlan(planId: $planId) {
planId
name
type
@@ -86,6 +92,7 @@ export function usePaymentPlanOperations({
emiTotalInstallments
subscriptionMonthlyAmount
subscriptionYearlyAmount
+ description
}
}
`;
@@ -96,8 +103,6 @@ export function usePaymentPlanOperations({
query,
variables: {
planId,
- entityId: id,
- entityType: entityType.toUpperCase(),
},
})
.setIsGraphQLEndpoint(true)
@@ -137,6 +142,64 @@ export function usePaymentPlanOperations({
return response.plan;
};
+ const onPlanUpdated = async (plan: any) => {
+ const query = `
+ mutation UpdatePlan(
+ $planId: String!
+ $name: String
+ $type: PaymentPlanType
+ $oneTimeAmount: Int
+ $emiAmount: Int
+ $emiTotalInstallments: Int
+ $subscriptionMonthlyAmount: Int
+ $subscriptionYearlyAmount: Int
+ $description: String
+ $includedProducts: [String]
+ ) {
+ plan: updatePlan(
+ planId: $planId
+ name: $name
+ type: $type
+ oneTimeAmount: $oneTimeAmount
+ emiAmount: $emiAmount
+ emiTotalInstallments: $emiTotalInstallments
+ subscriptionMonthlyAmount: $subscriptionMonthlyAmount
+ subscriptionYearlyAmount: $subscriptionYearlyAmount
+ description: $description
+ includedProducts: $includedProducts
+ ) {
+ planId
+ name
+ type
+ oneTimeAmount
+ emiAmount
+ emiTotalInstallments
+ subscriptionMonthlyAmount
+ subscriptionYearlyAmount
+ description
+ includedProducts
+ }
+ }
+ `;
+
+ const fetchRequest = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query,
+ variables: {
+ planId: plan.planId,
+ ...plan,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ const response = await fetchRequest.exec();
+ if (response.plan) {
+ setPaymentPlans([...paymentPlans, response.plan]);
+ }
+ return response.plan;
+ };
+
return {
paymentPlans,
setPaymentPlans,
@@ -145,5 +208,6 @@ export function usePaymentPlanOperations({
onPlanSubmitted,
onPlanArchived,
onDefaultPlanChanged,
+ onPlanUpdated,
};
}
diff --git a/apps/web/hooks/use-paymentplan.ts b/apps/web/hooks/use-paymentplan.ts
new file mode 100644
index 000000000..4dbb8fb8e
--- /dev/null
+++ b/apps/web/hooks/use-paymentplan.ts
@@ -0,0 +1,77 @@
+import { useContext, useEffect, useState } from "react";
+import { AddressContext } from "@components/contexts";
+import { FetchBuilder } from "@courselit/utils";
+import { MembershipEntityType, PaymentPlan } from "@courselit/common-models";
+import { TOAST_TITLE_ERROR } from "@ui-config/strings";
+import { useToast } from "@courselit/components-library";
+
+export const usePaymentPlan = (
+ id?: string | null,
+ entityId?: string | null,
+ entityType?: MembershipEntityType,
+) => {
+ const [paymentPlan, setPaymentPlan] = useState(null);
+ const address = useContext(AddressContext);
+ const [error, setError] = useState(null);
+ const [loaded, setLoaded] = useState(false);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (!id || !entityId || !entityType) {
+ return;
+ }
+
+ const loadPaymentPlan = async () => {
+ const query = `
+ query ($id: String!) {
+ paymentPlan: getPaymentPlan(id: $id) {
+ planId
+ name
+ type
+ entityId
+ entityType
+ oneTimeAmount
+ emiAmount
+ emiTotalInstallments
+ subscriptionMonthlyAmount
+ subscriptionYearlyAmount
+ description
+ includedProducts
+ }
+ }
+ `;
+ const fetch = new FetchBuilder()
+ .setUrl(`${address.backend}/api/graph`)
+ .setPayload({
+ query,
+ variables: {
+ id,
+ },
+ })
+ .setIsGraphQLEndpoint(true)
+ .build();
+ try {
+ const response = await fetch.exec();
+ if (response.paymentPlan) {
+ setPaymentPlan(response.paymentPlan);
+ }
+ if (response.error) {
+ setError(response.error);
+ }
+ } catch (err: any) {
+ setError(err.message);
+ toast({
+ title: TOAST_TITLE_ERROR,
+ description: err.message,
+ variant: "destructive",
+ });
+ } finally {
+ setLoaded(true);
+ }
+ };
+
+ loadPaymentPlan();
+ }, [address.backend, id, entityId, entityType]);
+
+ return { paymentPlan, error, loaded, setPaymentPlan };
+};
diff --git a/apps/web/hooks/use-product.ts b/apps/web/hooks/use-product.ts
index b0cf17f01..425bf2a67 100644
--- a/apps/web/hooks/use-product.ts
+++ b/apps/web/hooks/use-product.ts
@@ -1,9 +1,9 @@
-import { Address, Course } from "@courselit/common-models";
+import { Course } from "@courselit/common-models";
import { Lesson } from "@courselit/common-models";
import { useToast } from "@courselit/components-library";
-import { TOAST_TITLE_ERROR } from "@ui-config/strings";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useContext, useEffect, useState } from "react";
import { useGraphQLFetch } from "./use-graphql-fetch";
+import { AddressContext } from "@components/contexts";
export type ProductWithAdminProps = Partial<
Omit &
@@ -13,16 +13,17 @@ export type ProductWithAdminProps = Partial<
}
>;
-export default function useProduct(
- id: string,
- address: Address,
-): { product: ProductWithAdminProps | undefined | null; loaded: boolean } {
+export default function useProduct(id?: string | null): {
+ product: ProductWithAdminProps | undefined | null;
+ loaded: boolean;
+} {
const [product, setProduct] = useState<
ProductWithAdminProps | undefined | null
>();
const { toast } = useToast();
const [loaded, setLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
+ const address = useContext(AddressContext);
const fetch = useGraphQLFetch();
const loadProduct = useCallback(
@@ -94,6 +95,9 @@ export default function useProduct(
emiTotalInstallments
subscriptionMonthlyAmount
subscriptionYearlyAmount
+ entityId
+ entityType
+ includedProducts
}
leadMagnet
defaultPaymentPlan
@@ -111,11 +115,11 @@ export default function useProduct(
} catch (err: any) {
setHasError(true);
setProduct(null);
- toast({
- title: TOAST_TITLE_ERROR,
- description: err.message,
- variant: "destructive",
- });
+ // toast({
+ // title: TOAST_TITLE_ERROR,
+ // description: err.message,
+ // variant: "destructive",
+ // });
} finally {
setLoaded(true);
}
diff --git a/apps/web/hooks/use-products.ts b/apps/web/hooks/use-products.ts
index c16fb1457..8c9001629 100644
--- a/apps/web/hooks/use-products.ts
+++ b/apps/web/hooks/use-products.ts
@@ -1,7 +1,6 @@
-import { AddressContext } from "@components/contexts";
import { Course } from "@courselit/common-models";
-import { FetchBuilder } from "@courselit/utils";
-import { useState, useEffect, useContext } from "react";
+import { useState, useEffect } from "react";
+import { useGraphQLFetch } from "./use-graphql-fetch";
export function useProducts(
page: number,
@@ -12,11 +11,7 @@ export function useProducts(
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [totalPages, setTotalPages] = useState(1);
- const address = useContext(AddressContext);
-
- const fetch = new FetchBuilder()
- .setUrl(`${address.backend}/api/graph`)
- .setIsGraphQLEndpoint(true);
+ const fetch = useGraphQLFetch();
useEffect(() => {
const fetchProducts = async () => {
@@ -81,7 +76,7 @@ export function useProducts(
};
fetchProducts();
- }, [page, itemsPerPage, filter, address.backend, publicView]);
+ }, [page, itemsPerPage, filter, publicView, fetch]);
return { products, loading, totalPages };
}
diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts
index a3a3a1f2b..207322052 100644
--- a/apps/web/jest.config.ts
+++ b/apps/web/jest.config.ts
@@ -31,6 +31,8 @@ const config: Config = {
slugify: "/__mocks__/slugify.ts",
"@models/(.*)": "/models/$1",
"@/auth": "/auth.ts",
+ "@/payments-new": "/payments-new",
+ "@/graphql/(.*)": "/graphql/$1",
},
};
diff --git a/apps/web/lib/finalize-purchase.ts.notused b/apps/web/lib/finalize-purchase.ts.notused
deleted file mode 100644
index 0d5dfd8d5..000000000
--- a/apps/web/lib/finalize-purchase.ts.notused
+++ /dev/null
@@ -1,59 +0,0 @@
-import CourseModel, { Course } from "../models/Course";
-import UserModel, { User } from "../models/User";
-import { triggerSequences } from "./trigger-sequences";
-import { recordActivity } from "./record-activity";
-import { Constants, Progress } from "@courselit/common-models";
-
-async function finalizePurchase(
- userId: string,
- courseId: string,
- purchaseId?: string,
-) {
- const user: User | null = await UserModel.findOne({ userId });
- const course: Course | null = await CourseModel.findOne({ courseId });
-
- if (
- user &&
- course &&
- !user.purchases.some(
- (purchase: Progress) => purchase.courseId === course.courseId,
- )
- ) {
- user.purchases.push({
- courseId: course.courseId,
- completedLessons: [],
- accessibleGroups: [],
- });
- await (user as any).save();
- if (!course.customers.some((customer) => customer === user.userId)) {
- course.customers.push(user.userId);
- course.sales += course.cost;
- await (course as any).save();
- }
- await triggerSequences({
- user,
- event: Constants.eventTypes[2],
- data: course.courseId,
- });
- await recordActivity({
- domain: user.domain,
- userId: user.userId,
- type: "enrolled",
- entityId: course.courseId,
- });
- if (course.cost > 0) {
- await recordActivity({
- domain: user.domain,
- userId: user.userId,
- type: "purchased",
- entityId: course.courseId,
- metadata: {
- cost: course.cost,
- purchaseId,
- },
- });
- }
- }
-}
-
-export default finalizePurchase;
diff --git a/apps/web/models/Community.ts b/apps/web/models/Community.ts
index d2e9ba774..a770e1c91 100644
--- a/apps/web/models/Community.ts
+++ b/apps/web/models/Community.ts
@@ -7,7 +7,6 @@ export interface InternalCommunity extends Omit {
domain: mongoose.Types.ObjectId;
createdAt: Date;
updatedAt: Date;
- paymentPlans: string[];
deleted: boolean;
}
@@ -28,7 +27,7 @@ const CommunitySchema = new mongoose.Schema(
autoAcceptMembers: { type: Boolean, default: false },
joiningReasonText: { type: String },
pageId: { type: String, required: true },
- paymentPlans: [String],
+ // paymentPlans: [String],
defaultPaymentPlan: { type: String },
featuredImage: MediaSchema,
deleted: { type: Boolean, default: false },
diff --git a/apps/web/models/PaymentPlan.ts b/apps/web/models/PaymentPlan.ts
index a43da214d..554fa8112 100644
--- a/apps/web/models/PaymentPlan.ts
+++ b/apps/web/models/PaymentPlan.ts
@@ -24,14 +24,22 @@ const PaymentPlanSchema = new mongoose.Schema(
required: true,
enum: Object.values(Constants.PaymentPlanType),
},
+ entityId: { type: String, required: true },
+ entityType: {
+ type: String,
+ required: true,
+ enum: Object.values(Constants.MembershipEntityType),
+ },
+ userId: { type: String, required: true },
oneTimeAmount: { type: Number },
emiAmount: { type: Number },
emiTotalInstallments: { type: Number },
subscriptionMonthlyAmount: { type: Number },
subscriptionYearlyAmount: { type: Number },
- userId: { type: String, required: true },
archived: { type: Boolean, default: false },
internal: { type: Boolean, default: false },
+ description: { type: String },
+ includedProducts: { type: [String], default: [] },
},
{
timestamps: true,
@@ -40,7 +48,7 @@ const PaymentPlanSchema = new mongoose.Schema(
PaymentPlanSchema.pre("save", async function (next) {
if (this.internal) {
- const existingInternalPlan = await this.constructor.findOne({
+ const existingInternalPlan = await (this.constructor as any).findOne({
domain: this.domain,
internal: true,
_id: { $ne: this._id },
@@ -61,5 +69,11 @@ PaymentPlanSchema.pre("save", async function (next) {
next();
});
+// Add indexes for common query patterns
+PaymentPlanSchema.index({ domain: 1, entityId: 1, entityType: 1, archived: 1 });
+PaymentPlanSchema.index({ domain: 1, internal: 1 });
+PaymentPlanSchema.index({ domain: 1, planId: 1, archived: 1 });
+PaymentPlanSchema.index({ domain: 1, archived: 1, type: 1 });
+
export default mongoose.models.PaymentPlan ||
mongoose.model("PaymentPlan", PaymentPlanSchema);
diff --git a/apps/web/pages/api/payment/initiate.ts.notused b/apps/web/pages/api/payment/initiate.ts.notused
deleted file mode 100644
index 99ea26eff..000000000
--- a/apps/web/pages/api/payment/initiate.ts.notused
+++ /dev/null
@@ -1,116 +0,0 @@
-import { NextApiRequest, NextApiResponse } from "next";
-import constants from "../../../config/constants";
-import { responses } from "../../../config/strings";
-import CourseModel, { Course } from "../../../models/Course";
-import { getPaymentMethod } from "../../../payments";
-import PurchaseModel from "../../../models/Purchase";
-import finalizePurchase from "../../../lib/finalize-purchase";
-import { error } from "../../../services/logger";
-import User from "@models/User";
-import DomainModel, { Domain } from "@models/Domain";
-import { auth } from "@/auth";
-
-const { transactionSuccess, transactionFailed, transactionInitiated } =
- constants;
-
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse,
-) {
- if (req.method !== "POST") {
- return res.status(405).json({ message: "Not allowed" });
- }
-
- const domain = await DomainModel.findOne({
- name: req.headers.domain,
- });
- if (!domain) {
- return res.status(404).json({ message: "Domain not found" });
- }
-
- const session = await auth(req, res);
-
- let user;
- if (session) {
- user = await User.findOne({
- email: session.user!.email,
- domain: domain._id,
- active: true,
- });
- }
-
- if (!user) {
- return res.status(401).json({});
- }
-
- const { body } = req;
- const { courseid, metadata } = body;
-
- if (!courseid) {
- return res.status(400).json({ error: responses.invalid_course_id });
- }
-
- try {
- const course: Course | null = await CourseModel.findOne({
- courseId: courseid,
- domain: domain._id,
- });
- if (!course) {
- return res.status(404).json({ error: responses.item_not_found });
- }
-
- const buyer = user!;
- if (
- buyer.purchases.some(
- (purchase) => purchase.courseId === course.courseId,
- )
- ) {
- return res.status(200).json({
- status: transactionSuccess,
- });
- }
-
- if (course.cost === 0) {
- try {
- await finalizePurchase(user!.userId, course!.courseId);
- return res.status(200).json({
- status: transactionSuccess,
- });
- } catch (err: any) {
- return res.status(500).json({ error: err.message });
- }
- }
-
- const siteinfo = domain.settings;
- const paymentMethod = await getPaymentMethod(domain!._id.toString());
-
- const purchase = await PurchaseModel.create({
- domain: domain._id.toString(),
- courseId: course.courseId,
- purchasedBy: user!.userId,
- paymentMethod: paymentMethod.getName(),
- amount: course.cost * 100,
- currencyISOCode: siteinfo.currencyISOCode,
- });
-
- const paymentTracker = await paymentMethod.initiate({
- course,
- metadata: JSON.parse(metadata),
- purchaseId: purchase.orderId,
- });
-
- purchase.paymentId = paymentTracker;
- await purchase.save();
-
- res.status(200).json({
- status: transactionInitiated,
- paymentTracker,
- });
- } catch (err: any) {
- error(err.message, { stack: err.stack });
- res.status(500).json({
- status: transactionFailed,
- error: err.message,
- });
- }
-}
diff --git a/apps/web/pages/api/payment/webhook-old.ts.notused b/apps/web/pages/api/payment/webhook-old.ts.notused
deleted file mode 100644
index dc03c245a..000000000
--- a/apps/web/pages/api/payment/webhook-old.ts.notused
+++ /dev/null
@@ -1,60 +0,0 @@
-import { NextApiRequest, NextApiResponse } from "next";
-import constants from "@/config/constants";
-import finalizePurchase from "@/lib/finalize-purchase";
-import PurchaseModel, { Purchase } from "@/models/Purchase";
-import { getPaymentMethod } from "@/payments";
-const { transactionSuccess } = constants;
-import DomainModel, { Domain } from "@models/Domain";
-import { info } from "@/services/logger";
-
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse,
-) {
- if (req.method !== "POST") {
- return res.status(405).json({ message: "Not allowed" });
- }
-
- info(`POST /api/payment/webhook: domain detected: ${req.headers.domain}`);
-
- const domain = await DomainModel.findOne({
- name: req.headers.domain,
- });
- if (!domain) {
- return res.status(404).json({ message: "Domain not found" });
- }
-
- const { body } = req;
- const paymentMethod = await getPaymentMethod(domain._id.toString());
- const paymentVerified = paymentMethod.verify(body);
- if (paymentVerified) {
- const purchaseId = paymentMethod.getPaymentIdentifier(body);
- const purchaseRecord: Purchase | null = await PurchaseModel.findOne({
- orderId: purchaseId,
- });
-
- if (!purchaseRecord) {
- return res.status(200).json({
- message: "fail",
- });
- }
-
- purchaseRecord.status = transactionSuccess;
- purchaseRecord.webhookPayload = body;
- await (purchaseRecord as any).save();
-
- await finalizePurchase(
- purchaseRecord.purchasedBy,
- purchaseRecord.courseId,
- purchaseRecord.orderId,
- );
-
- res.status(200).json({
- message: "success",
- });
- } else {
- res.status(200).json({
- message: "fail",
- });
- }
-}
diff --git a/apps/web/setupTests.ts b/apps/web/setupTests.ts
index 07f325f12..39d41b0e4 100644
--- a/apps/web/setupTests.ts
+++ b/apps/web/setupTests.ts
@@ -4,3 +4,13 @@ import { TextEncoder, TextDecoder } from "node:util";
global.TextEncoder = TextEncoder as typeof global.TextEncoder;
global.TextDecoder = TextDecoder as typeof global.TextDecoder;
+
+// Suppress console.error during tests to reduce noise
+const originalError = console.error;
+beforeAll(() => {
+ console.error = jest.fn();
+});
+
+afterAll(() => {
+ console.error = originalError;
+});
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index fe6f8cd4a..8661dc6e5 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -434,7 +434,6 @@ export const DELETE_SECTION_HEADER = "Delete section";
export const PRICING_HEADER = "Pricing";
export const PRICING_DROPDOWN = "Pricing model";
export const PRICING_FREE = Constants.ProductPriceType.FREE;
-export const PRICING_FREE_LABEL = "Free";
export const PRICING_FREE_SUBTITLE =
"People can access the content for free. The user needs to be signed in.";
export const PRICING_EMAIL = Constants.ProductPriceType.EMAIL;
@@ -443,6 +442,11 @@ export const PRICING_EMAIL_SUBTITLE =
"People will be sent the content over email. The user needs not be signed in.";
export const PRICING_PAID = Constants.ProductPriceType.PAID;
export const PRICING_PAID_LABEL = "Paid";
+export const PRICING_FREE_LABEL = "Free";
+export const PAYMENT_PLAN_FREE_LABEL = "Free";
+export const PAYMENT_PLAN_ONETIME_LABEL = "One-time";
+export const PAYMENT_PLAN_SUBSCRIPTION_LABEL = "Subscription";
+export const PAYMENT_PLAN_EMI_LABEL = "EMI";
export const PRICING_PAID_SUBTITLE =
"People can access the content after a one time payment. The user needs to be signed in.";
export const PRICING_PAID_NO_PAYMENT_METHOD =
@@ -612,6 +616,14 @@ export const NEW_COMMUNITY_BUTTON = "New community";
export const COMMUNITY_FIELD_NAME = "Community name";
export const COMMUNITY_NEW_BTN_CAPTION = "Create";
export const COMMUNITY_SETTINGS = "Manage";
+
+// Payment Plan strings
+export const NEW_PAYMENT_PLAN_HEADER = "New Payment Plan";
+export const EDIT_PAYMENT_PLAN_HEADER = "Edit Payment Plan";
+export const PAYMENT_PLANS_HEADER = "Payment Plans";
+export const NEW_PAYMENT_PLAN_DESCRIPTION =
+ "Configure a new payment plan for your";
+export const EDIT_PAYMENT_PLAN_DESCRIPTION = "Update the configuration for";
export const TOAST_TITLE_SUCCESS = "Success";
export const TOAST_SEQUENCE_SAVED = "Sequence changes saved successfully";
export const TOAST_TITLE_ERROR = "Error";
diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts
index 0a682ad1d..f68e19163 100644
--- a/apps/web/ui-lib/utils.ts
+++ b/apps/web/ui-lib/utils.ts
@@ -6,7 +6,6 @@ import type {
Membership,
MembershipRole,
Page,
- PaymentPlan,
Profile,
SiteInfo,
TextEditorContent,
@@ -18,6 +17,7 @@ import { createHash, randomInt } from "crypto";
import { getProtocol } from "../lib/utils";
import { headers as headersType } from "next/headers";
import { Theme } from "@courselit/page-models";
+export { getPlanPrice } from "@courselit/utils";
const { permissions } = UIConstants;
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
@@ -399,38 +399,38 @@ export function getNextRoleForCommunityMember(role: MembershipRole) {
return roleCycle[(index + 1) % roleCycle.length];
}
-export function getPlanPrice(plan: PaymentPlan): {
- amount: number;
- period: string;
-} {
- if (!plan) {
- return { amount: 0, period: "" };
- }
- switch (plan.type) {
- case Constants.PaymentPlanType.FREE:
- return { amount: 0, period: "" };
- case Constants.PaymentPlanType.ONE_TIME:
- return { amount: plan.oneTimeAmount || 0, period: "" };
- case Constants.PaymentPlanType.SUBSCRIPTION:
- if (plan.subscriptionYearlyAmount) {
- return {
- amount: plan.subscriptionYearlyAmount,
- period: "/yr",
- };
- }
- return {
- amount: plan.subscriptionMonthlyAmount || 0,
- period: "/mo",
- };
- case Constants.PaymentPlanType.EMI:
- return {
- amount: plan.emiAmount || 0,
- period: "/mo",
- };
- default:
- return { amount: 0, period: "" };
- }
-}
+// export function getPlanPrice(plan: PaymentPlan): {
+// amount: number;
+// period: string;
+// } {
+// if (!plan) {
+// return { amount: 0, period: "" };
+// }
+// switch (plan.type) {
+// case Constants.PaymentPlanType.FREE:
+// return { amount: 0, period: "" };
+// case Constants.PaymentPlanType.ONE_TIME:
+// return { amount: plan.oneTimeAmount || 0, period: "" };
+// case Constants.PaymentPlanType.SUBSCRIPTION:
+// if (plan.subscriptionYearlyAmount) {
+// return {
+// amount: plan.subscriptionYearlyAmount,
+// period: "/yr",
+// };
+// }
+// return {
+// amount: plan.subscriptionMonthlyAmount || 0,
+// period: "/mo",
+// };
+// case Constants.PaymentPlanType.EMI:
+// return {
+// amount: plan.emiAmount || 0,
+// period: "/mo",
+// };
+// default:
+// return { amount: 0, period: "" };
+// }
+// }
export function hasCommunityPermission(
member: Pick,
diff --git a/docs/memberships.md b/docs/memberships.md
index 2937023b6..73061274a 100644
--- a/docs/memberships.md
+++ b/docs/memberships.md
@@ -62,7 +62,9 @@ This document provides a comprehensive overview of the payment lifecycle for mem
- The membership remains or transitions to `ACTIVE`.
- **EMI-specific check**: If the number of `PAID` invoices matches the payment planβs installments, the subscription is automatically canceled. The membership remains `ACTIVE`.
-## Mermaid Diagram: Full Payment Lifecycle
+## Diagrams
+
+### Payment flow
```mermaid
sequenceDiagram
@@ -79,3 +81,34 @@ sequenceDiagram
Backend->>Backend: Update Invoice (PAID), Membership (ACTIVE)
Backend->>User: Grant access to resource
```
+
+## Membership flow
+
+```mermaid
+graph TD
+ A[User Joins Community] --> B{Payment Plan Type}
+ B -->|FREE| C{Auto Accept Members?}
+ B -->|PAID| D[Payment Processing]
+
+ C -->|Yes| E[Activate Membership: ACTIVE]
+ C -->|No| F[Activate Membership: PENDING]
+
+ D --> G[Payment Success
Webhook]
+ G --> E
+
+ E --> M[Membership active]
+ F --> J{Memberhips approved}
+
+ J --> |Yes| M
+ J --> |No| Y
+
+ I -->|Yes| Q[Add memberships for included products, Generate notifications, Run triggers]
+ I -->|No| O[No Course Access]
+ M --> I{Plan has included products?}
+
+ O --> X[End]
+ Q --> X
+
+ Y[Admin Rejects Member] --> Z[Delete existing included membership]
+ Z --> X
+```
diff --git a/packages/common-logic/src/models/course.ts b/packages/common-logic/src/models/course.ts
index 651ca7f02..c52a1fb31 100644
--- a/packages/common-logic/src/models/course.ts
+++ b/packages/common-logic/src/models/course.ts
@@ -19,7 +19,6 @@ export interface InternalCourse extends Omit {
lessons: any[];
sales: number;
customers: string[];
- paymentPlans: string[];
}
export const CourseSchema = new mongoose.Schema(
@@ -78,7 +77,7 @@ export const CourseSchema = new mongoose.Schema(
sales: { type: Number, required: true, default: 0.0 },
customers: [String],
pageId: { type: String },
- paymentPlans: [String],
+ // paymentPlans: [String],
defaultPaymentPlan: { type: String },
leadMagnet: { type: Boolean, required: true, default: false },
},
diff --git a/packages/common-logic/src/models/membership.ts b/packages/common-logic/src/models/membership.ts
index c093c0d35..d18dbeadf 100644
--- a/packages/common-logic/src/models/membership.ts
+++ b/packages/common-logic/src/models/membership.ts
@@ -19,7 +19,7 @@ export const MembershipSchema = new mongoose.Schema(
default: generateUniqueId,
},
userId: { type: String, required: true },
- paymentPlanId: String,
+ paymentPlanId: { type: String, required: true },
entityId: { type: String, required: true },
entityType: {
type: String,
@@ -27,6 +27,7 @@ export const MembershipSchema = new mongoose.Schema(
required: true,
},
sessionId: { type: String, required: true, default: generateUniqueId },
+ isIncludedInPlan: { type: Boolean, default: false },
status: {
type: String,
enum: Object.values(MembershipStatus),
diff --git a/packages/common-models/src/community.ts b/packages/common-models/src/community.ts
index 2fba7c7e3..2cb244ec2 100644
--- a/packages/common-models/src/community.ts
+++ b/packages/common-models/src/community.ts
@@ -1,5 +1,4 @@
import { Media } from "./media";
-import { PaymentPlan } from "./payment-plan";
import { TextEditorContent } from "./text-editor-content";
export interface Community {
@@ -13,7 +12,6 @@ export interface Community {
pageId: string;
products: string[];
autoAcceptMembers: boolean;
- paymentPlans: PaymentPlan[];
defaultPaymentPlan?: string;
featuredImage?: Media;
membersCount: number;
diff --git a/packages/common-models/src/course.ts b/packages/common-models/src/course.ts
index f6d5698b8..c914d66e1 100644
--- a/packages/common-models/src/course.ts
+++ b/packages/common-models/src/course.ts
@@ -1,9 +1,9 @@
import { Media } from "./media";
import Group from "./group";
import { ProductPriceType, CourseType, ProductAccessType } from "./constants";
-import { PaymentPlan } from "./payment-plan";
import Lesson from "./lesson";
import User from "./user";
+import { PaymentPlan } from "./payment-plan";
export type ProductPriceType =
(typeof ProductPriceType)[keyof typeof ProductPriceType];
@@ -28,11 +28,11 @@ export interface Course {
type: CourseType;
pageId?: string;
groups?: Group[];
- paymentPlans: PaymentPlan[];
defaultPaymentPlan?: string;
createdAt: Date;
updatedAt: Date;
leadMagnet?: boolean;
lessons?: Lesson[];
user: User;
+ paymentPlans?: PaymentPlan[];
}
diff --git a/packages/common-models/src/membership.ts b/packages/common-models/src/membership.ts
index 140b3f04c..b3aeb9fc5 100644
--- a/packages/common-models/src/membership.ts
+++ b/packages/common-models/src/membership.ts
@@ -16,7 +16,7 @@ export interface Membership {
entityType: MembershipEntityType;
status: MembershipStatus;
role: MembershipRole;
- paymentPlanId?: string;
+ paymentPlanId: string;
subscriptionId?: string;
subscriptionMethod?: string;
joiningReason?: string;
@@ -24,4 +24,5 @@ export interface Membership {
sessionId: string;
createdAt?: Date;
updatedAt?: Date;
+ isIncludedInPlan?: boolean;
}
diff --git a/packages/common-models/src/payment-plan.ts b/packages/common-models/src/payment-plan.ts
index e3f600f28..d44b314c5 100644
--- a/packages/common-models/src/payment-plan.ts
+++ b/packages/common-models/src/payment-plan.ts
@@ -1,4 +1,4 @@
-import { Constants } from ".";
+import { Constants, MembershipEntityType } from ".";
const { PaymentPlanType: PaymentPlanTypeConst } = Constants;
export type PaymentPlanType =
@@ -8,9 +8,13 @@ export interface PaymentPlan {
name: string;
planId: string;
type: PaymentPlanType;
+ entityId: string;
+ entityType: MembershipEntityType;
oneTimeAmount?: number;
emiAmount?: number;
emiTotalInstallments?: number;
subscriptionMonthlyAmount?: number;
subscriptionYearlyAmount?: number;
+ includedProducts?: string[];
+ description?: string;
}
diff --git a/services/app/.dockerignore b/services/app/.dockerignore
index 5cf4091d7..7eaaafb3f 100644
--- a/services/app/.dockerignore
+++ b/services/app/.dockerignore
@@ -1,3 +1,3 @@
node_modules
.env
-.env.local
\ No newline at end of file
+.env.loca
\ No newline at end of file