From 05d470601b9cbe93f1833b6dd7245d07aedaa7e5 Mon Sep 17 00:00:00 2001 From: jedpattersonpaddle Date: Mon, 4 Aug 2025 15:05:17 +0100 Subject: [PATCH 1/2] paddle init --- template/README.md | 21 ++ template/app/.env.server.example | 17 +- template/app/package.json | 1 + .../app/src/payment/paddle/checkoutUtils.ts | 72 ++++++ .../app/src/payment/paddle/paddleClient.ts | 8 + .../app/src/payment/paddle/paymentDetails.ts | 76 ++++++ .../src/payment/paddle/paymentProcessor.ts | 70 ++++++ template/app/src/payment/paddle/webhook.ts | 229 ++++++++++++++++++ template/app/src/payment/paymentProcessor.ts | 27 ++- 9 files changed, 513 insertions(+), 8 deletions(-) create mode 100644 template/app/src/payment/paddle/checkoutUtils.ts create mode 100644 template/app/src/payment/paddle/paddleClient.ts create mode 100644 template/app/src/payment/paddle/paymentDetails.ts create mode 100644 template/app/src/payment/paddle/paymentProcessor.ts create mode 100644 template/app/src/payment/paddle/webhook.ts diff --git a/template/README.md b/template/README.md index d6621b4b6..05e5820fa 100644 --- a/template/README.md +++ b/template/README.md @@ -5,4 +5,25 @@ This project is based on [OpenSaas](https://opensaas.sh) template and consists o 2. `e2e-tests` - [Playwright](https://playwright.dev/) tests for your Wasp web app. 3. `blog` - Your blog / docs, built with [Astro](https://docs.astro.build) based on [Starlight](https://starlight.astro.build/) template. +## Payment Processors + +Open SaaS supports three payment processors out of the box: + +### Stripe +The most popular payment processor with extensive features and global reach. + +### Lemon Squeezy +Great for digital products with built-in tax handling and global compliance. + +### Paddle +A comprehensive payment solution with built-in tax handling, subscription management, and global compliance. + +To switch between payment processors, set the `PAYMENT_PROCESSOR` environment variable: +- `PAYMENT_PROCESSOR=stripe` (default) +- `PAYMENT_PROCESSOR=lemonsqueezy` +- `PAYMENT_PROCESSOR=paddle` + +Each processor has its own configuration file: +- `.env.server.example` - Default Stripe configuration + For more details, check READMEs of each respective directory! diff --git a/template/app/.env.server.example b/template/app/.env.server.example index 65d02b9e1..4bbf76f9c 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -1,7 +1,10 @@ -# NOTE: you can let Wasp set up your Postgres DB by running `wasp start db` in a separate terminal window. +# NOTE: you can let Wasp set up your Postgres DB by running `wasp start db` in a separate terminal window. # then, in a new terminal window, run `wasp db migrate-dev` and finally `wasp start`. # If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here. # DATABASE_URL= +# +# # Set the payment processor to use: 'stripe', 'lemonsqueezy', or 'paddle' (defaults to 'stripe') +PAYMENT_PROCESSOR=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_... @@ -10,6 +13,13 @@ STRIPE_WEBHOOK_SECRET=whsec_... # You can find your Stripe customer portal URL in the Stripe Dashboard under the 'Customer Portal' settings. STRIPE_CUSTOMER_PORTAL_URL=https://billing.stripe.com/... +# For testing with Paddle, create a sandbox account at https://sandbox-vendors.paddle.com +PADDLE_API_KEY=test_... +# Set up your webhook secret in Paddle Dashboard -> Developer Tools -> Notifications +PADDLE_WEBHOOK_SECRET=pdl_... +# Create a hosted checkout in Paddle Dashboard -> Checkout -> Hosted Checkout +PADDLE_HOSTED_CHECKOUT_URL=https://sandbox-pay.paddle.io/hsc_... + # For testing, create a new store in test mode on https://lemonsqueezy.com LEMONSQUEEZY_API_KEY=eyJ... # After creating a store, you can find your store id in the store settings https://app.lemonsqueezy.com/settings/stores @@ -19,6 +29,7 @@ LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret # 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 +# If using Paddle, go to https://sandbox-vendors.paddle.com/catalog and create your products and prices PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_CREDITS_10_PLAN_ID=012345 @@ -44,7 +55,7 @@ PLAUSIBLE_BASE_URL=https://plausible.io/api # if you are self-hosting plausible, # (OPTIONAL) get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts GOOGLE_ANALYTICS_CLIENT_EMAIL=email@example.gserviceaccount.com -# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info. +# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info. GOOGLE_ANALYTICS_PRIVATE_KEY=LS02... # You will find your Property ID in the Google Analytics dashboard. It will look like '987654321' GOOGLE_ANALYTICS_PROPERTY_ID=123456789 @@ -53,4 +64,4 @@ GOOGLE_ANALYTICS_PROPERTY_ID=123456789 AWS_S3_IAM_ACCESS_KEY=ACK... AWS_S3_IAM_SECRET_KEY=t+33a... AWS_S3_FILES_BUCKET=your-bucket-name -AWS_S3_REGION=your-region \ No newline at end of file +AWS_S3_REGION=your-region diff --git a/template/app/package.json b/template/app/package.json index adb0b6119..752a888bd 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -9,6 +9,7 @@ "@headlessui/react": "1.7.13", "@hookform/resolvers": "^5.1.1", "@lemonsqueezy/lemonsqueezy.js": "^3.2.0", + "@paddle/paddle-node-sdk": "^3.2.0", "@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/payment/paddle/checkoutUtils.ts b/template/app/src/payment/paddle/checkoutUtils.ts new file mode 100644 index 000000000..3c0a5972b --- /dev/null +++ b/template/app/src/payment/paddle/checkoutUtils.ts @@ -0,0 +1,72 @@ +import { paddle } from './paddleClient'; +import { requireNodeEnvVar } from '../../server/utils'; +import { prisma } from 'wasp/server'; +import { Customer } from '@paddle/paddle-node-sdk'; + +export interface CreatePaddleCheckoutSessionArgs { + priceId: string; + customerEmail: string; + userId: string; +} + +export async function createPaddleCheckoutSession({ + priceId, + customerEmail, + userId, +}: CreatePaddleCheckoutSessionArgs) { + const baseCheckoutUrl = requireNodeEnvVar('PADDLE_HOSTED_CHECKOUT_URL'); + let customer: Customer; + + const customerCollection = paddle.customers.list({ + email: [customerEmail], + }); + + const customers = await customerCollection.next(); + + if (!customers) { + customer = await paddle.customers.create({ + email: customerEmail, + }); + + await prisma.user.update({ + where: { + id: userId, + }, + data: { + paymentProcessorUserId: customer.id, + }, + }); + } else { + customer = customers[0]; + await prisma.user.update({ + where: { + id: userId, + }, + data: { + paymentProcessorUserId: customer.id, + }, + }); + } + + if (!customer) throw new Error('Could not create customer'); + + const transaction = await paddle.transactions.create({ + items: [{ priceId, quantity: 1 }], + customData: { + userId, + }, + customerId: customer.id, + }); + + const params = new URLSearchParams({ + price_id: priceId, + transaction_id: transaction.id, + }); + + const checkoutUrl = `${baseCheckoutUrl}?${params.toString()}`; + + return { + id: `paddle_checkout_${Date.now()}`, + url: checkoutUrl, + }; +} diff --git a/template/app/src/payment/paddle/paddleClient.ts b/template/app/src/payment/paddle/paddleClient.ts new file mode 100644 index 000000000..4fa372761 --- /dev/null +++ b/template/app/src/payment/paddle/paddleClient.ts @@ -0,0 +1,8 @@ +import { Paddle, Environment } from '@paddle/paddle-node-sdk'; +import { requireNodeEnvVar } from '../../server/utils'; + +const env = process.env.NODE_ENV === 'production' ? 'production' : 'sandbox'; + +export const paddle = new Paddle(requireNodeEnvVar('PADDLE_API_KEY'), { + environment: env as Environment, +}); diff --git a/template/app/src/payment/paddle/paymentDetails.ts b/template/app/src/payment/paddle/paymentDetails.ts new file mode 100644 index 000000000..98a633918 --- /dev/null +++ b/template/app/src/payment/paddle/paymentDetails.ts @@ -0,0 +1,76 @@ +import type { PrismaClient } from '@prisma/client'; +import type { PaymentPlanId, SubscriptionStatus } from '../plans'; + +export interface UpdateUserPaddlePaymentDetailsArgs { + paddleCustomerId: string; + userId?: string; + subscriptionPlan?: PaymentPlanId; + subscriptionStatus?: SubscriptionStatus; + numOfCreditsPurchased?: number; + datePaid?: Date; +} + +/** + * Updates the user's payment details in the database after a successful Paddle payment or subscription change. + */ +export async function updateUserPaddlePaymentDetails( + args: UpdateUserPaddlePaymentDetailsArgs, + prismaUserDelegate: PrismaClient['user'] +) { + const { + paddleCustomerId, + userId, + subscriptionPlan, + subscriptionStatus, + numOfCreditsPurchased, + datePaid, + } = args; + + // Find user by paddleCustomerId first, then by userId as fallback + let user = await prismaUserDelegate.findFirst({ + where: { + paymentProcessorUserId: paddleCustomerId, + }, + }); + + if (!user && userId) { + user = await prismaUserDelegate.findUniqueOrThrow({ + where: { + id: userId, + }, + }); + } + + if (!user) { + throw new Error(`User not found for Paddle customer ID: ${paddleCustomerId}`); + } + + const updateData: any = { + paymentProcessorUserId: paddleCustomerId, + }; + + if (subscriptionPlan !== undefined) { + updateData.subscriptionPlan = subscriptionPlan; + } + + if (subscriptionStatus !== undefined) { + updateData.subscriptionStatus = subscriptionStatus; + } + + if (numOfCreditsPurchased !== undefined) { + updateData.credits = { + increment: numOfCreditsPurchased, + }; + } + + if (datePaid !== undefined) { + updateData.datePaid = datePaid; + } + + return await prismaUserDelegate.update({ + where: { + id: user.id, + }, + data: updateData, + }); +} \ No newline at end of file diff --git a/template/app/src/payment/paddle/paymentProcessor.ts b/template/app/src/payment/paddle/paymentProcessor.ts new file mode 100644 index 000000000..095945471 --- /dev/null +++ b/template/app/src/payment/paddle/paymentProcessor.ts @@ -0,0 +1,70 @@ +import type { + CreateCheckoutSessionArgs, + FetchCustomerPortalUrlArgs, + PaymentProcessor, +} from '../paymentProcessor'; +import { createPaddleCheckoutSession } from './checkoutUtils'; +import { paddleWebhook, paddleMiddlewareConfigFn } from './webhook'; +import { paddle } from './paddleClient'; + +export const paddlePaymentProcessor: PaymentProcessor = { + id: 'paddle', + createCheckoutSession: async ({ + userId, + userEmail, + paymentPlan, + prismaUserDelegate, + }: CreateCheckoutSessionArgs) => { + const session = await createPaddleCheckoutSession({ + priceId: paymentPlan.getPaymentProcessorPlanId(), + customerEmail: userEmail, + userId, + }); + + return { session }; + }, + fetchCustomerPortalUrl: async ({ userId, prismaUserDelegate }: FetchCustomerPortalUrlArgs) => { + const user = await prismaUserDelegate.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + paymentProcessorUserId: true, + }, + }); + + if (!user.paymentProcessorUserId) { + return null; + } + + try { + // Get customer subscriptions to find an active one for the portal URL + const subscriptionCollection = paddle.subscriptions.list({ + customerId: [user.paymentProcessorUserId], + }); + + const subscriptions = await subscriptionCollection.next(); + + if (subscriptions.length === 0) { + return null; + } + + const activeSubscription = subscriptions.find((sub) => sub.status === 'active'); + if (activeSubscription?.managementUrls?.updatePaymentMethod) { + return activeSubscription.managementUrls.updatePaymentMethod; + } + + // Fallback to cancel URL if no update payment method URL is available - shouldn't happen + if (activeSubscription?.managementUrls?.cancel) { + return activeSubscription.managementUrls.cancel; + } + + return null; + } catch (error) { + console.error('Error fetching Paddle customer portal URL:', error); + return null; + } + }, + webhook: paddleWebhook, + webhookMiddlewareConfigFn: paddleMiddlewareConfigFn, +}; diff --git a/template/app/src/payment/paddle/webhook.ts b/template/app/src/payment/paddle/webhook.ts new file mode 100644 index 000000000..25da24403 --- /dev/null +++ b/template/app/src/payment/paddle/webhook.ts @@ -0,0 +1,229 @@ +import { type MiddlewareConfigFn, HttpError } from 'wasp/server'; +import { type PaymentsWebhook } from 'wasp/server/api'; +import { type PrismaClient } from '@prisma/client'; +import express from 'express'; +import { paymentPlans, PaymentPlanId, SubscriptionStatus, type PaymentPlanEffect } from '../plans'; +import { updateUserPaddlePaymentDetails } from './paymentDetails'; +import { requireNodeEnvVar } from '../../server/utils'; +import { assertUnreachable } from '../../shared/utils'; +import { UnhandledWebhookEventError } from '../errors'; +import { paddle } from './paddleClient'; +import { EventEntity, EventName } from '@paddle/paddle-node-sdk'; + +export const paddleWebhook: PaymentsWebhook = async (request, response, context) => { + try { + const eventData = await parseRequestBody(request); + const prismaUserDelegate = context.entities.User; + + switch (eventData.eventType) { + case 'subscription.created': + await handleSubscriptionCreated(eventData, prismaUserDelegate); + break; + case 'subscription.updated': + await handleSubscriptionUpdated(eventData, prismaUserDelegate); + break; + case 'subscription.canceled': + await handleSubscriptionCanceled(eventData, prismaUserDelegate); + break; + default: + // @ts-ignore + assertUnreachable(eventData.eventType); + } + + return response.status(200).json({ received: true }); + } catch (err) { + if (err instanceof UnhandledWebhookEventError) { + console.error(err.message); + return response.status(422).json({ error: err.message }); + } + + console.error('Paddle webhook error:', err); + if (err instanceof HttpError) { + return response.status(err.statusCode).json({ error: err.message }); + } else { + return response.status(400).json({ error: 'Error processing Paddle webhook event' }); + } + } +}; + +async function parseRequestBody(request: express.Request): Promise { + const requestBody = request.body.toString(); + const signature = request.get('paddle-signature'); + + if (!signature) { + throw new HttpError(400, 'Paddle webhook signature not provided'); + } + + const webhookSecret = requireNodeEnvVar('PADDLE_WEBHOOK_SECRET'); + + // Verify the webhook signature + const eventData = await paddle.webhooks.unmarshal(requestBody, webhookSecret, signature); + + return eventData; +} + +export const paddleMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => { + // Use raw middleware for webhook signature verification + middlewareConfig.delete('express.json'); + middlewareConfig.set('express.raw', express.raw({ type: 'application/json' })); + return middlewareConfig; +}; + +async function handleSubscriptionCreated(eventData: EventEntity, prismaUserDelegate: PrismaClient['user']) { + if (eventData.eventType !== EventName.SubscriptionCreated) return; + + if (!eventData.data.customerId) { + throw new Error(`No customer ID found in transaction ${eventData.data.id}`); + } + + const priceId = eventData.data.items[0].price?.id as string; + const planId = getPlanIdByPriceId(priceId); + const plan = paymentPlans[planId]; + + const { numOfCreditsPurchased, subscriptionPlan } = getPlanEffectPaymentDetails({ + planId, + planEffect: plan.effect, + }); + + await updateUserPaddlePaymentDetails( + { + paddleCustomerId: eventData.data.customerId, + // @ts-ignore userId isn't typed as it's custom data + userId: eventData.data.customData?.userId as string, + numOfCreditsPurchased, + subscriptionPlan, + subscriptionStatus: subscriptionPlan ? SubscriptionStatus.Active : undefined, + datePaid: new Date(), + }, + prismaUserDelegate + ); + + console.log(`Transaction ${eventData.data.id} completed for customer ${eventData.data.customerId}`); +} + +async function handleSubscriptionUpdated(eventData: EventEntity, prismaUserDelegate: PrismaClient['user']) { + if (eventData.eventType !== EventName.SubscriptionUpdated) return; + + const subscription = eventData.data; + const priceId = subscription.items[0].price?.id as string; + const planId = getPlanIdByPriceId(priceId); + + // @ts-ignore userId isn't typed as it's custom data + const userId = subscription.customData?.userId as string | undefined; + + let subscriptionStatus: SubscriptionStatus; + + // Check for scheduled changes + if (subscription.scheduledChange && subscription.scheduledChange !== null) { + switch (subscription.scheduledChange.action) { + case 'cancel': + // Subscription is scheduled to cancel but still active until effective_at + subscriptionStatus = + subscription.status === 'active' ? SubscriptionStatus.Active : SubscriptionStatus.Deleted; + console.log( + `Subscription ${subscription.id} scheduled to cancel on ${subscription.scheduledChange.effectiveAt}` + ); + break; + case 'pause': + // Subscription is scheduled to pause but still active until effective_at + subscriptionStatus = + subscription.status === 'active' ? SubscriptionStatus.Active : SubscriptionStatus.PastDue; + console.log( + `Subscription ${subscription.id} scheduled to pause on ${subscription.scheduledChange.effectiveAt}` + ); + break; + case 'resume': + // Subscription is scheduled to resume + subscriptionStatus = + subscription.status === 'paused' ? SubscriptionStatus.PastDue : SubscriptionStatus.Active; + console.log( + `Subscription ${subscription.id} scheduled to resume on ${ + subscription.scheduledChange.resumeAt || subscription.scheduledChange.effectiveAt + }` + ); + break; + default: + // Fallback to regular status handling + subscriptionStatus = getSubscriptionStatusFromPaddleStatus(subscription.status, subscription.id); + } + } else { + // No scheduled changes, handle based on current status + subscriptionStatus = getSubscriptionStatusFromPaddleStatus(subscription.status, subscription.id); + } + + const user = await updateUserPaddlePaymentDetails( + { + paddleCustomerId: subscription.customerId, + userId, + subscriptionPlan: planId, + subscriptionStatus, + ...(subscription.status === 'active' && { datePaid: new Date() }), + }, + prismaUserDelegate + ); + + console.log(`Subscription ${subscription.id} updated for customer ${subscription.customerId}`); +} + +async function handleSubscriptionCanceled(eventData: EventEntity, prismaUserDelegate: PrismaClient['user']) { + if (eventData.eventType !== EventName.SubscriptionCanceled) return; + + await updateUserPaddlePaymentDetails( + { + paddleCustomerId: eventData.data.customerId, + // @ts-ignore userId is not typed in customData + userId: eventData.data.customData?.userId as string, + subscriptionStatus: SubscriptionStatus.Deleted, + }, + prismaUserDelegate + ); + + console.log(`Subscription ${eventData.data.id} canceled for customer ${eventData.data.customerId}`); +} + +function getPlanIdByPriceId(priceId: string): PaymentPlanId { + const planId = Object.values(PaymentPlanId).find( + (planId) => paymentPlans[planId].getPaymentProcessorPlanId() === priceId + ); + if (!planId) { + throw new Error(`No plan found with Paddle price ID: ${priceId}`); + } + return planId; +} + +function getPlanEffectPaymentDetails({ + planId, + planEffect, +}: { + planId: PaymentPlanId; + planEffect: PaymentPlanEffect; +}): { + subscriptionPlan: PaymentPlanId | undefined; + numOfCreditsPurchased: number | undefined; +} { + switch (planEffect.kind) { + case 'subscription': + return { subscriptionPlan: planId, numOfCreditsPurchased: undefined }; + case 'credits': + return { subscriptionPlan: undefined, numOfCreditsPurchased: planEffect.amount }; + default: + assertUnreachable(planEffect); + } +} + +function getSubscriptionStatusFromPaddleStatus(status: string, subscriptionId: string): SubscriptionStatus { + switch (status) { + case 'active': + return SubscriptionStatus.Active; + case 'past_due': + return SubscriptionStatus.PastDue; + case 'canceled': + return SubscriptionStatus.Deleted; + case 'paused': + // Treating paused as past due for now + return SubscriptionStatus.PastDue; + default: + console.log(`Ignoring subscription ${subscriptionId} with status: ${status}`); + return SubscriptionStatus.PastDue; // Safe fallback + } +} diff --git a/template/app/src/payment/paymentProcessor.ts b/template/app/src/payment/paymentProcessor.ts index 9049e7214..d4a58d243 100644 --- a/template/app/src/payment/paymentProcessor.ts +++ b/template/app/src/payment/paymentProcessor.ts @@ -4,6 +4,7 @@ import type { MiddlewareConfigFn } from 'wasp/server'; import { PrismaClient } from '@prisma/client'; import { stripePaymentProcessor } from './stripe/paymentProcessor'; import { lemonSqueezyPaymentProcessor } from './lemonSqueezy/paymentProcessor'; +import { paddlePaymentProcessor } from './paddle/paymentProcessor'; export interface CreateCheckoutSessionArgs { userId: string; @@ -17,7 +18,7 @@ export interface FetchCustomerPortalUrlArgs { }; export interface PaymentProcessor { - id: 'stripe' | 'lemonsqueezy'; + id: 'stripe' | 'lemonsqueezy' | 'paddle'; createCheckoutSession: (args: CreateCheckoutSessionArgs) => Promise<{ session: { id: string; url: string }; }>; fetchCustomerPortalUrl: (args: FetchCustomerPortalUrlArgs) => Promise; webhook: PaymentsWebhook; @@ -25,8 +26,24 @@ export interface PaymentProcessor { } /** - * Choose which payment processor you'd like to use, then delete the - * other payment processor code that you're not using from `/src/payment` + * Choose which payment processor you'd like to use by setting the PAYMENT_PROCESSOR environment variable. + * Valid options: 'stripe', 'lemonsqueezy', 'paddle' + * Defaults to 'stripe' if not set. */ -// export const paymentProcessor: PaymentProcessor = lemonSqueezyPaymentProcessor; -export const paymentProcessor: PaymentProcessor = stripePaymentProcessor; +function getPaymentProcessor(): PaymentProcessor { + const processorType = process.env.PAYMENT_PROCESSOR || 'stripe'; + + switch (processorType) { + case 'stripe': + return stripePaymentProcessor; + case 'lemonsqueezy': + return lemonSqueezyPaymentProcessor; + case 'paddle': + return paddlePaymentProcessor; + default: + console.warn(`Unknown payment processor: ${processorType}. Defaulting to Stripe.`); + return stripePaymentProcessor; + } +} + +export const paymentProcessor: PaymentProcessor = getPaymentProcessor(); From 8144be0672b63d94588512343c3a587684ddeae7 Mon Sep 17 00:00:00 2001 From: jedpattersonpaddle Date: Thu, 7 Aug 2025 11:06:19 +0100 Subject: [PATCH 2/2] update readme --- template/README.md | 17 ++++------------- template/app/.env.server.example | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/template/README.md b/template/README.md index 05e5820fa..402ca1924 100644 --- a/template/README.md +++ b/template/README.md @@ -8,22 +8,13 @@ This project is based on [OpenSaas](https://opensaas.sh) template and consists o ## Payment Processors Open SaaS supports three payment processors out of the box: - -### Stripe -The most popular payment processor with extensive features and global reach. - -### Lemon Squeezy -Great for digital products with built-in tax handling and global compliance. - -### Paddle -A comprehensive payment solution with built-in tax handling, subscription management, and global compliance. +- [Stripe](https://stripe.com/) +- [Paddle](https://www.paddle.com/) +- [LemonSqueezy](https://www.lemonsqueezy.com/) To switch between payment processors, set the `PAYMENT_PROCESSOR` environment variable: - `PAYMENT_PROCESSOR=stripe` (default) -- `PAYMENT_PROCESSOR=lemonsqueezy` - `PAYMENT_PROCESSOR=paddle` - -Each processor has its own configuration file: -- `.env.server.example` - Default Stripe configuration +- `PAYMENT_PROCESSOR=lemonsqueezy` For more details, check READMEs of each respective directory! diff --git a/template/app/.env.server.example b/template/app/.env.server.example index 4bbf76f9c..96fced92a 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -28,8 +28,8 @@ LEMONSQUEEZY_STORE_ID=012345 LEMONSQUEEZY_WEBHOOK_SECRET=my-webhook-secret # If using Stripe, go to https://dashboard.stripe.com/test/products and click on + Add Product +# If using Paddle, go to https://vendors.paddle.com/catalog and create your products and prices # If using Lemon Squeezy, go to https://app.lemonsqueezy.com/products and create new products and variants -# If using Paddle, go to https://sandbox-vendors.paddle.com/catalog and create your products and prices PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345 PAYMENTS_CREDITS_10_PLAN_ID=012345