diff --git a/template/app/.env.server.example b/template/app/.env.server.example index 65d02b9e1..ad9854a3b 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -3,6 +3,9 @@ # If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here. # DATABASE_URL= +# Supports Stripe, LemonSqueezy, Polar +PAYMENT_PROCESSOR_ID=Stripe + # For testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..." STRIPE_API_KEY=sk_test_... # After downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/payments-webhook` it will output your signing secret @@ -17,6 +20,17 @@ LEMONSQUEEZY_STORE_ID=012345 # define your own webhook secret when creating a new webhook on https://app.lemonsqueezy.com/settings/webhooks LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret +# After creating an organization, you can find your organization id in the organization settings https://sandbox.polar.sh/dashboard/[your org slug]/settings +POLAR_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000 +# Generate a token at https://sandbox.polar.sh/dashboard/[your org slug]/settings +POLAR_ACCESS_TOKEN=polar_oat_... +# Define your own webhook secret when creating a new webhook at https://sandbox.polar.sh/dashboard/[your org slug]/settings/webhooks +POLAR_WEBHOOK_SECRET=polar_whs_... +# The unauthenticated URL is at https://sandbox.polar.sh/[your org slug]/portal +POLAR_CUSTOMER_PORTAL_URL=https://sandbox.polar.sh/.../portal +# For production, set this to false, then generate a new organization and products from the live dashboard +POLAR_SANDBOX_MODE=true + # If using Stripe, go to https://dashboard.stripe.com/test/products and click on + Add Product # If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345 diff --git a/template/app/main.wasp b/template/app/main.wasp index d1b3ea365..aefef864f 100644 --- a/template/app/main.wasp +++ b/template/app/main.wasp @@ -79,6 +79,10 @@ app OpenSaaS { ] }, + server: { + envValidationSchema: import { envValidationSchema } from "@src/server/validation", + }, + client: { rootComponent: import App from "@src/client/App", }, diff --git a/template/app/package.json b/template/app/package.json index adb0b6119..ea50b0aa8 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -9,6 +9,8 @@ "@headlessui/react": "1.7.13", "@hookform/resolvers": "^5.1.1", "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", + "@polar-sh/express": "^0.3.2", + "@polar-sh/sdk": "^0.34.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", diff --git a/template/app/src/analytics/stats.ts b/template/app/src/analytics/stats.ts index 57e73986b..9a167da58 100644 --- a/template/app/src/analytics/stats.ts +++ b/template/app/src/analytics/stats.ts @@ -1,8 +1,5 @@ import { type DailyStats } from 'wasp/entities'; import { type DailyStatsJob } from 'wasp/server/jobs'; -import Stripe from 'stripe'; -import { stripe } from '../payment/stripe/stripeClient'; -import { listOrders } from '@lemonsqueezy/lemonsqueezy.js'; import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils'; // import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils'; import { paymentProcessor } from '../payment/paymentProcessor'; @@ -42,18 +39,7 @@ export const calculateDailyStats: DailyStatsJob = async (_args, con paidUserDelta -= yesterdaysStats.paidUserCount; } - let totalRevenue; - switch (paymentProcessor.id) { - case 'stripe': - totalRevenue = await fetchTotalStripeRevenue(); - break; - case 'lemonsqueezy': - totalRevenue = await fetchTotalLemonSqueezyRevenue(); - break; - default: - throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`); - } - + const totalRevenue = await paymentProcessor.getTotalRevenue(); const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews(); let dailyStats = await context.entities.DailyStats.findUnique({ @@ -130,71 +116,3 @@ export const calculateDailyStats: DailyStatsJob = async (_args, con }); } }; - -async function fetchTotalStripeRevenue() { - let totalRevenue = 0; - let params: Stripe.BalanceTransactionListParams = { - limit: 100, - // created: { - // gte: startTimestamp, - // lt: endTimestamp - // }, - type: 'charge', - }; - - let hasMore = true; - while (hasMore) { - const balanceTransactions = await stripe.balanceTransactions.list(params); - - for (const transaction of balanceTransactions.data) { - if (transaction.type === 'charge') { - totalRevenue += transaction.amount; - } - } - - if (balanceTransactions.has_more) { - // Set the starting point for the next iteration to the last object fetched - params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id; - } else { - hasMore = false; - } - } - - // Revenue is in cents so we convert to dollars (or your main currency unit) - return totalRevenue / 100; -} - -async function fetchTotalLemonSqueezyRevenue() { - try { - let totalRevenue = 0; - let hasNextPage = true; - let currentPage = 1; - - while (hasNextPage) { - const { data: response } = await listOrders({ - filter: { - storeId: process.env.LEMONSQUEEZY_STORE_ID, - }, - page: { - number: currentPage, - size: 100, - }, - }); - - if (response?.data) { - for (const order of response.data) { - totalRevenue += order.attributes.total; - } - } - - hasNextPage = !response?.meta?.page.lastPage; - currentPage++; - } - - // Revenue is in cents so we convert to dollars (or your main currency unit) - return totalRevenue / 100; - } catch (error) { - console.error('Error fetching Lemon Squeezy revenue:', error); - throw error; - } -} diff --git a/template/app/src/payment/lemonSqueezy/paymentProcessor.ts b/template/app/src/payment/lemonSqueezy/paymentProcessor.ts index 2d4ac6463..4ed5c20de 100644 --- a/template/app/src/payment/lemonSqueezy/paymentProcessor.ts +++ b/template/app/src/payment/lemonSqueezy/paymentProcessor.ts @@ -2,14 +2,54 @@ import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, PaymentProc import { requireNodeEnvVar } from '../../server/utils'; import { createLemonSqueezyCheckoutSession } from './checkoutUtils'; import { lemonSqueezyWebhook, lemonSqueezyMiddlewareConfigFn } from './webhook'; -import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'; +import { lemonSqueezySetup, listOrders } from '@lemonsqueezy/lemonsqueezy.js'; +import { PaymentProcessors } from '../types'; lemonSqueezySetup({ apiKey: requireNodeEnvVar('LEMONSQUEEZY_API_KEY'), }); +/** + * Calculates total revenue from LemonSqueezy orders + * @returns Promise resolving to total revenue in dollars + */ +async function fetchTotalLemonSqueezyRevenue(): Promise { + try { + let totalRevenue = 0; + let hasNextPage = true; + let currentPage = 1; + + while (hasNextPage) { + const { data: response } = await listOrders({ + filter: { + storeId: process.env.LEMONSQUEEZY_STORE_ID, + }, + page: { + number: currentPage, + size: 100, + }, + }); + + if (response?.data) { + for (const order of response.data) { + totalRevenue += order.attributes.total; + } + } + + hasNextPage = !response?.meta?.page.lastPage; + currentPage++; + } + + // Revenue is in cents so we convert to dollars (or your main currency unit) + return totalRevenue / 100; + } catch (error) { + console.error('Error fetching Lemon Squeezy revenue:', error); + throw error; + } +} + export const lemonSqueezyPaymentProcessor: PaymentProcessor = { - id: 'lemonsqueezy', + id: PaymentProcessors.LemonSqueezy, createCheckoutSession: async ({ userId, userEmail, paymentPlan }: CreateCheckoutSessionArgs) => { if (!userId) throw new Error('User ID needed to create Lemon Squeezy Checkout Session'); const session = await createLemonSqueezyCheckoutSession({ @@ -33,6 +73,7 @@ export const lemonSqueezyPaymentProcessor: PaymentProcessor = { // This is handled in the Lemon Squeezy webhook. return user.lemonSqueezyCustomerPortalUrl; }, + getTotalRevenue: fetchTotalLemonSqueezyRevenue, webhook: lemonSqueezyWebhook, webhookMiddlewareConfigFn: lemonSqueezyMiddlewareConfigFn, }; diff --git a/template/app/src/payment/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 9049e7214..ee1720467 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -4,6 +4,9 @@ import type { MiddlewareConfigFn } from 'wasp/server'; import { PrismaClient } from '@prisma/client'; import { stripePaymentProcessor } from './stripe/paymentProcessor'; import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor'; +import { polarPaymentProcessor } from './polar/paymentProcessor'; +import { PaymentProcessorId, PaymentProcessors } from './types'; +import { getActivePaymentProcessor } from './validation'; export interface CreateCheckoutSessionArgs { userId: string; @@ -16,17 +19,91 @@ export interface FetchCustomerPortalUrlArgs { prismaUserDelegate: PrismaClient['user']; }; +/** + * Standard interface for all payment processors + * Provides a consistent API for payment operations across different providers + */ export interface PaymentProcessor { - id: 'stripe' | 'lemonsqueezy'; + id: PaymentProcessorId; + /** + * Creates a checkout session for payment processing + * Handles both subscription and one-time payment flows based on the payment plan configuration + * @param args Checkout session creation arguments + * @param args.userId Internal user ID for tracking and database updates + * @param args.userEmail Customer email address for payment processor customer creation/lookup + * @param args.paymentPlan Payment plan configuration containing pricing and payment type information + * @param args.prismaUserDelegate Prisma user delegate for database operations + * @returns Promise resolving to checkout session with session ID and redirect URL + * @throws {Error} When payment processor API calls fail or required configuration is missing + * @example + * ```typescript + * const { session } = await paymentProcessor.createCheckoutSession({ + * userId: 'user_123', + * userEmail: 'customer@example.com', + * paymentPlan: hobbyPlan, + * prismaUserDelegate: context.entities.User + * }); + * // Redirect user to session.url for payment + * ``` + */ createCheckoutSession: (args: CreateCheckoutSessionArgs) => Promise<{ session: { id: string; url: string }; }>; + /** + * Retrieves the customer portal URL for subscription and billing management + * Allows customers to view billing history, update payment methods, and manage subscriptions + * @param args Customer portal URL retrieval arguments + * @param args.userId Internal user ID to lookup customer information + * @param args.prismaUserDelegate Prisma user delegate for database operations + * @returns Promise resolving to customer portal URL or null if not available + * @throws {Error} When user lookup fails or payment processor API calls fail + * @example + * ```typescript + * const portalUrl = await paymentProcessor.fetchCustomerPortalUrl({ + * userId: 'user_123', + * prismaUserDelegate: context.entities.User + * }); + * if (portalUrl) { + * // Redirect user to portal for billing management + * return { redirectUrl: portalUrl }; + * } + * ``` + */ fetchCustomerPortalUrl: (args: FetchCustomerPortalUrlArgs) => Promise; + /** + * Calculates the total revenue from this payment processor + * @returns Promise resolving to total revenue in dollars + */ + getTotalRevenue: () => Promise; webhook: PaymentsWebhook; webhookMiddlewareConfigFn: MiddlewareConfigFn; } /** - * Choose which payment processor you'd like to use, then delete the - * other payment processor code that you're not using from `/src/payment` + * All available payment processors + */ +const paymentProcessorMap: Record = { + [PaymentProcessors.Stripe]: stripePaymentProcessor, + [PaymentProcessors.LemonSqueezy]: lemonSqueezyPaymentProcessor, + [PaymentProcessors.Polar]: polarPaymentProcessor, +}; + +/** + * Get the payment processor instance based on environment configuration or override + * @param override Optional processor override for testing scenarios + * @returns The configured payment processor instance + * @throws {Error} When the specified processor is not found in the processor map + */ +export function getPaymentProcessor(override?: PaymentProcessorId): PaymentProcessor { + const processorId = getActivePaymentProcessor(override); + const processor = paymentProcessorMap[processorId]; + + if (!processor) { + throw new Error(`Payment processor '${processorId}' not found. Available processors: ${Object.keys(paymentProcessorMap).join(', ')}`); + } + + return processor; +} + +/** + * The currently configured payment processor. */ -// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; -export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; +export const paymentProcessor: PaymentProcessor = getPaymentProcessor(); diff --git a/template/app/src/payment/polar/README.md b/template/app/src/payment/polar/README.md new file mode 100644 index 000000000..baf98064c --- /dev/null +++ b/template/app/src/payment/polar/README.md @@ -0,0 +1,237 @@ +# Polar Payment Processor Integration + +This directory contains the Polar payment processor integration for OpenSaaS. + +## Environment Variables + +The following environment variables are required when using Polar as your payment processor: + +### Core Configuration +```bash +PAYMENT_PROCESSOR_ID=polar # Select Polar as the active payment processor +POLAR_ACCESS_TOKEN=your_polar_access_token +POLAR_ORGANIZATION_ID=your_polar_organization_id +POLAR_WEBHOOK_SECRET=your_polar_webhook_secret +POLAR_CUSTOMER_PORTAL_URL=your_polar_customer_portal_url +``` + +### Product/Plan Mappings +```bash +POLAR_HOBBY_SUBSCRIPTION_PLAN_ID=your_hobby_plan_id +POLAR_PRO_SUBSCRIPTION_PLAN_ID=your_pro_plan_id +POLAR_CREDITS_10_PLAN_ID=your_credits_plan_id +``` + +### Optional Configuration +```bash +POLAR_SANDBOX_MODE=true # Override sandbox mode (defaults to NODE_ENV-based detection) +``` + +## Integration with Existing Payment Plan Infrastructure + +This Polar integration **maximizes reuse** of the existing OpenSaaS payment plan infrastructure for consistency and maintainability. + +### Reused Components + +#### **PaymentPlanId Enum** +- Uses the existing `PaymentPlanId` enum from `src/payment/plans.ts` +- Ensures consistent plan identifiers across all payment processors +- Values: `PaymentPlanId.Hobby`, `PaymentPlanId.Pro`, `PaymentPlanId.Credits10` + +#### **Plan ID Validation** +- Leverages existing `parsePaymentPlanId()` function for input validation +- Provides consistent error handling for invalid plan IDs +- Maintains compatibility with existing plan validation logic + +#### **Type Safety** +- All plan-related functions use `PaymentPlanId` enum types instead of strings +- Ensures compile-time safety when working with payment plans +- Consistent with other payment processor implementations + +### Plan ID Mapping Functions + +```typescript +import { PaymentPlanId } from '../plans'; + +// Maps Polar product ID to PaymentPlanId enum +function mapPolarProductIdToPlanId(polarProductId: string): PaymentPlanId { + // Returns PaymentPlanId.Hobby, PaymentPlanId.Pro, or PaymentPlanId.Credits10 +} + +// Maps PaymentPlanId enum to Polar product ID +function getPolarProductIdForPlan(planId: string | PaymentPlanId): string { + // Accepts both string and enum, validates using existing parsePaymentPlanId() +} +``` + +### Benefits of Integration + +1. **Consistency**: All payment processors use the same plan identifiers +2. **Type Safety**: Compile-time validation of plan IDs throughout the system +3. **Maintainability**: Single source of truth for payment plan definitions +4. **Validation**: Leverages existing validation logic for plan IDs +5. **Future-Proof**: Easy to add new plans or modify existing ones + +## Environment Variable Validation + +This integration uses **Wasp's centralized Zod-based environment variable validation** for type safety and comprehensive error handling. + +### How Validation Works + +1. **Schema Definition**: All Polar environment variables are defined with Zod schemas in `src/server/env.ts` +2. **Format Validation**: Each variable includes specific validation rules: + - `POLAR_ACCESS_TOKEN`: Minimum 10 characters + - `POLAR_WEBHOOK_SECRET`: Minimum 8 characters for security + - `POLAR_CUSTOMER_PORTAL_URL`: Must be a valid URL + - Product IDs: Alphanumeric characters, hyphens, and underscores only +3. **Conditional Validation**: Variables are only validated when `PAYMENT_PROCESSOR_ID=polar` +4. **Startup Validation**: Validation occurs automatically when configuration is accessed + +### Validation Features + +- ✅ **Type Safety**: All environment variables are properly typed +- ✅ **Format Validation**: URL validation, length checks, character restrictions +- ✅ **Conditional Logic**: Only validates when Polar is the selected processor +- ✅ **Detailed Error Messages**: Clear feedback on what's missing or invalid +- ✅ **Optional Variables**: Sandbox mode and other optional settings +- ✅ **Centralized**: Single source of truth for all validation logic + +### Usage in Code + +The validation is integrated into the configuration loading: + +```typescript +import { getPolarConfig, validatePolarConfig } from './config'; + +// Automatic validation when accessing config +const config = getPolarConfig(); // Validates automatically + +// Manual validation with optional force flag +validatePolarConfig(true); // Force validation regardless of processor selection +``` + +## Configuration Access + +### API Configuration +```typescript +import { getPolarApiConfig } from './config'; + +const apiConfig = getPolarApiConfig(); +// Returns: { accessToken, organizationId, webhookSecret, customerPortalUrl, sandboxMode } +``` + +### Plan Configuration +```typescript +import { getPolarPlanConfig } from './config'; + +const planConfig = getPolarPlanConfig(); +// Returns: { hobbySubscriptionPlanId, proSubscriptionPlanId, credits10PlanId } +``` + +### Complete Configuration +```typescript +import { getPolarConfig } from './config'; + +const config = getPolarConfig(); +// Returns: { api: {...}, plans: {...} } +``` + +### Plan ID Mapping +```typescript +import { mapPolarProductIdToPlanId, getPolarProductIdForPlan } from './config'; +import { PaymentPlanId } from '../plans'; + +// Convert Polar product ID to OpenSaaS plan ID +const planId: PaymentPlanId = mapPolarProductIdToPlanId('polar_product_123'); + +// Convert OpenSaaS plan ID to Polar product ID +const productId: string = getPolarProductIdForPlan(PaymentPlanId.Hobby); +// or with string validation +const productId2: string = getPolarProductIdForPlan('hobby'); // Validates input +``` + +## Sandbox Mode Detection + +The integration automatically detects sandbox mode using the following priority: + +1. **`POLAR_SANDBOX_MODE`** environment variable (`true`/`false`) +2. **`NODE_ENV`** fallback (sandbox unless `NODE_ENV=production`) + +## Error Handling + +The validation system provides comprehensive error messages: + +```bash +❌ Environment variable validation failed: +1. POLAR_ACCESS_TOKEN: POLAR_ACCESS_TOKEN must be at least 10 characters long +2. POLAR_CUSTOMER_PORTAL_URL: POLAR_CUSTOMER_PORTAL_URL must be a valid URL +``` + +## Integration with Wasp + +This validation integrates seamlessly with Wasp's environment variable system: + +- **Server Startup**: Validation runs automatically when the configuration is first accessed +- **Development**: Clear error messages help identify configuration issues quickly +- **Production**: Prevents deployment with invalid configuration +- **Type Safety**: Full TypeScript support for all environment variables + +## Best Practices + +1. **Set Required Variables**: Ensure all core configuration variables are set +2. **Use .env.server**: Store sensitive variables in your `.env.server` file +3. **Validate Early**: The system validates automatically, but you can force validation for testing +4. **Check Logs**: Watch for validation success/failure messages during startup +5. **Handle Errors**: Validation errors will prevent application startup with invalid config +6. **Use Type Safety**: Leverage PaymentPlanId enum for compile-time safety + +## Troubleshooting + +### Common Issues + +1. **Missing Variables**: Check that all required variables are set in `.env.server` +2. **Invalid URLs**: Ensure `POLAR_CUSTOMER_PORTAL_URL` includes protocol (`https://`) +3. **Wrong Processor**: Set `PAYMENT_PROCESSOR_ID=polar` to enable validation +4. **Token Format**: Ensure access tokens are at least 10 characters long +5. **Plan ID Errors**: Use PaymentPlanId enum values or valid string equivalents + +### Debug Validation + +To test validation manually: + +```typescript +import { validatePolarConfig } from './config'; + +// Force validation regardless of processor selection +try { + validatePolarConfig(true); + console.log('✅ Validation passed'); +} catch (error) { + console.error('❌ Validation failed:', error.message); +} +``` + +### Plan ID Debugging + +To debug plan ID mapping: + +```typescript +import { mapPolarProductIdToPlanId, getPolarProductIdForPlan } from './config'; +import { PaymentPlanId } from '../plans'; + +// Test product ID to plan ID mapping +try { + const planId = mapPolarProductIdToPlanId('your_polar_product_id'); + console.log('Plan ID:', planId); // Will be PaymentPlanId.Hobby, etc. +} catch (error) { + console.error('Unknown product ID:', error.message); +} + +// Test plan ID to product ID mapping +try { + const productId = getPolarProductIdForPlan(PaymentPlanId.Hobby); + console.log('Product ID:', productId); +} catch (error) { + console.error('Invalid plan ID:', error.message); +} +``` \ No newline at end of file diff --git a/template/app/src/payment/polar/checkoutUtils.ts b/template/app/src/payment/polar/checkoutUtils.ts new file mode 100644 index 000000000..b8a12ed20 --- /dev/null +++ b/template/app/src/payment/polar/checkoutUtils.ts @@ -0,0 +1,120 @@ +import { env } from 'wasp/server'; +import type { PolarMode } from './paymentProcessor'; +import { polar } from './polarClient'; + +/** + * Arguments for creating a Polar checkout session + */ +export interface CreatePolarCheckoutSessionArgs { + productId: string; + userEmail: string; + userId: string; + mode: PolarMode; +} + +/** + * Represents a Polar checkout session + */ +export interface PolarCheckoutSession { + id: string; + url: string; + customerId?: string; +} + +/** + * Creates a Polar checkout session + * @param args Arguments for creating a Polar checkout session + * @param args.productId Polar Product ID to use for the checkout session + * @param args.userEmail Email address of the customer + * @param args.userId Internal user ID for tracking + * @param args.mode Mode of the checkout session (subscription or payment) + * @returns Promise resolving to a PolarCheckoutSession object + */ +export async function createPolarCheckoutSession({ + productId, + userEmail, + userId, + mode, +}: CreatePolarCheckoutSessionArgs): Promise { + try { + const baseUrl = env.WASP_WEB_CLIENT_URL; + + // Create checkout session with proper Polar API structure + // Using type assertion due to potential API/TypeScript definition mismatches + const checkoutSessionArgs = { + products: [productId], // Array of Polar Product IDs + externalCustomerId: userId, // Use userId for customer deduplication + customerBillingAddress: { + country: 'US', // Default country - could be enhanced with user's actual country + }, + successUrl: `${baseUrl}/checkout?success=true`, + cancelUrl: `${baseUrl}/checkout?canceled=true`, + metadata: { + userId: userId, + userEmail: userEmail, + paymentMode: mode, + source: 'OpenSaaS', + }, + }; + const checkoutSession = await polar.checkouts.create(checkoutSessionArgs); + + if (!checkoutSession.url) { + throw new Error('Polar checkout session created without URL'); + } + + // Return customer ID from checkout session if available + const customerId = checkoutSession.customerId; + + return { + id: checkoutSession.id, + url: checkoutSession.url, + customerId: customerId || undefined, + }; + } catch (error) { + console.error('Error creating Polar checkout session:', error); + throw new Error( + `Failed to create Polar checkout session: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Fetches or creates a Polar customer for a given email address + * @param email Email address of the customer + * @returns Promise resolving to a Polar customer object + */ +export async function fetchPolarCustomer(email: string) { + try { + const customersIterator = await polar.customers.list({ + email: email, + limit: 1, + }); + let existingCustomer = null; + + for await (const page of customersIterator) { + const customers = page.result.items || []; + + if (customers.length > 0) { + existingCustomer = customers[0]; + + break; + } + } + + if (existingCustomer) { + return existingCustomer; + } + + const newCustomer = await polar.customers.create({ + email: email, + }); + + return newCustomer; + } catch (error) { + console.error('Error fetching/creating Polar customer:', error); + + throw new Error( + `Failed to fetch/create Polar customer: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/template/app/src/payment/polar/config.ts b/template/app/src/payment/polar/config.ts new file mode 100644 index 000000000..ddc5acaba --- /dev/null +++ b/template/app/src/payment/polar/config.ts @@ -0,0 +1,155 @@ +import { PaymentPlanId, parsePaymentPlanId } from '../plans'; +import { env } from 'wasp/server'; + +/** + * Core Polar API configuration environment variables + * Used throughout the Polar integration for SDK initialization and webhook processing + */ +export interface PolarApiConfig { + /** Polar API access token (required) - obtain from Polar dashboard */ + readonly accessToken: string; + /** Polar organization ID (required) - found in organization settings */ + readonly organizationId: string; + /** Webhook secret for signature verification (required) - generated when setting up webhooks */ + readonly webhookSecret: string; + /** Customer portal URL for subscription management (required) - provided by Polar */ + readonly customerPortalUrl: string; + /** Optional sandbox mode override (defaults to NODE_ENV-based detection) */ + readonly sandboxMode?: boolean; +} + +/** + * Polar product/plan ID mappings for OpenSaaS plans + * Maps internal plan identifiers to Polar product IDs + */ +export interface PolarPlanConfig { + /** Polar product ID for hobby subscription plan */ + readonly hobbySubscriptionPlanId: string; + /** Polar product ID for pro subscription plan */ + readonly proSubscriptionPlanId: string; + /** Polar product ID for 10 credits plan */ + readonly credits10PlanId: string; +} + +/** + * Complete Polar configuration combining API and plan settings + */ +export interface PolarConfig { + readonly api: PolarApiConfig; + readonly plans: PolarPlanConfig; +} + +/** + * All Polar-related environment variables + * Used for validation and configuration loading + */ +export const POLAR_ENV_VARS = { + // Core API Configuration + POLAR_ACCESS_TOKEN: 'POLAR_ACCESS_TOKEN', + POLAR_ORGANIZATION_ID: 'POLAR_ORGANIZATION_ID', + POLAR_WEBHOOK_SECRET: 'POLAR_WEBHOOK_SECRET', + POLAR_CUSTOMER_PORTAL_URL: 'POLAR_CUSTOMER_PORTAL_URL', + POLAR_SANDBOX_MODE: 'POLAR_SANDBOX_MODE', +} as const; + +/** + * Gets the complete Polar configuration from environment variables + * @returns Complete Polar configuration object + * @throws Error if any required variables are missing or invalid + */ +export function getPolarConfig(): PolarConfig { + return { + api: getPolarApiConfig(), + plans: getPolarPlanConfig(), + }; +} + +/** + * Gets Polar API configuration from environment variables + * @returns Polar API configuration object + * @throws Error if any required API variables are missing + */ +export function getPolarApiConfig(): PolarApiConfig { + return { + accessToken: process.env[POLAR_ENV_VARS.POLAR_ACCESS_TOKEN]!, + organizationId: process.env[POLAR_ENV_VARS.POLAR_ORGANIZATION_ID]!, + webhookSecret: process.env[POLAR_ENV_VARS.POLAR_WEBHOOK_SECRET]!, + customerPortalUrl: process.env[POLAR_ENV_VARS.POLAR_CUSTOMER_PORTAL_URL]!, + sandboxMode: shouldUseSandboxMode(), + }; +} + +/** + * Gets Polar plan configuration from environment variables + * @returns Polar plan configuration object + * @throws Error if any required plan variables are missing + */ +export function getPolarPlanConfig(): PolarPlanConfig { + return { + hobbySubscriptionPlanId: env.PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID, + proSubscriptionPlanId: env.PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID, + credits10PlanId: env.PAYMENTS_CREDITS_10_PLAN_ID, + }; +} + +/** + * Determines if Polar should use sandbox mode + * Checks POLAR_SANDBOX_MODE environment variable first, then falls back to NODE_ENV + * @returns true if sandbox mode should be used, false for production mode + */ +export function shouldUseSandboxMode(): boolean { + const explicitSandboxMode = process.env.POLAR_SANDBOX_MODE; + if (explicitSandboxMode !== undefined) { + return explicitSandboxMode === 'true'; + } + + return env.NODE_ENV !== 'production'; +} + +/** + * Maps a Polar product ID to an OpenSaaS plan ID + * @param polarProductId The Polar product ID to map + * @returns The corresponding OpenSaaS PaymentPlanId + * @throws Error if the product ID is not found + */ +export function mapPolarProductIdToPlanId(polarProductId: string): PaymentPlanId { + const planConfig = getPolarPlanConfig(); + + const planMapping: Record = { + [planConfig.hobbySubscriptionPlanId]: PaymentPlanId.Hobby, + [planConfig.proSubscriptionPlanId]: PaymentPlanId.Pro, + [planConfig.credits10PlanId]: PaymentPlanId.Credits10, + }; + + const planId = planMapping[polarProductId]; + if (!planId) { + throw new Error(`Unknown Polar product ID: ${polarProductId}`); + } + + return planId; +} + +/** + * Gets a Polar product ID for a given OpenSaaS plan ID + * @param planId The OpenSaaS plan ID (string or PaymentPlanId enum) + * @returns The corresponding Polar product ID + * @throws Error if the plan ID is not found or invalid + */ +export function getPolarProductIdForPlan(planId: string | PaymentPlanId): string { + const validatedPlanId = typeof planId === 'string' ? parsePaymentPlanId(planId) : planId; + + const planConfig = getPolarPlanConfig(); + + const productMapping: Record = { + [PaymentPlanId.Hobby]: planConfig.hobbySubscriptionPlanId, + [PaymentPlanId.Pro]: planConfig.proSubscriptionPlanId, + [PaymentPlanId.Credits10]: planConfig.credits10PlanId, + }; + + const productId = productMapping[validatedPlanId]; + if (!productId) { + throw new Error(`Unknown plan ID: ${validatedPlanId}`); + } + + return productId; +} \ No newline at end of file diff --git a/template/app/src/payment/polar/paymentDetails.ts b/template/app/src/payment/polar/paymentDetails.ts new file mode 100644 index 000000000..dad10b5f4 --- /dev/null +++ b/template/app/src/payment/polar/paymentDetails.ts @@ -0,0 +1,134 @@ +import type { PrismaClient } from '@prisma/client'; +import type { SubscriptionStatus, PaymentPlanId } from '../plans'; + +/** + * Arguments for updating user Polar payment details + */ +export interface UpdateUserPolarPaymentDetailsArgs { + polarCustomerId: string; + subscriptionPlan?: PaymentPlanId; + subscriptionStatus?: SubscriptionStatus | string; + numOfCreditsPurchased?: number; + datePaid?: Date; +} + +/** + * Updates user Polar payment details + * @param args Arguments for updating user Polar payment details + * @param args.polarCustomerId ID of the Polar customer + * @param args.subscriptionPlan ID of the subscription plan + * @param args.subscriptionStatus Status of the subscription + * @param args.numOfCreditsPurchased Number of credits purchased + * @param args.datePaid Date of payment + * @param userDelegate Prisma user delegate for database operations + * @returns Promise resolving to the updated user + */ +export const updateUserPolarPaymentDetails = async ( + args: UpdateUserPolarPaymentDetailsArgs, + userDelegate: PrismaClient['user'] +) => { + const { + polarCustomerId, + subscriptionPlan, + subscriptionStatus, + numOfCreditsPurchased, + datePaid, + } = args; + + try { + return await userDelegate.update({ + where: { + paymentProcessorUserId: polarCustomerId + }, + data: { + paymentProcessorUserId: polarCustomerId, + subscriptionPlan, + subscriptionStatus, + datePaid, + credits: numOfCreditsPurchased !== undefined + ? { increment: numOfCreditsPurchased } + : undefined, + }, + }); + } catch (error) { + console.error('Error updating user Polar payment details:', error); + throw new Error(`Failed to update user payment details: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Finds a user by their Polar customer ID + * @param polarCustomerId ID of the Polar customer + * @param userDelegate Prisma user delegate for database operations + * @returns Promise resolving to the user or null if not found + */ +export const findUserByPolarCustomerId = async ( + polarCustomerId: string, + userDelegate: PrismaClient['user'] +) => { + try { + return await userDelegate.findFirst({ + where: { + paymentProcessorUserId: polarCustomerId + } + }); + } catch (error) { + console.error('Error finding user by Polar customer ID:', error); + throw new Error(`Failed to find user by Polar customer ID: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Updates the subscription status of a user + * @param polarCustomerId ID of the Polar customer + * @param subscriptionStatus Status of the subscription + * @param userDelegate Prisma user delegate for database operations + * @returns Promise resolving to the updated user + */ +export const updateUserSubscriptionStatus = async ( + polarCustomerId: string, + subscriptionStatus: SubscriptionStatus | string, + userDelegate: PrismaClient['user'] +) => { + try { + return await userDelegate.update({ + where: { + paymentProcessorUserId: polarCustomerId + }, + data: { + subscriptionStatus, + }, + }); + } catch (error) { + console.error('Error updating user subscription status:', error); + throw new Error(`Failed to update subscription status: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Adds credits to a user + * @param polarCustomerId ID of the Polar customer + * @param creditsAmount Amount of credits to add + * @param userDelegate Prisma user delegate for database operations + * @returns Promise resolving to the updated user + */ +export const addCreditsToUser = async ( + polarCustomerId: string, + creditsAmount: number, + userDelegate: PrismaClient['user'] +) => { + try { + return await userDelegate.update({ + where: { + paymentProcessorUserId: polarCustomerId + }, + data: { + credits: { increment: creditsAmount }, + datePaid: new Date(), + }, + }); + } catch (error) { + console.error('Error adding credits to user:', error); + throw new Error(`Failed to add credits to user: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; \ No newline at end of file diff --git a/template/app/src/payment/polar/paymentProcessor.ts b/template/app/src/payment/polar/paymentProcessor.ts new file mode 100644 index 000000000..41e747a0c --- /dev/null +++ b/template/app/src/payment/polar/paymentProcessor.ts @@ -0,0 +1,144 @@ +// @ts-ignore +import { OrderStatus } from '@polar-sh/sdk/models/components/orderstatus.js'; +import { + type CreateCheckoutSessionArgs, + type FetchCustomerPortalUrlArgs, + type PaymentProcessor, +} from '../paymentProcessor'; +import type { PaymentPlanEffect } from '../plans'; +import { PaymentProcessors } from '../types'; +import { createPolarCheckoutSession } from './checkoutUtils'; +import { getPolarApiConfig } from './config'; +import { polar } from './polarClient'; +import { polarMiddlewareConfigFn, polarWebhook } from './webhook'; + +export type PolarMode = 'subscription' | 'payment'; + +/** + * Calculates total revenue from Polar transactions + * @returns Promise resolving to total revenue in dollars + */ +async function fetchTotalPolarRevenue(): Promise { + try { + let totalRevenue = 0; + + const result = await polar.orders.list({ + limit: 100, + }); + + for await (const page of result) { + const orders = page.result.items || []; + + for (const order of orders) { + if (order.status === OrderStatus.Paid && order.totalAmount > 0) { + totalRevenue += order.totalAmount; + } + } + } + + return totalRevenue / 100; + } catch (error) { + console.error('Error calculating Polar total revenue:', error); + return 0; + } +} + +export const polarPaymentProcessor: PaymentProcessor = { + id: PaymentProcessors.Polar, + /** + * Creates a Polar checkout session for subscription or one-time payments + * Handles customer creation/lookup automatically via externalCustomerId + * @param args Checkout session arguments including user info and payment plan + * @returns Promise resolving to checkout session with ID and redirect URL + */ + createCheckoutSession: async ({ + userId, + userEmail, + paymentPlan, + prismaUserDelegate, + }: CreateCheckoutSessionArgs) => { + try { + const session = await createPolarCheckoutSession({ + productId: paymentPlan.getPaymentProcessorPlanId(), + userEmail, + userId, + mode: paymentPlanEffectToPolarMode(paymentPlan.effect), + }); + + if (session.customerId) { + try { + await prismaUserDelegate.update({ + where: { + id: userId, + }, + data: { + paymentProcessorUserId: session.customerId, + }, + }); + } catch (dbError) { + console.error('Error updating user with Polar customer ID:', dbError); + } + } + + return { + session: { + id: session.id, + url: session.url, + }, + }; + } catch (error) { + console.error('Error in Polar createCheckoutSession:', error); + + throw new Error( + `Failed to create Polar checkout session: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }, + fetchCustomerPortalUrl: async (args: FetchCustomerPortalUrlArgs) => { + const defaultPortalUrl = getPolarApiConfig().customerPortalUrl; + + try { + const user = await args.prismaUserDelegate.findUnique({ + where: { + id: args.userId, + }, + select: { + paymentProcessorUserId: true, + }, + }); + + if (user?.paymentProcessorUserId) { + try { + const customerSession = await polar.customerSessions.create({ + customerId: user.paymentProcessorUserId, + }); + + return customerSession.customerPortalUrl; + } catch (polarError) { + console.error('Error creating Polar customer session:', polarError); + } + } + + return defaultPortalUrl; + } catch (error) { + console.error('Error fetching customer portal URL:', error); + return defaultPortalUrl; + } + }, + getTotalRevenue: fetchTotalPolarRevenue, + webhook: polarWebhook, + webhookMiddlewareConfigFn: polarMiddlewareConfigFn, +}; + +/** + * Maps a payment plan effect to a Polar mode + * @param planEffect Payment plan effect + * @returns Polar mode + */ +function paymentPlanEffectToPolarMode(planEffect: PaymentPlanEffect): PolarMode { + const effectToMode: Record = { + subscription: 'subscription', + credits: 'payment', + }; + return effectToMode[planEffect.kind]; +} diff --git a/template/app/src/payment/polar/polarClient.ts b/template/app/src/payment/polar/polarClient.ts new file mode 100644 index 000000000..af6c378de --- /dev/null +++ b/template/app/src/payment/polar/polarClient.ts @@ -0,0 +1,27 @@ +import { Polar } from '@polar-sh/sdk'; +import { getPolarApiConfig, shouldUseSandboxMode } from './config'; + +/** + * Polar SDK client instance configured with environment variables + * Automatically handles sandbox vs production environment selection + */ +export const polar = new Polar({ + accessToken: getPolarApiConfig().accessToken, + server: shouldUseSandboxMode() ? 'sandbox' : 'production', +}); + +/** + * Validates that the Polar client is properly configured + * @throws Error if configuration is invalid or client is not accessible + */ +export function validatePolarClient(): void { + const config = getPolarApiConfig(); + + if (!config.accessToken) { + throw new Error('Polar access token is required but not configured'); + } + + if (!config.organizationId) { + throw new Error('Polar organization ID is required but not configured'); + } +} \ No newline at end of file diff --git a/template/app/src/payment/polar/types.ts b/template/app/src/payment/polar/types.ts new file mode 100644 index 000000000..34e72e4d9 --- /dev/null +++ b/template/app/src/payment/polar/types.ts @@ -0,0 +1,322 @@ +/** + * Polar Payment Processor TypeScript Type Definitions + * + * This module defines all TypeScript types, interfaces, and enums + * used throughout the Polar payment processor integration. + */ + +// @ts-ignore +import { WebhookBenefitCreatedPayload } from '@polar-sh/sdk/models/components/webhookbenefitcreatedpayload.js'; +// @ts-ignore +import { WebhookBenefitGrantCreatedPayload } from '@polar-sh/sdk/models/components/webhookbenefitgrantcreatedpayload.js'; +// @ts-ignore +import { WebhookBenefitGrantCycledPayload } from '@polar-sh/sdk/models/components/webhookbenefitgrantcycledpayload.js'; +// @ts-ignore +import { WebhookBenefitGrantRevokedPayload } from '@polar-sh/sdk/models/components/webhookbenefitgrantrevokedpayload.js'; +// @ts-ignore +import { WebhookBenefitGrantUpdatedPayload } from '@polar-sh/sdk/models/components/webhookbenefitgrantupdatedpayload.js'; +// @ts-ignore +import { WebhookBenefitUpdatedPayload } from '@polar-sh/sdk/models/components/webhookbenefitupdatedpayload.js'; +// @ts-ignore +import { WebhookCheckoutCreatedPayload } from '@polar-sh/sdk/models/components/webhookcheckoutcreatedpayload.js'; +// @ts-ignore +import { WebhookCheckoutUpdatedPayload } from '@polar-sh/sdk/models/components/webhookcheckoutupdatedpayload.js'; +// @ts-ignore +import { WebhookCustomerCreatedPayload } from '@polar-sh/sdk/models/components/webhookcustomercreatedpayload.js'; +// @ts-ignore +import { WebhookCustomerDeletedPayload } from '@polar-sh/sdk/models/components/webhookcustomerdeletedpayload.js'; +// @ts-ignore +import { WebhookCustomerStateChangedPayload } from '@polar-sh/sdk/models/components/webhookcustomerstatechangedpayload.js'; +// @ts-ignore +import { WebhookCustomerUpdatedPayload } from '@polar-sh/sdk/models/components/webhookcustomerupdatedpayload.js'; +// @ts-ignore +import { WebhookOrderCreatedPayload } from '@polar-sh/sdk/models/components/webhookordercreatedpayload.js'; +// @ts-ignore +import { WebhookOrderPaidPayload } from '@polar-sh/sdk/models/components/webhookorderpaidpayload.js'; +// @ts-ignore +import { WebhookOrderRefundedPayload } from '@polar-sh/sdk/models/components/webhookorderrefundedpayload.js'; +// @ts-ignore +import { WebhookOrderUpdatedPayload } from '@polar-sh/sdk/models/components/webhookorderupdatedpayload.js'; +// @ts-ignore +import { WebhookOrganizationUpdatedPayload } from '@polar-sh/sdk/models/components/webhookorganizationupdatedpayload.js'; +// @ts-ignore +import { WebhookProductCreatedPayload } from '@polar-sh/sdk/models/components/webhookproductcreatedpayload.js'; +// @ts-ignore +import { WebhookProductUpdatedPayload } from '@polar-sh/sdk/models/components/webhookproductupdatedpayload.js'; +// @ts-ignore +import { WebhookRefundCreatedPayload } from '@polar-sh/sdk/models/components/webhookrefundcreatedpayload.js'; +// @ts-ignore +import { WebhookRefundUpdatedPayload } from '@polar-sh/sdk/models/components/webhookrefundupdatedpayload.js'; +// @ts-ignore +import { WebhookSubscriptionActivePayload } from '@polar-sh/sdk/models/components/webhooksubscriptionactivepayload.js'; +// @ts-ignore +import { WebhookSubscriptionCanceledPayload } from '@polar-sh/sdk/models/components/webhooksubscriptioncanceledpayload.js'; +// @ts-ignore +import { WebhookSubscriptionCreatedPayload } from '@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload.js'; +// @ts-ignore +import { WebhookSubscriptionRevokedPayload } from '@polar-sh/sdk/models/components/webhooksubscriptionrevokedpayload.js'; +// @ts-ignore +import { WebhookSubscriptionUncanceledPayload } from '@polar-sh/sdk/models/components/webhooksubscriptionuncanceledpayload.js'; +// @ts-ignore +import { SubscriptionStatus as PolarSubscriptionStatus } from '@polar-sh/sdk/models/components/subscriptionstatus.js'; +// @ts-ignore +import { WebhookSubscriptionUpdatedPayload } from '@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js'; +import { SubscriptionStatus as OpenSaasSubscriptionStatus } from '../plans'; + +// ================================ +// POLAR SDK TYPES +// ================================ + +/** + * Polar SDK server environment options + */ +export type PolarServerEnvironment = 'sandbox' | 'production'; + +/** + * Polar payment modes supported by the integration + */ +export type PolarMode = 'subscription' | 'payment'; + +// ================================ +// POLAR WEBHOOK PAYLOAD TYPES +// ================================ + +/** + * Polar webhook payload types + */ +export type PolarWebhookPayload = + | WebhookCheckoutCreatedPayload + | WebhookBenefitCreatedPayload + | WebhookBenefitGrantCreatedPayload + | WebhookBenefitGrantRevokedPayload + | WebhookBenefitGrantUpdatedPayload + | WebhookBenefitGrantCycledPayload + | WebhookBenefitUpdatedPayload + | WebhookCheckoutUpdatedPayload + | WebhookOrderCreatedPayload + | WebhookOrderRefundedPayload + | WebhookOrderUpdatedPayload + | WebhookOrderPaidPayload + | WebhookOrganizationUpdatedPayload + | WebhookProductCreatedPayload + | WebhookProductUpdatedPayload + | WebhookRefundCreatedPayload + | WebhookRefundUpdatedPayload + | WebhookSubscriptionActivePayload + | WebhookSubscriptionCanceledPayload + | WebhookSubscriptionCreatedPayload + | WebhookSubscriptionRevokedPayload + | WebhookSubscriptionUncanceledPayload + | WebhookSubscriptionUpdatedPayload + | WebhookCustomerCreatedPayload + | WebhookCustomerUpdatedPayload + | WebhookCustomerDeletedPayload + | WebhookCustomerStateChangedPayload; +/** + * Base metadata structure attached to Polar checkout sessions + */ +export interface PolarCheckoutMetadata { + /** Internal user ID from our system */ + userId: string; + /** Payment mode: subscription or one-time payment */ + mode: PolarMode; + /** Additional custom metadata */ + [key: string]: string | undefined; +} + +/** + * Common structure for Polar webhook payloads + */ +export interface BasePolarWebhookPayload { + /** Polar event ID */ + id: string; + /** Polar customer ID */ + customerId?: string; + /** Alternative customer ID field name */ + customer_id?: string; + /** Polar product ID */ + productId?: string; + /** Alternative product ID field name */ + product_id?: string; + /** Event creation timestamp */ + createdAt?: string; + /** Alternative creation timestamp field name */ + created_at?: string; + /** Custom metadata attached to the event */ + metadata?: PolarCheckoutMetadata; +} + +/** + * Polar checkout created webhook payload + */ +export interface PolarCheckoutCreatedPayload extends BasePolarWebhookPayload { + /** Checkout session URL */ + url?: string; + /** Checkout session status */ + status?: string; +} + +/** + * Polar order created webhook payload (for one-time payments/credits) + */ +export interface PolarOrderCreatedPayload extends BasePolarWebhookPayload { + /** Order total amount */ + amount?: number; + /** Order currency */ + currency?: string; + /** Order line items */ + items?: Array<{ + productId: string; + quantity: number; + amount: number; + }>; +} + +/** + * Polar subscription webhook payload + */ +export interface PolarSubscriptionPayload extends BasePolarWebhookPayload { + /** Subscription status */ + status: string; + /** Subscription start date */ + startedAt?: string; + /** Subscription end date */ + endsAt?: string; + /** Subscription cancellation date */ + canceledAt?: string; +} + +// ================================ +// CHECKOUT SESSION TYPES +// ================================ + +/** + * Arguments for creating a Polar checkout session + */ +export interface CreatePolarCheckoutSessionArgs { + /** Polar product ID */ + productId: string; + /** Customer email address */ + userEmail: string; + /** Internal user ID */ + userId: string; + /** Payment mode (subscription or one-time payment) */ + mode: PolarMode; +} + +/** + * Result of creating a Polar checkout session + */ +export interface PolarCheckoutSession { + /** Checkout session ID */ + id: string; + /** Checkout session URL */ + url: string; + /** Associated customer ID (if available) */ + customerId?: string; +} + +// ================================ +// CUSTOMER MANAGEMENT TYPES +// ================================ + +/** + * Polar customer information + */ +export interface PolarCustomer { + /** Polar customer ID */ + id: string; + /** Customer email address */ + email: string; + /** Customer name */ + name?: string; + /** Customer creation timestamp */ + createdAt: string; + /** Additional customer metadata */ + metadata?: Record; +} + +// ================================ +// ERROR TYPES +// ================================ + +/** + * Polar-specific error types + */ +export class PolarConfigurationError extends Error { + constructor(message: string) { + super(`Polar Configuration Error: ${message}`); + this.name = 'PolarConfigurationError'; + } +} + +export class PolarApiError extends Error { + constructor( + message: string, + public statusCode?: number + ) { + super(`Polar API Error: ${message}`); + this.name = 'PolarApiError'; + } +} + +export class PolarWebhookError extends Error { + constructor( + message: string, + public webhookEvent?: string + ) { + super(`Polar Webhook Error: ${message}`); + this.name = 'PolarWebhookError'; + } +} + +// ================================ +// UTILITY TYPES +// ================================ + +/** + * Type guard to check if a value is a valid Polar mode + */ +export function isPolarMode(value: string): value is PolarMode { + return value === 'subscription' || value === 'payment'; +} + +/** + * Type guard to check if a value is a valid Polar subscription status + */ +export function isPolarSubscriptionStatus(value: string): value is PolarSubscriptionStatus { + return Object.values(PolarSubscriptionStatus).includes(value as PolarSubscriptionStatus); +} + +/** + * Type for validating webhook payload structure + */ +export type WebhookPayloadValidator = (payload: unknown) => payload is T; + +// ================================ +// CONFIGURATION VALIDATION TYPES +// ================================ + +/** + * Environment variable validation result + */ +export interface EnvVarValidationResult { + /** Whether validation passed */ + isValid: boolean; + /** Missing required variables */ + missingVars: string[]; + /** Invalid variable values */ + invalidVars: Array<{ name: string; value: string; reason: string }>; +} + +/** + * Polar configuration validation options + */ +export interface PolarConfigValidationOptions { + /** Whether to throw errors on validation failure */ + throwOnError: boolean; + /** Whether to validate optional variables */ + validateOptional: boolean; + /** Whether to check environment-specific requirements */ + checkEnvironmentRequirements: boolean; +} diff --git a/template/app/src/payment/polar/webhook.ts b/template/app/src/payment/polar/webhook.ts new file mode 100644 index 000000000..4812e2928 --- /dev/null +++ b/template/app/src/payment/polar/webhook.ts @@ -0,0 +1,374 @@ +// @ts-ignore +import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'; +import express from 'express'; +import type { MiddlewareConfigFn } from 'wasp/server'; +import type { PaymentsWebhook } from 'wasp/server/api'; +import { SubscriptionStatus as OpenSaasSubscriptionStatus, PaymentPlanId, paymentPlans } from '../plans'; +import { getPolarApiConfig, mapPolarProductIdToPlanId } from './config'; +import { findUserByPolarCustomerId, updateUserPolarPaymentDetails } from './paymentDetails'; +import { PolarWebhookPayload } from './types'; +// @ts-ignore +import { SubscriptionStatus as PolarSubscriptionStatus } from '@polar-sh/sdk/models/components/subscriptionstatus.js'; +// @ts-ignore +import { Order } from '@polar-sh/sdk/models/components/order.js'; +// @ts-ignore +import { Subscription } from '@polar-sh/sdk/models/components/subscription.js'; +import { MiddlewareConfig } from 'wasp/server/middleware'; + +/** + * Main Polar webhook handler with signature verification and proper event routing + * Handles all Polar webhook events with comprehensive error handling and logging + * @param req Express request object containing raw webhook payload + * @param res Express response object for webhook acknowledgment + * @param context Wasp context containing database entities and user information + */ +export const polarWebhook: PaymentsWebhook = async (req, res, context) => { + try { + const config = getPolarApiConfig(); + const event = validateEvent(req.body, req.headers as Record, config.webhookSecret); + const success = await handlePolarEvent(event, context); + + if (success) { + res.status(200).json({ received: true }); + } else { + res.status(202).json({ received: true, processed: false }); + } + } catch (error) { + if (error instanceof WebhookVerificationError) { + console.error('Polar webhook signature verification failed:', error); + res.status(403).json({ error: 'Invalid signature' }); + return; + } + + console.error('Polar webhook processing error:', error); + res.status(500).json({ error: 'Webhook processing failed' }); + } +}; + +/** + * Routes Polar webhook events to appropriate handlers + * @param event Verified Polar webhook event + * @param context Wasp context with database entities + * @returns Promise resolving to boolean indicating if event was handled + */ +async function handlePolarEvent(event: PolarWebhookPayload, context: any): Promise { + const userDelegate = context.entities.User; + + try { + switch (event.type) { + case 'order.created': + await handleOrderCreated(event.data, userDelegate); + return true; + + case 'order.paid': + await handleOrderCompleted(event.data, userDelegate); + return true; + + case 'subscription.created': + await handleSubscriptionCreated(event.data, userDelegate); + return true; + + case 'subscription.updated': + await handleSubscriptionUpdated(event.data, userDelegate); + return true; + + case 'subscription.canceled': + await handleSubscriptionCanceled(event.data, userDelegate); + return true; + + case 'subscription.active': + await handleSubscriptionActivated(event.data, userDelegate); + return true; + + default: + console.warn('Unhandled Polar webhook event type:', event.type); + return false; + } + } catch (error) { + console.error(`Error handling Polar event ${event.type}:`, error); + throw error; // Re-throw to trigger 500 response for retry + } +} + +/** + * Handle order creation events (one-time payments/credits) + * @param data Order data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleOrderCreated(data: Order, userDelegate: any): Promise { + try { + const customerId = data.customerId; + const metadata = data.metadata || {}; + const paymentMode = metadata.paymentMode; + + if (!customerId) { + console.warn('Order created without customer_id'); + return; + } + + if (paymentMode !== 'payment') { + console.log(`Order ${data.id} is not for credits (mode: ${paymentMode})`); + return; + } + + const creditsAmount = extractCreditsFromPolarOrder(data); + + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + numOfCreditsPurchased: creditsAmount, + datePaid: new Date(data.createdAt), + }, + userDelegate + ); + + console.log(`Order created: ${data.id}, customer: ${customerId}, credits: ${creditsAmount}`); + } catch (error) { + console.error('Error handling order created:', error); + throw error; + } +} + +/** + * Handle order completion events + * @param data Order data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleOrderCompleted(data: Order, userDelegate: any): Promise { + try { + const customerId = data.customerId; + + if (!customerId) { + console.warn('Order completed without customer_id'); + return; + } + + console.log(`Order completed: ${data.id} for customer: ${customerId}`); + + const user = await findUserByPolarCustomerId(customerId, userDelegate); + if (user) { + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + datePaid: new Date(data.createdAt), + }, + userDelegate + ); + } + } catch (error) { + console.error('Error handling order completed:', error); + throw error; + } +} + +/** + * Handle subscription creation events + * @param data Subscription data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleSubscriptionCreated(data: Subscription, userDelegate: any): Promise { + try { + const customerId = data.customerId; + const planId = data.productId; + const status = data.status; + + if (!customerId || !planId) { + console.warn('Subscription created without required customer_id or plan_id'); + return; + } + + const mappedPlanId = mapPolarProductIdToPlanId(planId); + const subscriptionStatus = mapPolarStatusToOpenSaaS(status); + + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + subscriptionPlan: mappedPlanId, + subscriptionStatus, + datePaid: new Date(data.createdAt), + }, + userDelegate + ); + + console.log( + `Subscription created: ${data.id}, customer: ${customerId}, plan: ${mappedPlanId}, status: ${subscriptionStatus}` + ); + } catch (error) { + console.error('Error handling subscription created:', error); + throw error; + } +} + +/** + * Handle subscription update events + * @param data Subscription data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleSubscriptionUpdated(data: Subscription, userDelegate: any): Promise { + try { + const customerId = data.customerId; + const status = data.status; + const planId = data.productId; + + if (!customerId) { + console.warn('Subscription updated without customer_id'); + return; + } + + const subscriptionStatus = mapPolarStatusToOpenSaaS(status); + const mappedPlanId = planId ? mapPolarProductIdToPlanId(planId) : undefined; + + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + subscriptionPlan: mappedPlanId, + subscriptionStatus, + ...(status === 'active' && { datePaid: new Date() }), + }, + userDelegate + ); + + console.log(`Subscription updated: ${data.id}, customer: ${customerId}, status: ${subscriptionStatus}`); + } catch (error) { + console.error('Error handling subscription updated:', error); + throw error; + } +} + +/** + * Handle subscription cancellation events + * @param data Subscription data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleSubscriptionCanceled(data: Subscription, userDelegate: any): Promise { + try { + const customerId = data.customerId; + + if (!customerId) { + console.warn('Subscription canceled without customer_id'); + return; + } + + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + subscriptionStatus: 'cancelled', + }, + userDelegate + ); + + console.log(`Subscription canceled: ${data.id}, customer: ${customerId}`); + } catch (error) { + console.error('Error handling subscription canceled:', error); + throw error; + } +} + +/** + * Handle subscription activation events + * @param data Subscription data from webhook + * @param userDelegate Prisma user delegate + */ +async function handleSubscriptionActivated(data: Subscription, userDelegate: any): Promise { + try { + const customerId = data.customerId; + const planId = data.productId; + + if (!customerId) { + console.warn('Subscription activated without customer_id'); + return; + } + + const mappedPlanId = planId ? mapPolarProductIdToPlanId(planId) : undefined; + + await updateUserPolarPaymentDetails( + { + polarCustomerId: customerId, + subscriptionPlan: mappedPlanId, + subscriptionStatus: 'active', + datePaid: new Date(), + }, + userDelegate + ); + + console.log(`Subscription activated: ${data.id}, customer: ${customerId}, plan: ${mappedPlanId}`); + } catch (error) { + console.error('Error handling subscription activated:', error); + throw error; + } +} + +/** + * Maps Polar subscription status to OpenSaaS subscription status + * Uses the comprehensive type system for better type safety and consistency + * @param polarStatus The status from Polar webhook payload + * @returns The corresponding OpenSaaS status + */ +function mapPolarStatusToOpenSaaS(polarStatus: PolarSubscriptionStatus): OpenSaasSubscriptionStatus { + const statusMap: Record = { + [PolarSubscriptionStatus.Active]: OpenSaasSubscriptionStatus.Active, + [PolarSubscriptionStatus.Canceled]: OpenSaasSubscriptionStatus.CancelAtPeriodEnd, + [PolarSubscriptionStatus.PastDue]: OpenSaasSubscriptionStatus.PastDue, + [PolarSubscriptionStatus.IncompleteExpired]: OpenSaasSubscriptionStatus.Deleted, + [PolarSubscriptionStatus.Incomplete]: OpenSaasSubscriptionStatus.PastDue, + [PolarSubscriptionStatus.Trialing]: OpenSaasSubscriptionStatus.Active, + [PolarSubscriptionStatus.Unpaid]: OpenSaasSubscriptionStatus.PastDue, + }; + + return statusMap[polarStatus]; +} + +/** + * Helper function to extract credits amount from order + * @param order Order data from Polar webhook payload + * @returns Number of credits purchased + */ +function extractCreditsFromPolarOrder(order: Order): number { + try { + const productId = order.productId; + + if (!productId) { + console.warn('No product_id found in Polar order:', order.id); + return 0; + } + + let planId: PaymentPlanId; + try { + planId = mapPolarProductIdToPlanId(productId); + } catch (error) { + console.warn(`Unknown Polar product ID ${productId} in order ${order.id}`); + return 0; + } + + const plan = paymentPlans[planId]; + if (!plan) { + console.warn(`No payment plan found for plan ID ${planId}`); + return 0; + } + + if (plan.effect.kind === 'credits') { + const credits = plan.effect.amount; + console.log(`Extracted ${credits} credits from order ${order.id} (product: ${productId})`); + return credits; + } + + console.log(`Order ${order.id} product ${productId} is not a credit product (plan: ${planId})`); + return 0; + } catch (error) { + console.error('Error extracting credits from Polar order:', error, order); + return 0; + } +} + +/** + * Middleware configuration function for Polar webhooks + * Sets up raw body parsing for webhook signature verification + * @param middlewareConfig Express middleware configuration object + * @returns Updated middleware configuration + */ +export const polarMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig: MiddlewareConfig) => { + middlewareConfig.delete('express.json'); + middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); + + return middlewareConfig; +}; diff --git a/template/app/src/payment/stripe/paymentProcessor.ts b/template/app/src/payment/stripe/paymentProcessor.ts index 4055d8827..8a333ddfa 100644 --- a/template/app/src/payment/stripe/paymentProcessor.ts +++ b/template/app/src/payment/stripe/paymentProcessor.ts @@ -3,11 +3,51 @@ import type { CreateCheckoutSessionArgs, FetchCustomerPortalUrlArgs, PaymentProc import { fetchStripeCustomer, createStripeCheckoutSession } from './checkoutUtils'; import { requireNodeEnvVar } from '../../server/utils'; import { stripeWebhook, stripeMiddlewareConfigFn } from './webhook'; +import Stripe from 'stripe'; +import { stripe } from './stripeClient'; +import { PaymentProcessors } from '../types'; export type StripeMode = 'subscription' | 'payment'; +/** + * Calculates total revenue from Stripe transactions + * @returns Promise resolving to total revenue in dollars + */ +async function fetchTotalStripeRevenue(): Promise { + let totalRevenue = 0; + let params: Stripe.BalanceTransactionListParams = { + limit: 100, + // created: { + // gte: startTimestamp, + // lt: endTimestamp + // }, + type: 'charge', + }; + + let hasMore = true; + while (hasMore) { + const balanceTransactions = await stripe.balanceTransactions.list(params); + + for (const transaction of balanceTransactions.data) { + if (transaction.type === 'charge') { + totalRevenue += transaction.amount; + } + } + + if (balanceTransactions.has_more) { + // Set the starting point for the next iteration to the last object fetched + params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id; + } else { + hasMore = false; + } + } + + // Revenue is in cents so we convert to dollars (or your main currency unit) + return totalRevenue / 100; +} + export const stripePaymentProcessor: PaymentProcessor = { - id: 'stripe', + id: PaymentProcessors.Stripe, createCheckoutSession: async ({ userId, userEmail, paymentPlan, prismaUserDelegate }: CreateCheckoutSessionArgs) => { const customer = await fetchStripeCustomer(userEmail); const stripeSession = await createStripeCheckoutSession({ @@ -32,6 +72,7 @@ export const stripePaymentProcessor: PaymentProcessor = { }, fetchCustomerPortalUrl: async (_args: FetchCustomerPortalUrlArgs) => requireNodeEnvVar('STRIPE_CUSTOMER_PORTAL_URL'), + getTotalRevenue: fetchTotalStripeRevenue, webhook: stripeWebhook, webhookMiddlewareConfigFn: stripeMiddlewareConfigFn, }; diff --git a/template/app/src/payment/types.ts b/template/app/src/payment/types.ts new file mode 100644 index 000000000..56b97986a --- /dev/null +++ b/template/app/src/payment/types.ts @@ -0,0 +1,13 @@ +/** + * All supported payment processors + */ +export enum PaymentProcessors { + Stripe = 'Stripe', + LemonSqueezy = 'LemonSqueezy', + Polar = 'Polar', +} + +/** + * All supported payment processor identifiers + */ +export type PaymentProcessorId = `${PaymentProcessors}`; diff --git a/template/app/src/payment/validation.ts b/template/app/src/payment/validation.ts new file mode 100644 index 000000000..5b17adb65 --- /dev/null +++ b/template/app/src/payment/validation.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { PaymentProcessorId, PaymentProcessors } from './types'; + +const processorSchemas: Record = { + [PaymentProcessors.Stripe]: { + STRIPE_API_KEY: z.string(), + STRIPE_WEBHOOK_SECRET: z.string(), + STRIPE_CUSTOMER_PORTAL_URL: z.string().url(), + }, + [PaymentProcessors.LemonSqueezy]: { + LEMONSQUEEZY_API_KEY: z.string(), + LEMONSQUEEZY_WEBHOOK_SECRET: z.string(), + LEMONSQUEEZY_STORE_ID: z.string(), + }, + [PaymentProcessors.Polar]: { + POLAR_ACCESS_TOKEN: z.string().min(10, 'POLAR_ACCESS_TOKEN must be at least 10 characters long'), + POLAR_ORGANIZATION_ID: z.string().min(1, 'POLAR_ORGANIZATION_ID cannot be empty'), + POLAR_WEBHOOK_SECRET: z + .string() + .min(8, 'POLAR_WEBHOOK_SECRET must be at least 8 characters long for security'), + POLAR_CUSTOMER_PORTAL_URL: z.string().url('POLAR_CUSTOMER_PORTAL_URL must be a valid URL'), + POLAR_SANDBOX_MODE: z.string().transform((val) => val === 'true'), + }, +}; + +/** + * Get the active payment processor from environment variables + * @param override Optional processor override for testing scenarios + * @returns The active payment processor ID + */ +export function getActivePaymentProcessor(override?: PaymentProcessorId): PaymentProcessorId { + if (override) { + return override; + } + + return (process.env.PAYMENT_PROCESSOR_ID as PaymentProcessorId) || PaymentProcessors.Stripe; +} + +const activePaymentProcessor: PaymentProcessorId = getActivePaymentProcessor(); +const processorSchema = processorSchemas[activePaymentProcessor]; + +/** + * Payment processor validation schema for active payment processor + */ +export const paymentSchema = { + PAYMENT_PROCESSOR_ID: z.nativeEnum(PaymentProcessors).default(PaymentProcessors.Stripe), + PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Product ID must contain only alphanumeric characters, hyphens, and underscores' + ), + PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Product ID must contain only alphanumeric characters, hyphens, and underscores' + ), + PAYMENTS_CREDITS_10_PLAN_ID: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Product ID must contain only alphanumeric characters, hyphens, and underscores' + ), + WASP_WEB_CLIENT_URL: z.string().url().default('http://localhost:3000'), + ...processorSchema, +}; diff --git a/template/app/src/server/validation.ts b/template/app/src/server/validation.ts index c94ebcdfb..63b40af70 100644 --- a/template/app/src/server/validation.ts +++ b/template/app/src/server/validation.ts @@ -1,5 +1,23 @@ +import { defineEnvValidationSchema } from 'wasp/env'; import { HttpError } from 'wasp/server'; import * as z from 'zod'; +import { paymentSchema } from '../payment/validation'; + +/** + * Add any custom environment variables here, e.g. + * const customSchema = { + * CUSTOM_ENV_VAR: z.string().min(1), + * }; + */ +const customSchema = {}; +const fullSchema = {...customSchema, ...paymentSchema} + +/** + * Complete environment validation schema + * + * If you need to add custom variables, add them to the customSchema object above. + */ +export const envValidationSchema = defineEnvValidationSchema(z.object(fullSchema)); export function ensureArgsSchemaOrThrowHttpError( schema: Schema,