-
-
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 |
---|---|---|
@@ -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.