Skip to content

feat: paddle payment provider #486

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@ 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](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=paddle`
- `PAYMENT_PROCESSOR=lemonsqueezy`

For more details, check READMEs of each respective directory!
17 changes: 14 additions & 3 deletions template/app/.env.server.example
Original file line number Diff line number Diff line change
@@ -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_...
Expand All @@ -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
Expand All @@ -18,6 +28,7 @@ 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
PAYMENTS_HOBBY_SUBSCRIPTION_PLAN_ID=012345
PAYMENTS_PRO_SUBSCRIPTION_PLAN_ID=012345
Expand All @@ -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
[email protected]
# 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
Expand All @@ -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
AWS_S3_REGION=your-region
1 change: 1 addition & 0 deletions template/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions template/app/src/payment/paddle/checkoutUtils.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really want to check if there are no customers with that customerEmail here.

I assume customers customerCollection.next() always returns an array, sometimes empty sometimes with customers (according to types).

If it always returns an array !customers is always false.
image

So we always go to the second branch and try to fetch the customer, even if the array could be empty.

We should instead check the .length of the array.

if (customers.length === 0) {}

customer = await paddle.customers.create({
email: customerEmail,
});

await prisma.user.update({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay we have a function called createPaddleCheckoutSession but in reality it:

  • fetches Paddle customers
  • if none exist we create a Paddle customer
  • we update the User entity
  • we create a checkout session

We should split this function up.

I would create two separate functions:

  1. ensurePaddleCustomer
  2. createPaddleCheckoutSession

And then move "we update the User entity" part to paddlePaymentProcessor.createCheckoutSession in paddle/paymentProcessor.ts

To reiterate:

We first call ensurePaddleCustomer which:

  • fetches Paddle customers
  • if none exist we create a Paddle customer
  • returns the found/created Paddle customer

Then we update the User entity with prismaUserDelegate inside of paddlePaymentProcessor.createCheckoutSession.

Finally createPaddleCheckoutSession should:

  • create a checkout session

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something akin to this in paddlePaymentProcessor.createCheckoutSession:

const customer = await ensurePaddleCustomer({
  customerEmail: userEmail,
});

await prismaUserDelegate.update({
  where: {
    id: userId,
  },
  data: {
    paymentProcessorUserId: customer.id,
  },
});

const checkoutSession = await createPaddleCheckoutSession({
  userId,
  customerId: customer.id,
  priceId: paymentPlan.getPaymentProcessorPlanId(),
});

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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary?
I would assume paddle.customers.create would throw its own error if it failed?


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,
};
}
8 changes: 8 additions & 0 deletions template/app/src/payment/paddle/paddleClient.ts
Original file line number Diff line number Diff line change
@@ -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,
});
76 changes: 76 additions & 0 deletions template/app/src/payment/paddle/paymentDetails.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to check for user.

Prisma will throw error here if no users are found to be updated:

return await prismaUserDelegate.update({
  where: {
    id: user.id,
  },
  data: updateData,
});

And User.paymentProcessorUserId should exists as soon as the user created a single checkout session.
It's impossible to call updateUserPaddlePaymentDetails if he didn't create a checkout session and bought something, so we don't have to check for it.

throw new Error(`User not found for Paddle customer ID: ${paddleCustomerId}`);
}

const updateData: any = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing any you may find something like this more useful: https://github.com/wasp-lang/open-saas/pull/493/files#diff-167a62d0761d8215c87779c0a1a97a2b1e38db5f2be5180a8d644c4f4f80fb57

We can split updateUserPaddlePaymentDetails into two different sub-functions:

  • updateUserPaddleOneTimePaymentDetails
  • updateUserPaddleSubscriptionDetails

Also also same for the function arguments type:

export async function updateUserPaddlePaymentDetails(
  paymentDetails: UpdateUserPaddleOneTimePaymentDetails | UpdateUserPaddleSubscriptionDetails,
  prismaUserDelegate: PrismaClient['user']
) {
// ...
}

interface UpdateUserPaddleOneTimePaymentDetails {
  customerId: Customer['id'];
  datePaid: Date;
  numOfCreditsPurchased: number;
}

interface UpdateUserPaddleSubscriptionDetails {
  customerId: Customer['id'];
  subscriptionStatus: SubscriptionStatus;
  paymentPlanId?: PaymentPlanId;
  datePaid?: Date;
}

In that way we can keep everything type-safe.

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,
});
}
70 changes: 70 additions & 0 deletions template/app/src/payment/paddle/paymentProcessor.ts
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for prismaUserDelegate if we don't use it.

  createCheckoutSession: async ({ userId, userEmail, paymentPlan }: CreateCheckoutSessionArgs) => {
    const session = await createPaddleCheckoutSession({
      priceId: paymentPlan.getPaymentProcessorPlanId(),
      customerEmail: userEmail,
      userId,
    });

    return { session };
  },

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, it seems we will need it after all, we should use the prismaUserDelegate instead of prisma.user.

}: 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question.
Why not return: https://developer.paddle.com/concepts/customer-portal#customer-portal

We want to give people to both mange their subscriptions, change payment methods and view past invoices?
I don't know too much about paddle.

Is there differences from what you're doing here and that?
I see that paddle.customerPortalSessions exists.

// 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,
};
Loading