diff --git a/apps/docs-new/content/docs/schools/set-up-payments.mdx b/apps/docs-new/content/docs/schools/set-up-payments.mdx
index 8a46232d6..14b5b954d 100644
--- a/apps/docs-new/content/docs/schools/set-up-payments.mdx
+++ b/apps/docs-new/content/docs/schools/set-up-payments.mdx
@@ -9,6 +9,7 @@ CourseLit offers integrations with the following payment platforms:
- [Stripe](https://stripe.com)
- [Razorpay](https://razorpay.com)
+- [PayPal](https://paypal.com)
- [Lemonsqueezy](https://lemonsqueezy.com) (Experimental)
> A school can only have a single payment platform activated at a time.
@@ -106,6 +107,37 @@ CourseLit offers integrations with the following payment platforms:
9. That's it! Your Lemon Squeezy configuration is complete, and you are ready to receive payments.
+## PayPal setup
+
+> PayPal subscriptions require a product and recurring plans. Similar to our Lemon Squeezy integration, CourseLit does not create these provider-side objects for you. You must create them in PayPal first and then copy the IDs into CourseLit.
+
+1. Sign up for a PayPal Business account and a PayPal Developer account, and get your business approved (or use sandbox accounts for testing).
+2. In the PayPal Developer dashboard, go to `Apps & Credentials`.
+3. Create an app if you do not already have one, then keep that screen open.
+4. Copy the `Client ID` and `Client Secret` for your PayPal app.
+5. Create a generic PayPal product for CourseLit subscriptions.
+ PayPal requires a product before recurring billing plans can be created.
+6. Create the recurring plans you want to use with CourseLit:
+ - **Monthly plan**: To enable monthly subscriptions and EMIs in CourseLit
+ - **Yearly plan**: To enable yearly subscriptions in CourseLit
+7. In your CourseLit school's dashboard, go to `Settings > Payment` and configure the settings as described below:
+ 1. **Currency**: This will be visible throughout your school.
+ 2. **Payment method**: Select PayPal.
+ 3. **PayPal Client ID**: Paste the client ID from your PayPal app.
+ 4. **PayPal Client Secret**: Paste the client secret from your PayPal app.
+ 5. **PayPal Product ID**: Paste the generic product ID you created in PayPal.
+ 6. **PayPal Monthly Plan ID**: Paste the monthly recurring plan ID.
+ 7. **PayPal Yearly Plan ID**: Paste the yearly recurring plan ID.
+8. Set up the webhooks. Using webhooks, your school receives timely updates about payments from PayPal.
+9. In the PayPal Developer dashboard, create a webhook using your CourseLit school's webhook endpoint (listed in the same payment screen in your school).
+10. Subscribe the webhook to the following events:
+ - `Checkout order completed`: For confirming one-time payments
+ - `Payment sale completed`: For confirming subscription and EMI payments
+ - `Billing subscription cancelled`: For subscription cancellation updates
+ - `Billing subscription expired`: For subscription expiry updates
+ - `Billing subscription payment failed`: For failed recurring payments
+11. That's it! Your PayPal configuration is complete, and you are ready to receive payments.
+
## Reset payment method
If you want to stop using the currently selected payment platform, go to `Settings > Payment` and click the reset icon next to the `Payment Method` dropdown.
diff --git a/apps/docs-new/next-env.d.ts b/apps/docs-new/next-env.d.ts
index 9edff1c7c..c4b7818fb 100644
--- a/apps/docs-new/next-env.d.ts
+++ b/apps/docs-new/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/docs/src/pages/en/schools/set-up-payments.md b/apps/docs/src/pages/en/schools/set-up-payments.md
index 1766a92ff..d6da1da23 100644
--- a/apps/docs/src/pages/en/schools/set-up-payments.md
+++ b/apps/docs/src/pages/en/schools/set-up-payments.md
@@ -10,6 +10,7 @@ CourseLit offers integrations with the following payment platforms:
- [Stripe](https://stripe.com)
- [Razorpay](https://razorpay.com)
+- [PayPal](https://paypal.com)
- [Lemonsqueezy](https://lemonsqueezy.com) (Experimental)
> A school can only have a single payment platform activated at a time.
@@ -107,6 +108,37 @@ CourseLit offers integrations with the following payment platforms:
9. That's it! Your Lemon Squeezy configuration is complete, and you are ready to receive payments.
+## PayPal setup
+
+> PayPal subscriptions require a product and recurring plans. Similar to our Lemon Squeezy integration, CourseLit does not create these provider-side objects for you. You must create them in PayPal first and then copy the IDs into CourseLit.
+
+1. Sign up for a PayPal Business account and a PayPal Developer account, and get your business approved (or use sandbox accounts for testing).
+2. In the PayPal Developer dashboard, go to `Apps & Credentials`.
+3. Create an app if you do not already have one, then keep that screen open.
+4. Copy the `Client ID` and `Client Secret` for your PayPal app.
+5. Create a generic PayPal product for CourseLit subscriptions.
+ PayPal requires a product before recurring billing plans can be created.
+6. Create the recurring plans you want to use with CourseLit:
+ - **Monthly plan**: To enable monthly subscriptions and EMIs in CourseLit
+ - **Yearly plan**: To enable yearly subscriptions in CourseLit
+7. In your CourseLit school's dashboard, go to `Settings > Payment` and configure the settings as described below:
+ 1. **Currency**: This will be visible throughout your school.
+ 2. **Payment method**: Select PayPal.
+ 3. **PayPal Client ID**: Paste the client ID from your PayPal app.
+ 4. **PayPal Client Secret**: Paste the client secret from your PayPal app.
+ 5. **PayPal Product ID**: Paste the generic product ID you created in PayPal.
+ 6. **PayPal Monthly Plan ID**: Paste the monthly recurring plan ID.
+ 7. **PayPal Yearly Plan ID**: Paste the yearly recurring plan ID.
+8. Set up the webhooks. Using webhooks, your school receives timely updates about payments from PayPal.
+9. In the PayPal Developer dashboard, create a webhook using your CourseLit school's webhook endpoint (listed in the same payment screen in your school).
+10. Subscribe the webhook to the following events:
+ - `Checkout order completed`: For confirming one-time payments
+ - `Payment sale completed`: For confirming subscription and EMI payments
+ - `Billing subscription cancelled`: For subscription cancellation updates
+ - `Billing subscription expired`: For subscription expiry updates
+ - `Billing subscription payment failed`: For failed recurring payments
+11. That's it! Your PayPal configuration is complete, and you are ready to receive payments.
+
## Reset payment method
If you want to stop using the currently selected payment platform, go to `Settings > Payment` and click the reset icon next to the `Payment Method` dropdown.
diff --git a/apps/web/app/api/payment/initiate/__tests__/route.test.ts b/apps/web/app/api/payment/initiate/__tests__/route.test.ts
index 8a9f35a6b..69e8dd107 100644
--- a/apps/web/app/api/payment/initiate/__tests__/route.test.ts
+++ b/apps/web/app/api/payment/initiate/__tests__/route.test.ts
@@ -179,6 +179,48 @@ describe("Payment Initiate Route", () => {
expect(response.status).toBe(404);
});
+ it("initiates PayPal payments using the existing payment abstraction", async () => {
+ mockRequest.json = jest.fn().mockResolvedValue({
+ id: "course-123",
+ type: Constants.MembershipEntityType.COURSE,
+ planId: "planA",
+ origin: "https://school.example.com",
+ });
+
+ (PaymentPlan.findOne as jest.Mock).mockResolvedValue({
+ planId: "planA",
+ type: Constants.PaymentPlanType.ONE_TIME,
+ entityId: "course-123",
+ entityType: Constants.MembershipEntityType.COURSE,
+ archived: false,
+ internal: false,
+ oneTimeAmount: 99,
+ });
+
+ const { getPaymentMethodFromSettings } = require("@/payments-new");
+ (getPaymentMethodFromSettings as jest.Mock).mockResolvedValue({
+ name: "paypal",
+ initiate: jest
+ .fn()
+ .mockResolvedValue("https://paypal.test/approve"),
+ getCurrencyISOCode: jest.fn().mockResolvedValue("USD"),
+ validateSubscription: jest.fn().mockResolvedValue(true),
+ });
+
+ const response = await POST(mockRequest);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.status).toBe("initiated");
+ expect(body.paymentTracker).toBe("https://paypal.test/approve");
+ expect(Invoice.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ paymentProcessor: "paypal",
+ paymentProcessorEntityId: "https://paypal.test/approve",
+ }),
+ );
+ });
+
describe("Free Community with Included Products", () => {
beforeEach(() => {
// Reset to community context for these tests
diff --git a/apps/web/app/api/payment/initiate/route.ts b/apps/web/app/api/payment/initiate/route.ts
index 3fc3b6f07..ccf2c2fc1 100644
--- a/apps/web/app/api/payment/initiate/route.ts
+++ b/apps/web/app/api/payment/initiate/route.ts
@@ -179,6 +179,7 @@ export async function POST(req: NextRequest) {
membershipId: membership.membershipId,
invoiceId,
currencyISOCode,
+ domainName: domain.name,
};
const paymentTracker = await paymentMethod!.initiate({
diff --git a/apps/web/app/api/payment/vendor/paypal/capture/route.ts b/apps/web/app/api/payment/vendor/paypal/capture/route.ts
new file mode 100644
index 000000000..8efbbf80b
--- /dev/null
+++ b/apps/web/app/api/payment/vendor/paypal/capture/route.ts
@@ -0,0 +1,46 @@
+import DomainModel, { Domain } from "@models/Domain";
+import PayPalPayment from "@/payments-new/paypal-payment";
+import { getPaymentMethodFromSettings } from "@/payments-new";
+import { error } from "@/services/logger";
+import { NextRequest } from "next/server";
+
+export async function GET(req: NextRequest) {
+ const orderId = req.nextUrl.searchParams.get("token");
+ const invoiceId = req.nextUrl.searchParams.get("invoiceId");
+ const domainName = req.nextUrl.searchParams.get("domainName");
+ const redirectUrl = new URL(
+ `/checkout/verify?id=${invoiceId || ""}`,
+ req.nextUrl.origin,
+ );
+
+ if (!orderId || !domainName) {
+ return Response.redirect(redirectUrl, 302);
+ }
+
+ try {
+ const domain = await DomainModel.findOne({ name: domainName });
+
+ if (!domain?.settings) {
+ return Response.redirect(redirectUrl, 302);
+ }
+
+ const paymentMethod = await getPaymentMethodFromSettings(
+ domain.settings,
+ "paypal",
+ );
+
+ if (!(paymentMethod instanceof PayPalPayment)) {
+ return Response.redirect(redirectUrl, 302);
+ }
+
+ await paymentMethod.captureOrder(orderId);
+ } catch (e: any) {
+ error(`Error capturing PayPal order: ${e.message}`, {
+ domainName,
+ invoiceId,
+ orderId,
+ });
+ }
+
+ return Response.redirect(redirectUrl, 302);
+}
diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx
index c502a8559..fbd74b2d4 100644
--- a/apps/web/components/admin/settings/index.tsx
+++ b/apps/web/components/admin/settings/index.tsx
@@ -9,7 +9,7 @@ import {
SITE_SETTINGS_CURRENCY,
SITE_ADMIN_SETTINGS_STRIPE_SECRET,
SITE_ADMIN_SETTINGS_RAZORPAY_SECRET,
- SITE_ADMIN_SETTINGS_PAYPAL_SECRET,
+ SITE_ADMIN_SETTINGS_PAYPAL_CLIENT_SECRET,
SITE_ADMIN_SETTINGS_PAYTM_SECRET,
SITE_SETTINGS_SECTION_GENERAL,
SITE_SETTINGS_SECTION_PAYMENT,
@@ -44,6 +44,10 @@ import {
SITE_SETTINGS_LEMONSQUEEZY_ONETIME_TEXT,
SITE_SETTINGS_LEMONSQUEEZY_SUB_MONTHLY_TEXT,
SITE_SETTINGS_LEMONSQUEEZY_SUB_YEARLY_TEXT,
+ SITE_SETTINGS_PAYPAL_CLIENT_ID_TEXT,
+ SITE_SETTINGS_PAYPAL_PRODUCT_ID_TEXT,
+ SITE_SETTINGS_PAYPAL_MONTHLY_PLAN_ID_TEXT,
+ SITE_SETTINGS_PAYPAL_YEARLY_PLAN_ID_TEXT,
SETTINGS_RESOURCE_PAYMENT,
SITE_MISCELLANEOUS_SETTING_HEADER,
BUTTON_CANCEL_TEXT,
@@ -159,6 +163,10 @@ const Settings = (props: SettingsProps) => {
paymentMethod,
stripeKey,
razorpayKey,
+ paypalClientId,
+ paypalProductId,
+ paypalMonthlyPlanId,
+ paypalYearlyPlanId,
lemonsqueezyStoreId,
lemonsqueezyOneTimeVariantId,
lemonsqueezySubscriptionMonthlyVariantId,
@@ -203,6 +211,10 @@ const Settings = (props: SettingsProps) => {
paymentMethod: settingsResponse.paymentMethod || "",
stripeKey: settingsResponse.stripeKey || "",
razorpayKey: settingsResponse.razorpayKey || "",
+ paypalClientId: settingsResponse.paypalClientId || "",
+ paypalProductId: settingsResponse.paypalProductId || "",
+ paypalMonthlyPlanId: settingsResponse.paypalMonthlyPlanId || "",
+ paypalYearlyPlanId: settingsResponse.paypalYearlyPlanId || "",
lemonsqueezyStoreId: settingsResponse.lemonsqueezyStoreId || "",
codeInjectionHead: settingsResponse.codeInjectionHead || "",
codeInjectionBody: settingsResponse.codeInjectionBody || "",
@@ -528,12 +540,17 @@ const Settings = (props: SettingsProps) => {
$paymentMethod: String,
$stripeKey: String,
$stripeSecret: String,
+ $paypalClientId: String,
+ $paypalClientSecret: String,
+ $paypalProductId: String,
+ $paypalMonthlyPlanId: String,
+ $paypalYearlyPlanId: String,
$razorpayKey: String,
$razorpaySecret: String,
$razorpayWebhookSecret: String,
$lemonsqueezyKey: String,
$lemonsqueezyStoreId: String,
- $lemonsqueezyWebhookSecret: String
+ $lemonsqueezyWebhookSecret: String,
$lemonsqueezyOneTimeVariantId: String,
$lemonsqueezySubscriptionMonthlyVariantId: String,
$lemonsqueezySubscriptionYearlyVariantId: String
@@ -543,6 +560,11 @@ const Settings = (props: SettingsProps) => {
paymentMethod: $paymentMethod,
stripeKey: $stripeKey,
stripeSecret: $stripeSecret,
+ paypalClientId: $paypalClientId,
+ paypalClientSecret: $paypalClientSecret,
+ paypalProductId: $paypalProductId,
+ paypalMonthlyPlanId: $paypalMonthlyPlanId,
+ paypalYearlyPlanId: $paypalYearlyPlanId,
razorpayKey: $razorpayKey,
razorpaySecret: $razorpaySecret,
razorpayWebhookSecret: $razorpayWebhookSecret,
@@ -570,6 +592,10 @@ const Settings = (props: SettingsProps) => {
paymentMethod,
stripeKey,
razorpayKey,
+ paypalClientId,
+ paypalProductId,
+ paypalMonthlyPlanId,
+ paypalYearlyPlanId,
lemonsqueezyStoreId,
lemonsqueezyOneTimeVariantId,
lemonsqueezySubscriptionMonthlyVariantId,
@@ -592,6 +618,11 @@ const Settings = (props: SettingsProps) => {
paymentMethod: newSettings.paymentMethod,
stripeKey: newSettings.stripeKey,
stripeSecret: newSettings.stripeSecret,
+ paypalClientId: newSettings.paypalClientId,
+ paypalClientSecret: newSettings.paypalClientSecret,
+ paypalProductId: newSettings.paypalProductId,
+ paypalMonthlyPlanId: newSettings.paypalMonthlyPlanId,
+ paypalYearlyPlanId: newSettings.paypalYearlyPlanId,
razorpayKey: newSettings.razorpayKey,
razorpaySecret: newSettings.razorpaySecret,
razorpayWebhookSecret:
@@ -694,9 +725,21 @@ const Settings = (props: SettingsProps) => {
stripeSecret: getNewSettings
? newSettings.stripeSecret
: settings.stripeSecret,
- paypalSecret: getNewSettings
- ? newSettings.paypalSecret
- : settings.paypalSecret,
+ paypalClientId: getNewSettings
+ ? newSettings.paypalClientId
+ : settings.paypalClientId,
+ paypalClientSecret: getNewSettings
+ ? newSettings.paypalClientSecret
+ : settings.paypalClientSecret,
+ paypalProductId: getNewSettings
+ ? newSettings.paypalProductId
+ : settings.paypalProductId,
+ paypalMonthlyPlanId: getNewSettings
+ ? newSettings.paypalMonthlyPlanId
+ : settings.paypalMonthlyPlanId,
+ paypalYearlyPlanId: getNewSettings
+ ? newSettings.paypalYearlyPlanId
+ : settings.paypalYearlyPlanId,
paytmSecret: getNewSettings
? newSettings.paytmSecret
: settings.paytmSecret,
@@ -913,6 +956,10 @@ const Settings = (props: SettingsProps) => {
!x.razorpay,
),
},
+ {
+ label: "PayPal",
+ value: PAYMENT_METHOD_PAYPAL,
+ },
{
label: capitalize(
PAYMENT_METHOD_LEMONSQUEEZY.toLowerCase(),
@@ -1104,14 +1151,48 @@ const Settings = (props: SettingsProps) => {
)}
{newSettings.paymentMethod ===
PAYMENT_METHOD_PAYPAL && (
-
+ <>
+
+
+
+
+
+ >
)}
{newSettings.paymentMethod === PAYMENT_METHOD_PAYTM && (
{
);
expect(updatedDomain?.settings?.currencyISOCode).toBe("usd");
});
+
+ it("should treat PayPal as invalid when required ids or credentials are missing", () => {
+ expect(
+ checkForInvalidPaymentMethodSettings({
+ paymentMethod: UIConstants.PAYMENT_METHOD_PAYPAL,
+ currencyISOCode: "usd",
+ paypalClientId: "client-id",
+ paypalClientSecret: "client-secret",
+ paypalProductId: "product-id",
+ paypalMonthlyPlanId: "monthly-plan-id",
+ } as any),
+ ).toBe(UIConstants.PAYMENT_METHOD_PAYPAL);
+ });
+
+ it("should accept PayPal when credentials and recurring template ids are present", () => {
+ expect(
+ checkForInvalidPaymentMethodSettings({
+ paymentMethod: UIConstants.PAYMENT_METHOD_PAYPAL,
+ currencyISOCode: "usd",
+ paypalClientId: "client-id",
+ paypalClientSecret: "client-secret",
+ paypalProductId: "product-id",
+ paypalMonthlyPlanId: "monthly-plan-id",
+ paypalYearlyPlanId: "yearly-plan-id",
+ } as any),
+ ).toBeUndefined();
+ });
});
diff --git a/apps/web/graphql/settings/helpers.ts b/apps/web/graphql/settings/helpers.ts
index 8f23c6a8f..c9fcf05ad 100644
--- a/apps/web/graphql/settings/helpers.ts
+++ b/apps/web/graphql/settings/helpers.ts
@@ -67,7 +67,13 @@ export const checkForInvalidPaymentMethodSettings = (
if (
siteInfo.paymentMethod === UIConstants.PAYMENT_METHOD_PAYPAL &&
- !siteInfo.paypalSecret
+ !(
+ siteInfo.paypalClientId &&
+ siteInfo.paypalClientSecret &&
+ siteInfo.paypalProductId &&
+ siteInfo.paypalMonthlyPlanId &&
+ siteInfo.paypalYearlyPlanId
+ )
) {
failedPaymentMethod = UIConstants.PAYMENT_METHOD_PAYPAL;
}
diff --git a/apps/web/graphql/settings/logic.ts b/apps/web/graphql/settings/logic.ts
index aa39d58e0..787fe6acb 100644
--- a/apps/web/graphql/settings/logic.ts
+++ b/apps/web/graphql/settings/logic.ts
@@ -120,7 +120,7 @@ export const getSiteInfo = async (ctx: GQLContext) => {
customDomain: 0,
"settings.stripeSecret": 0,
"settings.paytmSecret": 0,
- "settings.paypalSecret": 0,
+ "settings.paypalClientSecret": 0,
"settings.razorpaySecret": 0,
"settings.razorpayWebhookSecret": 0,
};
diff --git a/apps/web/graphql/settings/types.ts b/apps/web/graphql/settings/types.ts
index a72503208..fb915f804 100644
--- a/apps/web/graphql/settings/types.ts
+++ b/apps/web/graphql/settings/types.ts
@@ -51,6 +51,10 @@ const siteType = new GraphQLObjectType({
paymentMethod: { type: GraphQLString },
stripeKey: { type: GraphQLString },
razorpayKey: { type: GraphQLString },
+ paypalClientId: { type: GraphQLString },
+ paypalProductId: { type: GraphQLString },
+ paypalMonthlyPlanId: { type: GraphQLString },
+ paypalYearlyPlanId: { type: GraphQLString },
lemonsqueezyStoreId: { type: GraphQLString },
lemonsqueezyOneTimeVariantId: { type: GraphQLString },
lemonsqueezySubscriptionMonthlyVariantId: { type: GraphQLString },
@@ -85,7 +89,11 @@ const sitePaymentUpdateType = new GraphQLInputObjectType({
stripeSecret: { type: GraphQLString },
stripeWebhookSecret: { type: GraphQLString },
paytmSecret: { type: GraphQLString },
- paypalSecret: { type: GraphQLString },
+ paypalClientId: { type: GraphQLString },
+ paypalClientSecret: { type: GraphQLString },
+ paypalProductId: { type: GraphQLString },
+ paypalMonthlyPlanId: { type: GraphQLString },
+ paypalYearlyPlanId: { type: GraphQLString },
razorpayKey: { type: GraphQLString },
razorpaySecret: { type: GraphQLString },
razorpayWebhookSecret: { type: GraphQLString },
diff --git a/apps/web/payments-new/__tests__/paypal-payment.test.ts b/apps/web/payments-new/__tests__/paypal-payment.test.ts
new file mode 100644
index 000000000..6951f36c0
--- /dev/null
+++ b/apps/web/payments-new/__tests__/paypal-payment.test.ts
@@ -0,0 +1,151 @@
+import { Constants, UIConstants } from "@courselit/common-models";
+import PayPalPayment from "../paypal-payment";
+
+describe("PayPalPayment", () => {
+ const siteinfo = {
+ paymentMethod: UIConstants.PAYMENT_METHOD_PAYPAL,
+ currencyISOCode: "usd",
+ paypalClientId: "client-id",
+ paypalClientSecret: "client-secret",
+ paypalProductId: "product-id",
+ paypalMonthlyPlanId: "monthly-plan-id",
+ paypalYearlyPlanId: "yearly-plan-id",
+ };
+
+ it("requires PayPal credentials and template ids during setup", async () => {
+ const payment = new PayPalPayment({
+ ...siteinfo,
+ paypalProductId: undefined,
+ });
+
+ await expect(payment.setup()).rejects.toThrow();
+ });
+
+ it("accepts one-time capture completion events with metadata", async () => {
+ const payment = await new PayPalPayment(siteinfo as any).setup();
+
+ await expect(
+ payment.verify({
+ event_type: "PAYMENT.CAPTURE.COMPLETED",
+ resource: {
+ status: "COMPLETED",
+ custom_id: JSON.stringify({
+ membershipId: "membership-1",
+ invoiceId: "invoice-1",
+ }),
+ },
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("accepts recurring sale completion events with metadata", async () => {
+ const payment = await new PayPalPayment(siteinfo as any).setup();
+
+ await expect(
+ payment.verify({
+ event_type: "PAYMENT.SALE.COMPLETED",
+ resource: {
+ custom: JSON.stringify({
+ membershipId: "membership-1",
+ }),
+ },
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("extracts metadata from custom_id or custom fields", async () => {
+ const payment = await new PayPalPayment(siteinfo as any).setup();
+
+ expect(
+ payment.getMetadata({
+ resource: {
+ custom_id: JSON.stringify({
+ membershipId: "membership-1",
+ invoiceId: "invoice-1",
+ }),
+ },
+ }),
+ ).toEqual({
+ membershipId: "membership-1",
+ invoiceId: "invoice-1",
+ });
+
+ expect(
+ payment.getMetadata({
+ resource: {
+ custom: JSON.stringify({
+ membershipId: "membership-2",
+ }),
+ },
+ }),
+ ).toEqual({
+ membershipId: "membership-2",
+ });
+ });
+
+ it("returns recurring subscription ids from billing agreement ids", async () => {
+ const payment = await new PayPalPayment(siteinfo as any).setup();
+
+ expect(
+ payment.getSubscriptionId({
+ resource: {
+ billing_agreement_id: "subscription-1",
+ },
+ }),
+ ).toBe("subscription-1");
+ });
+
+ it("builds approval urls for one-time and recurring payments", async () => {
+ const payment = await new PayPalPayment(siteinfo as any).setup();
+ const paypalFetchSpy = jest
+ .spyOn(payment as any, "paypalFetch")
+ .mockResolvedValue({
+ links: [
+ { rel: "approve", href: "https://paypal.test/approve" },
+ ],
+ });
+
+ await expect(
+ payment.initiate({
+ metadata: {
+ membershipId: "membership-1",
+ invoiceId: "invoice-1",
+ currencyISOCode: "usd",
+ domainName: "school.example.com",
+ },
+ paymentPlan: {
+ type: Constants.PaymentPlanType.ONE_TIME,
+ oneTimeAmount: 99,
+ } as any,
+ product: {
+ id: "course-1",
+ title: "Course 1",
+ type: Constants.MembershipEntityType.COURSE,
+ },
+ origin: "https://school.example.com",
+ }),
+ ).resolves.toBe("https://paypal.test/approve");
+
+ await expect(
+ payment.initiate({
+ metadata: {
+ membershipId: "membership-1",
+ invoiceId: "invoice-1",
+ currencyISOCode: "usd",
+ },
+ paymentPlan: {
+ type: Constants.PaymentPlanType.SUBSCRIPTION,
+ subscriptionMonthlyAmount: 19,
+ } as any,
+ product: {
+ id: "course-1",
+ title: "Course 1",
+ type: Constants.MembershipEntityType.COURSE,
+ },
+ origin: "https://school.example.com",
+ }),
+ ).resolves.toBe("https://paypal.test/approve");
+
+ expect(paypalFetchSpy).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/payments-new/index.ts b/apps/web/payments-new/index.ts
index 576ad92a5..e20fa3c4b 100644
--- a/apps/web/payments-new/index.ts
+++ b/apps/web/payments-new/index.ts
@@ -4,11 +4,10 @@ import DomainModel, { Domain } from "../models/Domain";
import StripePayment from "./stripe-payment";
import RazorpayPayment from "./razorpay-payment";
import LemonSqueezyPayment from "./lemonsqueezy-payment";
+import PayPalPayment from "./paypal-payment";
-const {
- error_unrecognised_payment_method: unrecognisedPaymentMethod,
- error_payment_method_not_implemented: notYetSupported,
-} = internal;
+const { error_unrecognised_payment_method: unrecognisedPaymentMethod } =
+ internal;
export const getPaymentMethod = async (domainName: string) => {
const domain: Domain | null = await DomainModel.findOne({
@@ -29,7 +28,7 @@ export const getPaymentMethodFromSettings = async (
switch (name || siteInfo.paymentMethod) {
case UIConstants.PAYMENT_METHOD_PAYPAL:
- throw new Error(notYetSupported);
+ return await new PayPalPayment(siteInfo).setup();
case UIConstants.PAYMENT_METHOD_STRIPE:
return await new StripePayment(siteInfo).setup();
case UIConstants.PAYMENT_METHOD_RAZORPAY:
@@ -37,7 +36,7 @@ export const getPaymentMethodFromSettings = async (
case UIConstants.PAYMENT_METHOD_LEMONSQUEEZY:
return await new LemonSqueezyPayment(siteInfo).setup();
case UIConstants.PAYMENT_METHOD_PAYTM:
- throw new Error(notYetSupported);
+ throw new Error(internal.error_payment_method_not_implemented);
default:
throw new Error(unrecognisedPaymentMethod);
}
diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts
index ba9d60e3d..0b4784a78 100644
--- a/apps/web/payments-new/payment.ts
+++ b/apps/web/payments-new/payment.ts
@@ -14,6 +14,8 @@ export interface InitiateProps {
interface Metadata {
membershipId: string;
invoiceId: string;
+ currencyISOCode?: string;
+ domainName?: string;
}
export default interface Payment {
diff --git a/apps/web/payments-new/paypal-payment.ts b/apps/web/payments-new/paypal-payment.ts
new file mode 100644
index 000000000..66e94eca6
--- /dev/null
+++ b/apps/web/payments-new/paypal-payment.ts
@@ -0,0 +1,388 @@
+import Payment, { InitiateProps } from "./payment";
+import { responses } from "../config/strings";
+import { Constants, SiteInfo, UIConstants } from "@courselit/common-models";
+import { getUnitAmount } from "./helpers";
+
+const LIVE_BASE_URL = "https://api-m.paypal.com";
+const SANDBOX_BASE_URL = "https://api-m.sandbox.paypal.com";
+
+const {
+ payment_invalid_settings: paymentInvalidSettings,
+ currency_iso_not_set: currencyISONotSet,
+} = responses;
+
+type TokenCacheEntry = {
+ accessToken: string;
+ expiresAt: number;
+ baseUrl: string;
+};
+
+const tokenCache = new Map();
+
+const buildCacheKey = (siteinfo: SiteInfo) =>
+ `${siteinfo.paypalClientId}:${siteinfo.paypalClientSecret}`;
+
+const encodeMetadata = (
+ metadata: InitiateProps["metadata"] & { currencyISOCode?: string },
+) =>
+ JSON.stringify({
+ membershipId: metadata.membershipId,
+ invoiceId: metadata.invoiceId,
+ currencyISOCode: metadata.currencyISOCode,
+ });
+
+const parseMetadata = (value?: string) => {
+ if (!value) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch {
+ return {};
+ }
+};
+
+export default class PayPalPayment implements Payment {
+ public siteinfo: SiteInfo;
+ public name: string;
+ private accessToken?: string;
+ private baseUrl?: string;
+
+ constructor(siteinfo: SiteInfo) {
+ this.siteinfo = siteinfo;
+ this.name = UIConstants.PAYMENT_METHOD_PAYPAL;
+ }
+
+ async setup() {
+ if (!this.siteinfo.currencyISOCode) {
+ throw new Error(currencyISONotSet);
+ }
+
+ if (
+ !this.siteinfo.paypalClientId ||
+ !this.siteinfo.paypalClientSecret ||
+ !this.siteinfo.paypalProductId ||
+ !this.siteinfo.paypalMonthlyPlanId ||
+ !this.siteinfo.paypalYearlyPlanId
+ ) {
+ throw new Error(`${this.name} ${paymentInvalidSettings}`);
+ }
+
+ return this;
+ }
+
+ async initiate({ metadata, paymentPlan, product, origin }: InitiateProps) {
+ if (paymentPlan.type === Constants.PaymentPlanType.ONE_TIME) {
+ return this.createOrder({ metadata, paymentPlan, product, origin });
+ }
+
+ return this.createSubscription({
+ metadata,
+ paymentPlan,
+ product,
+ origin,
+ });
+ }
+
+ async verify(event: any) {
+ if (!event?.event_type || !event?.resource) {
+ return false;
+ }
+
+ if (
+ event.event_type === "PAYMENT.CAPTURE.COMPLETED" &&
+ event.resource.status === "COMPLETED"
+ ) {
+ return !!this.getMetadata(event).membershipId;
+ }
+
+ if (event.event_type === "PAYMENT.SALE.COMPLETED") {
+ return !!this.getMetadata(event).membershipId;
+ }
+
+ return false;
+ }
+
+ getPaymentIdentifier(event: any) {
+ return event?.resource?.id;
+ }
+
+ getMetadata(event: any) {
+ if (event?.resource?.custom_id) {
+ return parseMetadata(event.resource.custom_id);
+ }
+
+ if (event?.resource?.custom) {
+ return parseMetadata(event.resource.custom);
+ }
+
+ return {};
+ }
+
+ getName() {
+ return this.name;
+ }
+
+ async cancel(subscriptionId: string) {
+ await this.paypalFetch(
+ `/v1/billing/subscriptions/${subscriptionId}/cancel`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ reason: "Cancelled by CourseLit",
+ }),
+ },
+ );
+
+ return true;
+ }
+
+ getSubscriptionId(event: any): string {
+ return (
+ event?.resource?.billing_agreement_id || event?.resource?.id || ""
+ );
+ }
+
+ async validateSubscription(subscriptionId: string) {
+ const subscription = await this.paypalFetch(
+ `/v1/billing/subscriptions/${subscriptionId}`,
+ );
+
+ return subscription?.status === "ACTIVE";
+ }
+
+ async getCurrencyISOCode() {
+ return this.siteinfo.currencyISOCode!;
+ }
+
+ async captureOrder(orderId: string) {
+ const response = await this.paypalFetch(
+ `/v2/checkout/orders/${orderId}/capture`,
+ {
+ method: "POST",
+ },
+ );
+
+ return response;
+ }
+
+ private async createOrder({
+ metadata,
+ paymentPlan,
+ product,
+ origin,
+ }: InitiateProps) {
+ const unitAmount = this.formatAmount(getUnitAmount(paymentPlan));
+ const returnUrl = new URL(
+ `${origin}/api/payment/vendor/paypal/capture`,
+ );
+ returnUrl.searchParams.set("invoiceId", metadata.invoiceId);
+ if (metadata.domainName) {
+ returnUrl.searchParams.set("domainName", metadata.domainName);
+ }
+
+ const payload = {
+ intent: "CAPTURE",
+ purchase_units: [
+ {
+ description: product.title,
+ custom_id: encodeMetadata(metadata),
+ invoice_id: metadata.invoiceId,
+ amount: {
+ currency_code:
+ this.siteinfo.currencyISOCode?.toUpperCase(),
+ value: unitAmount,
+ breakdown: {
+ item_total: {
+ currency_code:
+ this.siteinfo.currencyISOCode?.toUpperCase(),
+ value: unitAmount,
+ },
+ },
+ },
+ items: [
+ {
+ name: product.title,
+ quantity: "1",
+ unit_amount: {
+ currency_code:
+ this.siteinfo.currencyISOCode?.toUpperCase(),
+ value: unitAmount,
+ },
+ },
+ ],
+ },
+ ],
+ application_context: {
+ return_url: returnUrl.toString(),
+ cancel_url: `${origin}/checkout?type=${product.type}&id=${product.id}`,
+ },
+ };
+
+ const response = await this.paypalFetch("/v2/checkout/orders", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+
+ const approvalLink = response?.links?.find(
+ (link: any) => link.rel === "approve",
+ )?.href;
+
+ if (!approvalLink) {
+ throw new Error("PayPal approval URL not found");
+ }
+
+ return approvalLink;
+ }
+
+ private async createSubscription({
+ metadata,
+ paymentPlan,
+ product,
+ origin,
+ }: InitiateProps) {
+ const unitAmount = this.formatAmount(getUnitAmount(paymentPlan));
+ const isYearly =
+ paymentPlan.type === Constants.PaymentPlanType.SUBSCRIPTION &&
+ !!paymentPlan.subscriptionYearlyAmount;
+ const isEmi = paymentPlan.type === Constants.PaymentPlanType.EMI;
+ const planId = isYearly
+ ? this.siteinfo.paypalYearlyPlanId
+ : this.siteinfo.paypalMonthlyPlanId;
+ const intervalUnit = isYearly ? "YEAR" : "MONTH";
+
+ const billingCycle: any = {
+ sequence: 1,
+ tenure_type: "REGULAR",
+ pricing_scheme: {
+ fixed_price: {
+ currency_code: this.siteinfo.currencyISOCode?.toUpperCase(),
+ value: unitAmount,
+ },
+ },
+ };
+
+ if (isEmi) {
+ billingCycle.total_cycles = paymentPlan.emiTotalInstallments;
+ }
+
+ const payload = {
+ plan_id: planId,
+ custom_id: encodeMetadata(metadata),
+ plan: {
+ product_id: this.siteinfo.paypalProductId,
+ name: product.title,
+ billing_cycles: [
+ {
+ ...billingCycle,
+ frequency: {
+ interval_unit: intervalUnit,
+ interval_count: 1,
+ },
+ },
+ ],
+ },
+ application_context: {
+ return_url: `${origin}/checkout/verify?id=${metadata.invoiceId}`,
+ cancel_url: `${origin}/checkout?type=${product.type}&id=${product.id}`,
+ },
+ };
+
+ const response = await this.paypalFetch("/v1/billing/subscriptions", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+
+ const approvalLink = response?.links?.find(
+ (link: any) => link.rel === "approve",
+ )?.href;
+
+ if (!approvalLink) {
+ throw new Error("PayPal approval URL not found");
+ }
+
+ return approvalLink;
+ }
+
+ private formatAmount(amount: number) {
+ return amount.toFixed(2);
+ }
+
+ private async paypalFetch(path: string, init?: RequestInit) {
+ await this.ensureAccessToken();
+
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.accessToken}`,
+ ...(init?.headers || {}),
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(
+ `PayPal error: ${response.status} ${response.statusText} ${error}`,
+ );
+ }
+
+ if (response.status === 204) {
+ return true;
+ }
+
+ return await response.json();
+ }
+
+ private async ensureAccessToken() {
+ const cacheKey = buildCacheKey(this.siteinfo);
+ const cached = tokenCache.get(cacheKey);
+
+ if (cached && cached.expiresAt > Date.now()) {
+ this.accessToken = cached.accessToken;
+ this.baseUrl = cached.baseUrl;
+ return;
+ }
+
+ const liveToken = await this.tryAuthenticate(LIVE_BASE_URL);
+ const token =
+ liveToken || (await this.tryAuthenticate(SANDBOX_BASE_URL));
+
+ if (!token) {
+ throw new Error("Failed to authenticate with PayPal");
+ }
+
+ this.accessToken = token.accessToken;
+ this.baseUrl = token.baseUrl;
+
+ tokenCache.set(cacheKey, token);
+ }
+
+ private async tryAuthenticate(baseUrl: string) {
+ const basicToken = Buffer.from(
+ `${this.siteinfo.paypalClientId}:${this.siteinfo.paypalClientSecret}`,
+ ).toString("base64");
+
+ const response = await fetch(`${baseUrl}/v1/oauth2/token`, {
+ method: "POST",
+ headers: {
+ Authorization: `Basic ${basicToken}`,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: "grant_type=client_credentials",
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const result = await response.json();
+
+ return {
+ accessToken: result.access_token,
+ expiresAt: Date.now() + (result.expires_in - 60) * 1000,
+ baseUrl,
+ } satisfies TokenCacheEntry;
+ }
+}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index 0c8a70a02..79414dde0 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -103,7 +103,12 @@ export const SITE_ADMIN_SETTINGS_STRIPE_SECRET = "Stripe Secret Key";
export const SITE_ADMIN_SETTINGS_RAZORPAY_SECRET = "Razorpay Secret Key";
export const SITE_ADMIN_SETTINGS_RAZORPAY_WEBHOOK_SECRET =
"Razorpay Webhook Secret";
-export const SITE_ADMIN_SETTINGS_PAYPAL_SECRET = "Paypal Secret Key";
+export const SITE_ADMIN_SETTINGS_PAYPAL_CLIENT_SECRET = "PayPal Client Secret";
+export const SITE_SETTINGS_PAYPAL_CLIENT_ID_TEXT = "PayPal Client ID";
+export const SITE_SETTINGS_PAYPAL_PRODUCT_ID_TEXT = "PayPal Product ID";
+export const SITE_SETTINGS_PAYPAL_MONTHLY_PLAN_ID_TEXT =
+ "PayPal Monthly Plan ID";
+export const SITE_SETTINGS_PAYPAL_YEARLY_PLAN_ID_TEXT = "PayPal Yearly Plan ID";
export const SITE_ADMIN_SETTINGS_PAYTM_SECRET = "Paytm Secret Key";
export const SITE_SETTINGS_SECTION_GENERAL = "Branding";
export const SITE_SETTINGS_SECTION_PAYMENT = "Payment";
diff --git a/docs/wip/PAYPAL_INTEGRATION_PRD.md b/docs/wip/PAYPAL_INTEGRATION_PRD.md
new file mode 100644
index 000000000..feb90356c
--- /dev/null
+++ b/docs/wip/PAYPAL_INTEGRATION_PRD.md
@@ -0,0 +1,813 @@
+# PayPal Integration PRD
+
+## Document Control
+
+- Status: Draft
+- Last updated: April 19, 2026
+- Owner: Payments/Platform team
+- Target workspace: `apps/web` (`@courselit/web`)
+
+## Executive Summary
+
+CourseLit should add PayPal as a supported payment gateway alongside Stripe, Razorpay, and Lemon Squeezy.
+
+PayPal is a strong fit for one-time payments and an acceptable fit for subscriptions and EMI, provided we adopt a provider-managed recurring object model similar to our Lemon Squeezy approach:
+
+- one-time payments use PayPal Orders with dynamic pricing and product name
+- subscriptions use a generic PayPal product plus cadence-specific recurring plan templates
+- EMI uses the same monthly recurring template model with finite billing cycles
+
+This PRD recommends implementing PayPal in a way that is:
+
+- scalable for multi-tenant usage
+- secure against forged callbacks and tenant leakage
+- maintainable within the existing `payments-new` abstraction
+
+## Problem Statement
+
+CourseLit currently supports:
+
+- Stripe
+- Razorpay
+- Lemon Squeezy
+
+PayPal is already partially modeled in the codebase, but is not implemented:
+
+- `paypal` exists in payment method constants
+- `paypalSecret` exists in site settings types/schema
+- provider resolution throws `not implemented`
+- admin settings render a disabled PayPal credential field
+
+Relevant files:
+
+- [apps/web/payments-new/index.ts](/home/rajat/dev/proj/courselit/apps/web/payments-new/index.ts:21)
+- [packages/common-models/src/site-info.ts](/home/rajat/dev/proj/courselit/packages/common-models/src/site-info.ts:1)
+- [packages/orm-models/src/models/site-info.ts](/home/rajat/dev/proj/courselit/packages/orm-models/src/models/site-info.ts:1)
+- [apps/web/components/admin/settings/index.tsx](/home/rajat/dev/proj/courselit/apps/web/components/admin/settings/index.tsx:1106)
+
+The product requirement is that CourseLit must preserve its dynamic checkout behavior:
+
+- creator-defined product name at checkout
+- creator-defined price at checkout
+- no requirement to predefine one provider-side product per CourseLit product
+
+That model already works well with Stripe and Razorpay, and is partially approximated in Lemon Squeezy using a generic provider product and overrideable pricing. PayPal must fit this model as closely as possible without introducing operational fragility.
+
+## Goals
+
+1. Support PayPal for all existing paid CourseLit payment plan types:
+ - one-time
+ - subscription
+ - EMI
+2. Preserve dynamic price and display-name behavior at checkout.
+3. Reuse the existing `payments-new` provider abstraction with minimal cross-provider branching.
+4. Maintain tenant isolation for a multi-tenant application.
+5. Keep the admin setup flow understandable and low-friction.
+6. Ensure webhook verification, idempotency, and replay safety.
+7. Avoid uncontrolled growth of provider-side objects over time.
+8. Fit PayPal into the existing CourseLit payment architecture without changing existing payment constructs, plan semantics, invoice semantics, membership lifecycle semantics, or non-PayPal provider behavior.
+
+## Non-Goals
+
+- Replacing or redesigning the existing payment architecture
+- Refactoring existing Stripe, Razorpay, or Lemon Squeezy flows to make PayPal fit
+- Changing existing CourseLit payment plan constructs or introducing new plan types for PayPal
+- Changing current invoice, membership, checkout, or webhook abstractions unless a change is strictly additive and backward-compatible
+- Building provider-agnostic recurring object storage for all gateways in phase 1
+- Supporting PayPal marketplace payouts or partner/referral flows
+- Supporting multiple active payment gateways per tenant at the same time
+- Migrating existing Stripe, Razorpay, or Lemon Squeezy subscriptions to PayPal
+
+## Current State
+
+Current provider behavior in `payments-new`:
+
+- Stripe creates checkout sessions with inline recurring price data and dynamic product names.
+- Razorpay creates orders or recurring plans/subscriptions on the fly.
+- Lemon Squeezy relies on a generic provider product with stored variant IDs and overrides custom price/name at checkout.
+
+Relevant implementation references:
+
+- [apps/web/payments-new/stripe-payment.ts](/home/rajat/dev/proj/courselit/apps/web/payments-new/stripe-payment.ts:43)
+- [apps/web/payments-new/razorpay-payment.ts](/home/rajat/dev/proj/courselit/apps/web/payments-new/razorpay-payment.ts:97)
+- [apps/web/payments-new/lemonsqueezy-payment.ts](/home/rajat/dev/proj/courselit/apps/web/payments-new/lemonsqueezy-payment.ts:53)
+- [apps/web/app/api/payment/initiate/route.ts](/home/rajat/dev/proj/courselit/apps/web/app/api/payment/initiate/route.ts:1)
+- [apps/web/app/api/payment/webhook/route.ts](/home/rajat/dev/proj/courselit/apps/web/app/api/payment/webhook/route.ts:1)
+- [apps/web/components/public/payments/checkout.tsx](/home/rajat/dev/proj/courselit/apps/web/components/public/payments/checkout.tsx:174)
+
+## Product and API Constraints
+
+### Architectural constraint
+
+PayPal must be designed within the boundaries of the payment abstractions already present in CourseLit.
+
+This means:
+
+- no redesign of the `Payment` interface purely for PayPal
+- no change to the meaning of existing CourseLit payment plan types
+- no change to the existing `initiate -> invoice pending -> webhook verify -> invoice paid -> membership activate` lifecycle
+- no change to existing provider implementations except additive registration and shared hardening that is independently valuable
+- no PayPal-driven rewrite of checkout UI beyond provider-specific redirect handling
+
+If PayPal cannot fit a behavior cleanly into these existing abstractions, the preferred response is to constrain the PayPal implementation, not to expand the global abstraction surface in phase 1.
+
+### One-time payments
+
+PayPal Orders supports dynamic purchase units, item names, invoice IDs, and custom identifiers. This is a good fit for CourseLit one-time payments.
+
+### Subscriptions
+
+PayPal recurring billing requires a billing plan, and plans require a product. This is not as flexible as Stripe inline recurring pricing, but it is conceptually close to our Lemon Squeezy model.
+
+Important implication:
+
+- we should not attempt to use a single recurring template for all cadences
+- monthly and yearly recurring flows should use different base plan templates
+
+### EMI
+
+PayPal recurring plans support finite billing cycles. This is a strong fit for EMI because EMI in CourseLit is already modeled as a monthly recurring charge with a fixed number of installments.
+
+## Proposed Product Model
+
+CourseLit should implement PayPal using the following model:
+
+### One-time
+
+- create a PayPal Order dynamically at checkout
+- set dynamic amount
+- set dynamic item name
+- set `invoice_id` and `custom_id` for CourseLit invoice/membership tracking
+- redirect buyer to PayPal approval flow
+
+### Subscription
+
+- configure one generic PayPal product per tenant
+- configure one monthly recurring billing plan template per tenant
+- configure one yearly recurring billing plan template per tenant
+- create subscriptions from the relevant template
+- override price and subscriber-facing metadata at subscription creation where PayPal allows
+
+### EMI
+
+- reuse the monthly recurring plan template
+- create a subscription with finite monthly billing cycles equal to `emiTotalInstallments`
+- use the per-cycle amount from `emiAmount`
+
+This is intentionally similar to Lemon Squeezy:
+
+- separate base recurring objects by cadence
+- override dynamic business values per checkout where allowed
+- avoid one provider object per CourseLit product
+
+## Recommended Tenant Configuration Model
+
+The current `paypalSecret` field is not enough. Phase 1 should expand site settings to support a secure and maintainable PayPal integration.
+
+### Required settings
+
+- `paypalClientId`
+- `paypalClientSecret`
+
+### Recommended recurring object settings
+
+- `paypalProductId`
+- `paypalMonthlyPlanId`
+- `paypalYearlyPlanId`
+
+### Optional future settings
+
+- `paypalBrandName`
+- `paypalMerchantCountry`
+
+### Why store template IDs instead of creating plans every time
+
+Storing template IDs gives us:
+
+- predictable recurring cadence behavior
+- less API churn
+- easier debugging and support
+- lower risk of runaway provider object creation
+
+This is better for scalability and maintainability than creating a brand-new plan for every CourseLit checkout.
+
+## UX and Setup Flow
+
+Admin setup should feel closer to Lemon Squeezy than Stripe.
+
+### Admin configuration flow
+
+1. Merchant connects PayPal credentials.
+2. Merchant selects PayPal as payment method.
+3. Merchant creates a generic PayPal product and cadence-specific recurring plans in the PayPal portal.
+4. Merchant enters the existing PayPal product/plan template IDs in CourseLit settings.
+5. Merchant configures webhook in PayPal.
+6. CourseLit validates the configuration before saving.
+
+Admin UX requirement:
+
+- PayPal settings should reuse the same admin UX pattern as Lemon Squeezy.
+- The UI should use explicitly labeled fields for the generic PayPal product ID, monthly plan ID, and yearly plan ID.
+- The UX should make it clear that these IDs are created in the PayPal portal and then pasted into CourseLit.
+- CourseLit should accept a single set of PayPal credentials and IDs, just like the app does for other gateways.
+- CourseLit should not introduce separate app-level live/test environment toggles for PayPal in phase 1.
+
+### Product decision
+
+CourseLit should not support auto-provisioning of PayPal products or recurring plan templates.
+
+The merchant should create the generic PayPal product and the recurring plan templates in the PayPal portal, then copy their IDs into CourseLit settings.
+
+This is intentional and should mirror the existing Lemon Squeezy operating model:
+
+- CourseLit does not provision provider-side catalog objects on behalf of the merchant
+- the merchant remains the source of truth for provider-side product and plan setup
+- CourseLit stores and reuses the IDs needed for checkout initiation
+
+## Detailed Functional Requirements
+
+### FR1: Payment method availability
+
+When a tenant selects PayPal and provides valid configuration, CourseLit should allow paid plans to be purchased via PayPal.
+
+### FR2: One-time initiation
+
+For one-time plans, initiating payment should:
+
+- create a PayPal Order
+- include dynamic price
+- include dynamic product title
+- persist a pending CourseLit invoice before redirect
+- return enough information for frontend approval redirect
+
+### FR3: Subscription initiation
+
+For monthly and yearly subscriptions, initiating payment should:
+
+- choose the correct base PayPal plan template by cadence
+- create a PayPal subscription approval session using CourseLit invoice/membership metadata
+- persist a pending CourseLit invoice before redirect
+
+### FR4: EMI initiation
+
+For EMI plans, initiating payment should:
+
+- use the monthly recurring base template
+- set finite cycle count equal to `emiTotalInstallments`
+- set cycle price equal to `emiAmount`
+- persist a pending CourseLit invoice before redirect
+
+### FR5: Verification
+
+PayPal payments must continue to use the existing server-side webhook confirmation flow and must not trust browser redirects alone.
+
+Webhook signature/authenticity verification is out of scope for this PRD and will be handled in a separate cross-provider follow-up.
+
+### FR6: Membership activation
+
+Membership activation must continue to flow through the existing invoice + webhook pipeline, preserving current behavior for:
+
+- active membership granting
+- included products
+- EMI completion handling
+- recurring invoice processing
+
+### FR7: Subscription cancellation
+
+For subscription and EMI memberships, CourseLit should be able to cancel the PayPal subscription using the stored subscription ID.
+
+### FR8: Subscription validation
+
+The provider must support validation of subscription status when a previously active membership re-enters checkout.
+
+## Proposed Technical Design
+
+### 1. Provider implementation
+
+Add:
+
+- `apps/web/payments-new/paypal-payment.ts`
+
+This provider should implement the existing `Payment` interface:
+
+- `setup`
+- `initiate`
+- `verify`
+- `getPaymentIdentifier`
+- `getMetadata`
+- `getName`
+- `cancel`
+- `getSubscriptionId`
+- `validateSubscription`
+- `getCurrencyISOCode`
+
+Implementation rule:
+
+- PayPal must conform to the current `Payment` contract as it exists today.
+- Phase 1 should not broaden the interface solely to express PayPal-specific concepts.
+- If PayPal needs intermediate helper methods, they should remain internal to `paypal-payment.ts`.
+
+### 2. Provider selection
+
+Update:
+
+- [apps/web/payments-new/index.ts](/home/rajat/dev/proj/courselit/apps/web/payments-new/index.ts:21)
+
+Behavior:
+
+- remove the `not implemented` branch for PayPal
+- instantiate and set up `PayPalPayment`
+
+Constraint:
+
+- provider selection should remain a simple extension of the existing switch-based model
+- no new provider registry framework or payment architecture rewrite is needed for PayPal
+
+### 3. Checkout response shape
+
+Current provider return values differ:
+
+- Stripe returns a session ID
+- Razorpay returns an order/subscription ID
+- Lemon Squeezy returns a checkout URL
+
+PayPal will likely return either:
+
+- approval URL, or
+- an order/subscription ID plus approval URL
+
+Recommendation:
+
+- preserve backward compatibility in phase 1
+- return `paymentTracker` as the approval URL for PayPal
+- use provider-specific frontend handling similar to Lemon Squeezy redirect behavior
+
+Constraint:
+
+- do not redesign the payment initiation route contract to normalize all providers in phase 1
+- PayPal should adapt to the current route response pattern the same way existing providers already do
+
+### 4. Frontend checkout integration
+
+Update:
+
+- [apps/web/components/public/payments/checkout.tsx](/home/rajat/dev/proj/courselit/apps/web/components/public/payments/checkout.tsx:174)
+
+Behavior:
+
+- when `paymentMethod === paypal`
+- redirect the browser to the returned approval URL
+
+Phase 1 recommendation:
+
+- use redirect-based PayPal approval
+- do not add PayPal smart buttons in phase 1
+
+Reason:
+
+- simpler integration
+- smaller frontend surface area
+- lower maintenance cost
+- consistent with current redirect-based Stripe and Lemon Squeezy flows
+
+Constraint:
+
+- do not restructure checkout into a provider plugin system for this feature
+- do not move existing Stripe, Razorpay, or Lemon Squeezy logic just to create symmetry for PayPal
+
+### 5. Webhook handling
+
+The existing route:
+
+- [apps/web/app/api/payment/webhook/route.ts](/home/rajat/dev/proj/courselit/apps/web/app/api/payment/webhook/route.ts:1)
+
+should remain the central payment confirmation path.
+
+PayPal support requires:
+
+- parsing PayPal event payloads
+- mapping approved/captured payments and subscription billings to CourseLit invoice semantics
+
+Constraint:
+
+- keep the existing webhook route as the shared orchestration point
+- keep provider-specific translation logic inside the PayPal provider where practical
+- do not introduce a new parallel payment lifecycle just for PayPal
+
+Current-state note:
+
+- CourseLit does not yet have a standardized webhook signature verification baseline across all providers.
+- PayPal should not introduce provider-specific webhook signature verification in this work.
+- Webhook authenticity verification is intentionally deferred to a separate cross-provider follow-up.
+- In the current `payments-new` abstraction, `Payment.verify(event)` is not a cryptographic signature-verification hook.
+- `Payment.verify(event)` should continue to mean "does this payload represent a relevant and processable payment event for this provider?"
+- When the later cross-provider verification work lands, any authenticity check must happen before the existing `Payment.verify(event)` shape/content check runs.
+
+### 6. Metadata strategy
+
+CourseLit currently depends on metadata flowing back from providers.
+
+Required CourseLit metadata:
+
+- `membershipId`
+- `invoiceId`
+- `currencyISOCode`
+
+Recommended PayPal metadata mapping:
+
+- store CourseLit invoice ID in `invoice_id` when supported
+- store membership/invoice identifiers in `custom_id` when supported
+- if webhook payloads do not include all required metadata directly, fetch the underlying order/subscription resource during verification
+
+### 7. EMI completion behavior
+
+Current EMI completion logic in the webhook route counts paid invoices and cancels the underlying provider subscription after the configured installment count is reached.
+
+Relevant code:
+
+- [apps/web/app/api/payment/webhook/route.ts](/home/rajat/dev/proj/courselit/apps/web/app/api/payment/webhook/route.ts:58)
+
+For PayPal, we should still keep this app-side safeguard even if PayPal plan cycles are finite.
+
+Reason:
+
+- defense in depth
+- protects against plan misconfiguration
+- preserves consistent provider-agnostic EMI semantics
+
+## Scalability Review
+
+This section records explicit design choices for scalability.
+
+### 1. Avoid per-checkout recurring object creation
+
+Do not create a fresh PayPal product or billing plan for every CourseLit checkout.
+
+Why:
+
+- unbounded provider-side object growth
+- harder support/debugging
+- noisier merchant dashboards
+- more API calls and slower checkout initiation
+
+Preferred model:
+
+- one product per tenant
+- one monthly plan template per tenant
+- one yearly plan template per tenant
+
+### 2. Keep provider object lifecycle tenant-scoped
+
+Each tenant (data model: Domain) must use their own PayPal credentials and template IDs.
+
+Do not share any PayPal product or plan IDs across tenants.
+
+Why:
+
+- avoids cross-tenant data leakage
+- simplifies support
+- aligns with merchant ownership expectations
+
+### 3. Cache access tokens carefully
+
+PayPal OAuth access tokens should be cached in-memory per app instance with TTL based on provider expiry.
+
+Requirements:
+
+- cache per tenant credential pair
+- refresh on expiry
+- never persist access tokens to MongoDB
+
+Current-state note:
+
+- CourseLit receives only one set of PayPal credentials from the tenant configuration.
+- The application should treat those credentials as the active credentials without introducing app-level awareness of whether they are sandbox or live.
+- Any sandbox vs live distinction remains implicit in the credentials provided by the merchant.
+
+### 4. Minimize webhook follow-up calls
+
+Webhook processing should avoid unnecessary PayPal API fetches, but may fetch provider resources when needed for metadata recovery or status verification.
+
+Guideline:
+
+- trust verified webhook payload first
+- fetch provider resources only when payload data is incomplete or ambiguous
+
+### 5. Preserve idempotency in invoice handling
+
+Webhook processing must remain safe for retries and duplicate event delivery.
+
+The current invoice flow is already partly idempotent via pending invoice lookup and paid invoice creation logic. PayPal implementation must not weaken this.
+
+## Security Review
+
+This section records required security controls.
+
+### 1. Webhook authenticity
+
+Webhook authenticity verification is out of scope for this PRD.
+
+Current-state note:
+
+- CourseLit does not yet enforce a standardized webhook signature verification baseline across providers.
+- PayPal should not add a one-off provider-specific verification mechanism ahead of the planned cross-provider follow-up.
+- The existing implementation should continue to treat webhook payloads according to the current application model until that follow-up lands.
+- Any later authenticity verification must remain separate from the current `Payment.verify(event)` contract.
+
+### 2. Never trust browser redirects as payment proof
+
+Success redirects from PayPal approval pages are not sufficient proof of payment.
+
+Only webhook events received on the server should:
+
+- mark invoices as paid
+- attach provider transaction IDs
+- activate memberships
+
+### 3. Secret handling
+
+The following fields are secrets:
+
+- `paypalClientSecret`
+- any webhook verification secret material if later introduced
+
+Requirements:
+
+- never expose in GraphQL reads
+- never send to client components
+- redact from logs
+
+### 4. Tenant isolation
+
+Webhook processing must resolve the correct tenant before provider verification and state mutation.
+
+Requirements:
+
+- maintain domain-based tenant routing
+- ensure provider credentials and template IDs come from the resolved tenant only
+- never attempt fallback across tenants
+
+### 5. Replay safety
+
+PayPal webhooks may be retried. Duplicate delivery must not create duplicate paid invoices or inconsistent membership state.
+
+Recommended controls:
+
+- store processed event IDs for a bounded retention window, or
+- rely on invoice idempotency plus transaction ID uniqueness checks
+
+Preferred direction:
+
+- add event ID dedupe for PayPal webhook events in phase 1 if effort is reasonable
+- keep this dedupe logic PayPal-specific in phase 1
+- do not expand it into a generic cross-provider payment-webhook dedupe facility as part of this work
+
+### 6. Logging hygiene
+
+Do not log:
+
+- access tokens
+- client secrets
+- full request bodies containing payer details
+- raw provider headers unless explicitly sanitized
+
+Allowed log context:
+
+- domain
+- provider event type
+- CourseLit invoice ID
+- CourseLit membership ID
+- PayPal resource ID
+
+## Maintainability Review
+
+This section records choices that reduce long-term cost.
+
+### 1. Keep provider-specific behavior inside provider classes
+
+PayPal-specific initiation, cancellation, verification, and metadata translation should live in `paypal-payment.ts`, not in generic routes.
+
+The generic routes should continue to depend on the `Payment` interface only.
+
+This is a hard requirement for PayPal in phase 1:
+
+- fit PayPal into the current abstraction
+- do not reshape the abstraction around PayPal
+- do not change the meaning of `Payment.verify(event)` from payload/event validation into cryptographic signature verification
+
+### 2. Do not special-case too much in checkout UI
+
+Frontend behavior should stay minimal:
+
+- initiate on backend
+- receive redirect target
+- navigate
+
+Avoid embedding heavy provider business logic in React components.
+
+Existing providers should remain behaviorally untouched unless an additive refactor benefits all providers and is independently justified.
+
+### 3. Expand settings model deliberately
+
+Avoid introducing a large number of low-level PayPal knobs in phase 1.
+
+Recommended minimum stable config:
+
+- credentials
+- generic product/plan template IDs
+
+Do not add auto-provisioning flows, background synchronization, or provider object management screens in phase 1.
+
+### 4. Preserve provider-agnostic EMI semantics
+
+Even though PayPal supports finite cycles, CourseLit should continue to reason about EMI using its own plan model and invoice counts.
+
+This keeps the business rules understandable and consistent across gateways.
+
+### 5. Add focused tests near existing payment tests
+
+Do not sprawl test coverage into many new files if existing payment tests can be extended.
+
+## Data Model Changes
+
+### Site settings additions
+
+Add to common model, ORM schema, and GraphQL payment settings input:
+
+- `paypalClientId?: string`
+- `paypalClientSecret?: string`
+- `paypalProductId?: string`
+- `paypalMonthlyPlanId?: string`
+- `paypalYearlyPlanId?: string`
+
+### Backward compatibility
+
+Keep `paypalSecret` temporarily only if required for migration compatibility.
+
+Recommendation:
+
+- replace legacy `paypalSecret` with explicit `paypalClientSecret`
+- no dedicated migration path is required because there are no active users of the legacy PayPal field
+
+## API and Object Lifecycle Strategy
+
+### One-time lifecycle
+
+1. CourseLit creates pending invoice.
+2. PayPal Order is created dynamically.
+3. Buyer approves payment on PayPal.
+4. Webhook event marks invoice paid through the existing server-side payment flow.
+5. Membership activates.
+
+### Subscription lifecycle
+
+1. CourseLit chooses monthly or yearly template.
+2. CourseLit creates a PayPal subscription approval flow from the template.
+3. Buyer approves subscription.
+4. Webhook event marks invoice paid through the existing server-side payment flow.
+5. Subscription ID is stored on membership.
+6. Future billing webhooks create/mark later invoices.
+
+### EMI lifecycle
+
+1. CourseLit chooses monthly template.
+2. CourseLit creates subscription approval flow with finite cycles and EMI amount.
+3. Buyer approves.
+4. Webhook event marks installment invoices paid through the existing server-side payment flow.
+5. CourseLit cancels as safeguard after expected count if still active.
+
+## Rollout Plan
+
+### Phase 1: Foundation
+
+- Add settings fields and validation.
+- Add PayPal provider implementation.
+- Add one-time payment support.
+- Add redirect-based frontend flow.
+- Reuse the existing webhook-driven payment confirmation flow.
+
+#### Task checklist
+
+- [ ] Add PayPal settings fields to `packages/common-models/src/site-info.ts`.
+- [ ] Add PayPal settings fields to `packages/orm-models/src/models/site-info.ts`.
+- [ ] Add PayPal settings fields to GraphQL settings types and payment update input.
+- [ ] Update payment settings validation to validate PayPal-specific required fields.
+- [ ] Ensure PayPal secrets are excluded from settings read responses.
+- [ ] Add `apps/web/payments-new/paypal-payment.ts` implementing the existing `Payment` interface.
+- [ ] Add OAuth token acquisition and in-memory token caching inside the PayPal provider.
+- [ ] Add one-time order creation flow in the PayPal provider.
+- [ ] Register PayPal in `apps/web/payments-new/index.ts`.
+- [ ] Update payment initiation flow only as needed to support PayPal return data without changing global abstractions.
+- [ ] Update checkout UI to redirect buyers to PayPal approval URLs.
+- [ ] Extend the shared payment webhook route to accept PayPal events through the current payment-processing model.
+- [ ] Add or update tests covering PayPal settings validation.
+- [ ] Add or update tests covering one-time PayPal initiation and webhook confirmation.
+
+### Phase 2: Recurring payments
+
+- Add monthly subscription support.
+- Add yearly subscription support.
+- Add EMI support with finite cycles.
+- Add recurring webhook handling and cancellation paths.
+
+#### Task checklist
+
+- [ ] Implement monthly subscription initiation using the stored PayPal monthly plan template.
+- [ ] Implement yearly subscription initiation using the stored PayPal yearly plan template.
+- [ ] Implement EMI initiation using the monthly plan template plus finite cycle configuration.
+- [ ] Ensure subscriptions and EMI attach enough CourseLit metadata for invoice and membership reconciliation.
+- [ ] Implement PayPal subscription ID extraction and persistence through the existing membership flow.
+- [ ] Implement PayPal subscription cancellation inside the provider.
+- [ ] Implement PayPal subscription status validation inside the provider.
+- [ ] Extend webhook handling for recurring payment success events.
+- [ ] Extend webhook handling for recurring payment failure, cancellation, and expiry events where needed.
+- [ ] Ensure EMI completion continues to use CourseLit invoice counting plus cancellation safeguard.
+- [ ] Add or update tests covering monthly subscriptions.
+- [ ] Add or update tests covering yearly subscriptions.
+- [ ] Add or update tests covering EMI flows.
+- [ ] Add or update tests covering cancellation and subscription validation behavior.
+- [ ] Add duplicate-event or replay-safety coverage for PayPal recurring webhooks.
+
+### Phase 3: Operator ergonomics
+
+- Improve settings UI guidance and validation.
+- Add docs and troubleshooting guidance.
+
+#### Task checklist
+
+- [ ] Update admin settings UI labels and help text for PayPal fields.
+- [ ] Align the PayPal setup UX with the existing Lemon Squeezy manual-ID pattern.
+- [ ] Add inline validation or guidance for generic product ID, monthly plan ID, yearly plan ID, and webhook ID fields.
+- [ ] Add troubleshooting guidance for common PayPal misconfiguration cases.
+- [ ] Review logs and error messages for operator usefulness without leaking secrets.
+- [ ] Review the implementation against this PRD’s security requirements.
+- [ ] Review the implementation against this PRD’s scalability requirements.
+- [ ] Review the implementation against this PRD’s maintainability requirements.
+- [ ] Confirm that no existing payment construct or provider flow was changed solely to accommodate PayPal.
+- [ ] Add documentation for PayPal to set-up-payments in both docs and docs-new.
+
+## Testing Plan
+
+### Unit tests
+
+- provider setup validation
+- one-time initiation payload formation
+- subscription template selection
+- EMI template selection and finite cycle payload formation
+- metadata extraction
+- cancellation behavior
+- subscription validation behavior
+
+### Route tests
+
+- payment initiation with PayPal selected
+- webhook rejects invalid signature
+- webhook accepts valid event and marks invoice paid
+- duplicate webhook does not duplicate invoice state transitions
+
+### Settings tests
+
+- invalid PayPal settings fail validation
+- valid PayPal settings are accepted
+- secrets remain excluded from public settings reads
+
+### Manual staging checks
+
+- one-time purchase completes successfully
+- monthly subscription activates successfully
+- yearly subscription activates successfully
+- EMI completes correct number of installments
+- cancellation from CourseLit propagates to PayPal
+- failed or duplicate webhook deliveries are harmless
+
+## Acceptance Criteria
+
+1. Tenants can configure PayPal credentials and recurring template IDs from settings.
+2. One-time PayPal checkout works with dynamic CourseLit product name and price.
+3. Monthly subscriptions work using the monthly recurring template.
+4. Yearly subscriptions work using the yearly recurring template.
+5. EMI works using monthly recurring billing with finite cycles.
+6. Server-side webhook processing, not browser redirects, drives invoice payment and membership activation.
+7. PayPal integration does not require one provider-side recurring object per CourseLit product.
+8. Secrets are never exposed to public settings queries or client code.
+9. Duplicate webhook deliveries do not produce duplicate successful payments.
+10. The implementation fits the existing `payments-new` abstraction without broad payment-route rewrites.
+11. No existing CourseLit payment construct, provider flow, or global payment abstraction is changed solely to accommodate PayPal.
+12. `Payment.verify(event)` remains a provider-specific payload relevance/shape check and is not repurposed into webhook signature verification.
+
+## Risks and Mitigations
+
+- PayPal recurring overrides may be more restrictive than expected:
+ - Mitigation: use cadence-specific plan templates and avoid relying on deep plan mutation.
+- Merchant setup may be too complex:
+ - Mitigation: keep setup aligned with Lemon Squeezy and provide strong admin UI guidance plus step-by-step docs.
+- Webhook authenticity is not addressed in this PRD:
+ - Mitigation: keep PayPal aligned with the current provider model and handle signature verification in the planned cross-provider follow-up.
+- Legacy `paypalSecret` field may cause confusion:
+ - Mitigation: deprecate clearly and migrate to explicit credential field names.
+- Provider API differences may push branching into UI:
+ - Mitigation: keep checkout UI to simple redirect handling only.
+
+## Open Questions
+
+No open questions at this time.
diff --git a/packages/common-models/src/site-info.ts b/packages/common-models/src/site-info.ts
index e432dce20..00b9b0c82 100644
--- a/packages/common-models/src/site-info.ts
+++ b/packages/common-models/src/site-info.ts
@@ -13,7 +13,8 @@ export default interface SiteInfo {
codeInjectionBody?: string;
stripeSecret?: string;
stripeWebhookSecret?: string;
- paypalSecret?: string;
+ paypalClientId?: string;
+ paypalClientSecret?: string;
paytmSecret?: string;
mailingAddress?: string;
hideCourseLitBranding?: boolean;
@@ -26,6 +27,9 @@ export default interface SiteInfo {
lemonsqueezySubscriptionMonthlyVariantId?: string;
lemonsqueezySubscriptionYearlyVariantId?: string;
lemonsqueezyWebhookSecret?: string;
+ paypalProductId?: string;
+ paypalMonthlyPlanId?: string;
+ paypalYearlyPlanId?: string;
logins?: LoginProvider[];
ssoTrustedDomain?: string;
}
diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts
index 3430532a8..05ef44bbe 100644
--- a/packages/orm-models/src/models/site-info.ts
+++ b/packages/orm-models/src/models/site-info.ts
@@ -13,7 +13,8 @@ export const SettingsSchema = new mongoose.Schema({
codeInjectionBody: { type: String },
stripeSecret: { type: String },
paytmSecret: { type: String },
- paypalSecret: { type: String },
+ paypalClientId: { type: String },
+ paypalClientSecret: { type: String },
mailingAddress: { type: String },
hideCourseLitBranding: { type: Boolean, default: false },
razorpayKey: { type: String },
@@ -25,6 +26,9 @@ export const SettingsSchema = new mongoose.Schema({
lemonsqueezyOneTimeVariantId: { type: String },
lemonsqueezySubscriptionMonthlyVariantId: { type: String },
lemonsqueezySubscriptionYearlyVariantId: { type: String },
+ paypalProductId: { type: String },
+ paypalMonthlyPlanId: { type: String },
+ paypalYearlyPlanId: { type: String },
logins: { type: [String], enum: Object.values(Constants.LoginProvider) },
ssoTrustedDomain: { type: String },
});