-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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_... | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 |
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) { | ||
customer = await paddle.customers.create({ | ||
email: customerEmail, | ||
}); | ||
|
||
await prisma.user.update({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay we have a function called
We should split this function up. I would create two separate functions:
And then move "we update the To reiterate:We first call
Then we update the Finally
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Something akin to this in 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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this necessary? |
||
|
||
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()}`, | ||
jedpattersonpaddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
url: checkoutUrl, | ||
}; | ||
} |
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'; | ||
jedpattersonpaddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
export const paddle = new Paddle(requireNodeEnvVar('PADDLE_API_KEY'), { | ||
jedpattersonpaddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
environment: env as Environment, | ||
}); |
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
throw new Error(`User not found for Paddle customer ID: ${paddleCustomerId}`); | ||
} | ||
|
||
const updateData: any = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of doing We can split
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, | ||
}); | ||
} | ||
jedpattersonpaddle marked this conversation as resolved.
Show resolved
Hide resolved
|
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need for createCheckoutSession: async ({ userId, userEmail, paymentPlan }: CreateCheckoutSessionArgs) => {
const session = await createPaddleCheckoutSession({
priceId: paymentPlan.getPaymentProcessorPlanId(),
customerEmail: userEmail,
userId,
});
return { session };
}, There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
}: 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a question. We want to give people to both mange their subscriptions, change payment methods and view past invoices? Is there differences from what you're doing here and that? |
||
// 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, | ||
}; |
There was a problem hiding this comment.
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.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.