From 4833a9aa74cf966fddd6933a25038820e8986972 Mon Sep 17 00:00:00 2001 From: Rajat Date: Mon, 20 Apr 2026 12:42:04 +0530 Subject: [PATCH 1/4] PayPal PRD --- docs/wip/PAYPAL_INTEGRATION_PRD.md | 813 +++++++++++++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 docs/wip/PAYPAL_INTEGRATION_PRD.md 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. From 78e176afef9032de31a7290e79f1fe3695eb685c Mon Sep 17 00:00:00 2001 From: Rajat Date: Mon, 20 Apr 2026 18:40:05 +0530 Subject: [PATCH 2/4] wip: paypal integration (implemented but not tested) --- .../content/docs/schools/set-up-payments.mdx | 32 ++ apps/docs-new/next-env.d.ts | 2 +- .../src/pages/en/schools/set-up-payments.md | 32 ++ .../payment/initiate/__tests__/route.test.ts | 42 ++ apps/web/app/api/payment/initiate/route.ts | 1 + .../payment/vendor/paypal/capture/route.ts | 46 +++ apps/web/components/admin/settings/index.tsx | 107 ++++- .../components/public/payments/checkout.tsx | 3 + .../settings/__tests__/payment.test.ts | 28 ++ apps/web/graphql/settings/helpers.ts | 8 +- apps/web/graphql/settings/logic.ts | 2 +- apps/web/graphql/settings/types.ts | 10 +- .../__tests__/paypal-payment.test.ts | 151 +++++++ apps/web/payments-new/index.ts | 11 +- apps/web/payments-new/payment.ts | 2 + apps/web/payments-new/paypal-payment.ts | 388 ++++++++++++++++++ apps/web/ui-config/strings.ts | 7 +- packages/common-models/src/site-info.ts | 6 +- packages/orm-models/src/models/site-info.ts | 6 +- 19 files changed, 858 insertions(+), 26 deletions(-) create mode 100644 apps/web/app/api/payment/vendor/paypal/capture/route.ts create mode 100644 apps/web/payments-new/__tests__/paypal-payment.test.ts create mode 100644 apps/web/payments-new/paypal-payment.ts 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/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 }, }); From 7b84b4711543585bf2f791da0797ed9f2073b46b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 10 Jul 2026 19:03:45 +0000 Subject: [PATCH 3/4] fix: add missing await to reCAPTCHA validation in login forms The validateRecaptcha() function is async and returns a Promise, but was called without await on both the checkout login form and the main login form. Since a Promise object is always truthy, !Promise is always false, which meant the reCAPTCHA check was effectively bypassed. This fix adds the missing await keyword so reCAPTCHA validation actually runs when reCAPTCHA env vars are configured. --- apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx | 2 +- apps/web/components/public/payments/login-form.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index 23e1ca455..98cc24159 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -175,7 +175,7 @@ export default function LoginForm({ setLoading(true); setError(""); - if (!validateRecaptcha()) { + if (!(await validateRecaptcha())) { return; } diff --git a/apps/web/components/public/payments/login-form.tsx b/apps/web/components/public/payments/login-form.tsx index ff800b2c4..ee3ca6d8c 100644 --- a/apps/web/components/public/payments/login-form.tsx +++ b/apps/web/components/public/payments/login-form.tsx @@ -158,7 +158,7 @@ export function LoginForm({ const requestCode = async function (email: string) { setLoading(true); - if (!validateRecaptcha()) { + if (!(await validateRecaptcha())) { return; } From c7569f09a02cd8d277518117685dfaa61a518311 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 10 Jul 2026 19:37:08 +0000 Subject: [PATCH 4/4] revert: recaptcha fix doesn't belong on this branch --- apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx | 2 +- apps/web/components/public/payments/login-form.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index 98cc24159..23e1ca455 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -175,7 +175,7 @@ export default function LoginForm({ setLoading(true); setError(""); - if (!(await validateRecaptcha())) { + if (!validateRecaptcha()) { return; } diff --git a/apps/web/components/public/payments/login-form.tsx b/apps/web/components/public/payments/login-form.tsx index ee3ca6d8c..ff800b2c4 100644 --- a/apps/web/components/public/payments/login-form.tsx +++ b/apps/web/components/public/payments/login-form.tsx @@ -158,7 +158,7 @@ export function LoginForm({ const requestCode = async function (email: string) { setLoading(true); - if (!(await validateRecaptcha())) { + if (!validateRecaptcha()) { return; }