-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Polar integration #461
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?
Polar integration #461
Conversation
- improve encapsulation and separation of concerns - simplify stats calculation
- add JSDoc comments for PaymentProcessor methods - improve JSDoc comments for exported object
- Enable processor selection by environment variable or specified override for testing scenarios - Refactor env var validation
- Add type-safety to webhook handler - Apply current Wasp env var validation
@vincanger Still a WIP, but as I've gotten to the point where I can create orders and subscriptions, I thought I should share the changes I've made that affect other parts of the codebase for discussion. Configurable provider selectionThe current codebase requires the developer implementing the template to modify the codebase to select which payment provider to implement. I understand the reasoning behind this decision, but it also makes it impossible to implement e2e tests for multiple providers without modifying the codebase. To address this, I implemented a new Updated schema validationOpenSaas currently uses a custom validation function to ensure the required env vars are set, but with the introduction of Zod validation, this seems redundant and so I implemented Zod-based validation for this provider, which could also be applied to the existing platforms, if desired. Refactored stats jobThe stats job previously contained functions for both Stripe and LemonSqueezy, which felt like a bit of code smell as it violates the open/closed principle, so I refactored that code to make the revenue calculation a function of the PaymentProcessor interface to be implemented by each integration. |
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.
Did a quick review. I see out moduleResolution
is causing import issues for the polar SDK. Hopefully we can get that sorted out quick. Everything looks to be on the right track though! The main thing I'd like to change at the moment is to remove comments that are redundant as many of them just repeat what's discernible from the function name.
*/ | ||
|
||
// @ts-ignore | ||
import { WebhookBenefitCreatedPayload } from '@polar-sh/sdk/models/components/webhookbenefitcreatedpayload.js'; |
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.
Ok so we just opened an issue to fix the moduleResolution
option on the Wasp end so that we can avioid this issue. So hopefully that gets done ASAP and we can merge the PR without it...
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.
I would wait on merging this maybe before moduleResolution
fix. Hopefully, we do get it before this is finished.
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.
Keeping an eye on wasp-lang/wasp#3054
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.
The reason it is taking so long is that most of the team is on vacation.
Should be reviewed soon hopefully.
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.
FYI it just got approved, should be merged soon.
Though, once merged, this will work but only with wasp-cli
the development verison of Wasp CLI.
It won't work with wasp
until we create a new release for it.
- Fix order status checking - Remove redundant subscription status mapping type and custom status values - Remove redundant JSDoc comments
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.
Hey @Genyus,
went over the PR.
Great work on this.
I'm sorry that we are asking you to remove so much from PR, but we want to focus on getting only Polar working here first.
Also I would appreciate it if you could reduce the amount of unnecessary jsdocs/comments. Didn't want to point it out everywhere since there is a lot. Do take care to use it only when we need it.
For now I've only went through the code, didn't run anything yet.
After you implement changes I would like to actually test everything out with Polar sandbox account. I would recommend you do the same.
template/app/.env.server.example
Outdated
@@ -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 |
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.
Hey, it's great to see that you're trying to improve open-saas
further above the Polar integration, but I wouldn't do this as part of this PR.
Paddle integration PR had the same problem here:
https://github.com/wasp-lang/open-saas/pull/486/files#r2266908329
Doing in this PR will distract us from the main point (Polar), and will prolong the process to get the feature we want.
I've explained in the Paddle PR (linked above) why we don't believe this approach is right for us.
I would kindly ask you to remove non-Polar parts of the PR.
Thanks for the effort.
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.
@FranjoMindek Thanks for the feedback. Happy to proceed, but one of the main reasons I adopted this approach was because I wanted to add e2e tests for Polar and the current architecture makes it impossible to run tests for more than one provider without modifying the source. Do you have any suggestions for how that limitation could be addressed?
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.
Hey @Genyus.
Truthfully, e2e-tests
are in a bad state and outdated.
We will want to improve them but that is a separate issue.
However we did start opening up issues about them:
#469
I wouldn't be concerned with improving them in this PR.
I think it's okay for now to require people to change the app
to test different payment providers in e2e-tests
.
What we want of e2e-tests
is that we have separate package.json
scripts to run for different providers.
So if we have the correct payment provider in app
and run the correct script, it works.
So I would rename the current scripts to be more explicit about stripe and then add scripts for Polar
.
This is kinda harder to do fully correct because we would have to change a lot of script names.
I would most likely suggest to do minimal changes for now, and we will refactor all of the names then in a separate PR dedicated to e2e-tests
.
So just change the script name for running Stripe e2e tests, and add yours to be similarly named.
"local:e2e:stripe:start": "npm run local:e2e:cleanup-stripe && npm run local:e2e:start-stripe && npm run local:e2e:playwright:ui && npm run local:e2e:cleanup-stripe",
"local:e2e:polar:start": "...",
},
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.
One thing in code part of e2e-tests
you would need to change is to make all "Stripe specific" functions be changed with "payment provider abstract" ones.
Now to know which one to run, you could do the environment variable trick in e2e-tests
, but we would set them through npm
scripts instead.
e.g. "local:e2e:polar:start": "PAYMENT_PROVIDER=polar ..."
Then those abstract functions would use PAYMENT_PROVIDER
to know what to do under the hood.
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.
There is probably a better way to do this (but it would require too much changes).
This will allows us to do this PR reasonably fast and without larger changes, and later on we can improve it.
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.
OK, understood. So I had already created a separate e2e-tests branch to work on these specifically and I use a PAYMENT_PROVIDER_ID
env var to handle provider-specific scripts, as you suggested. But I'll leave this alone until I've completed the requested changes to the application code.
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.
Okay, btw ping me here and/or on discord when you are ready for re-review.
- Remove payment processors and types - Restore hard-coded payment processor references - Remove validation schemas
- Remove unnecessary try/catch blocks - Remove excessive JSDoc comments
- Remove env var and rely solely on polar.customerSessions.create call
- Refactor sandbox mode selection logic
- Restore logic from previous order creation handler
- Restore original file content - Partial reversion of 721fd6f.
Pull changes from upstream
- Align with changes coming in wasp-lang#493
- Add support for subscription.revoked and subscription.uncanceled - Remove support for subscription.created (unnecessary with subscription.active)
- Clean up handler functions and reduce duplication - Implement dedicated handler for subscription.uncanceled event
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.
Hey Genyus, glad we reduce the PR to affect only Polar.
I went over it besides webook.ts
individual handle
functions for events.
I skipped them over for now because they might change from other comments.
One thing we are also missing here is documentation update to opensaas-sh/blog
.
We should add it once we are happy with Polar integration.
await handleOrderCompleted(data, prismaUserDelegate); | ||
|
||
break; | ||
case 'subscription.revoked': |
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.
I would be careful here that we don't react to events twice.
e.g. if subscription.revoked
also triggers subscription.updated
we are reacting twice to it -> bad.
Only add subscription events besides subscription.updated
if they happen without updated event.
Can you confirm this for me?
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.
Unfortunately, the Polar API emits two updated events for each action, e.g.
subscription.updated
subscription.canceled
subscription.updated
When applying an actual change to the subscription, we only receive the two subscription.update
events , and the payload for each event is identical besides the type
property.
The docs claim that the data.status
property should reflect the status of the subscription, suggesting we should be able to simply listen to the subscription.updated
event alone, but that hasn't been the case in my testing — the status value is always active
, regardless of the actual subscription state.
I've reached out in the Polar community to see if there's an explanation/resolution for this unexpected behaviour.
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.
Hey, still didn't do webhook.ts
file since I presume we are waiting for their response.
In the meantime I noticed I missed a single comment you made because it was resolved. So I responded now.
I also have a question around createPolarCheckoutSession
and ensurePolarCustomer
.
}: CreatePolarCheckoutSessionArgs): Promise<PolarCheckoutSession> { | ||
const baseUrl = env.WASP_WEB_CLIENT_URL.replace(/\/+$/, ''); | ||
const successUrl = `${baseUrl}/checkout?success=true`; | ||
const existingCustomer = await ensurePolarCustomer(userId, userEmail); |
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.
Ah now I see why ensurePolarCustomer
was exported.
Why do we ensurePolarCustomer
here?
ensurePolarCustomer
returns customer.id
which is also required in polarPaymentProcessor.createCheckoutSession
. We just used it here just to return it instead of using it before this function.
Hm, I see that we send both customerId
and externalCustomerId
customerEmail
pair to Polar.
Is both of them required?
I would assume no.
I didn't check docs for this, but I would guess you use either customerId
if you already created a customer (which we did) or you use externalCustomerId
customerEmail
pair if you want Polar to automatically handle customer creation for you instead.
Could you please check this?
If so we can simplify this a lot.
Is this possible?
export interface CreatePolarCheckoutSessionArgs {
productId: string;
customerId: string;
mode: PolarMode;
}
export interface PolarCheckoutSession {
id: string;
url: string;
}
export async function createPolarCheckoutSession({
productId,
customerId,
mode,
}: CreatePolarCheckoutSessionArgs): Promise<PolarCheckoutSession> {
const baseUrl = env.WASP_WEB_CLIENT_URL.replace(/\/+$/, '');
const checkoutSession = await polarClient.checkouts.create({
products: [productId],
successUrl: `${baseUrl}/checkout?success=true`,
metadata: {
paymentMode: mode,
source: baseUrl,
},
customerId,
});
return {
id: checkoutSession.id,
url: checkoutSession.url,
};
}
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.
Why do we
ensurePolarCustomer
here?
For a first-time order, a customer record is created and linked to the Wasp user on the Polar side. The connection isn't saved on the Wasp side until the webhook event is handled.
ensurePolarCustomer
returnscustomer.id
which is also required inpolarPaymentProcessor.createCheckoutSession
. We just used it here just to return it instead of using it before this function.
polarPaymentProcessor.createCheckoutSession
accepts the Wasp user ID as a parameter, not the Polar customer ID, which is what this method returns.
Hm, I see that we send both
customerId
andexternalCustomerId
customerEmail
pair to Polar.
Is both of them required?
I ran into an issue when switching between sandbox and production environments because the Polar customer ID is different in each. The webhook handlers look up the user by their Wasp user ID, not their Polar customer ID, to ensure that the event is always associated with the correct user, even if the environment changes. The externalCustomerId
value has to passed in to the checkout session in order to be retrieved in the webhook event and I included the email to make sure the Polar record is updated if the Wasp user ever changes their address.
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.
For a first-time order, a customer record is created and linked to the Wasp user on the Polar side. The connection isn't saved on the Wasp side until the webhook event is handled.
Inside of polarPaymentProcessor.createCheckoutSession
we use createPolarCheckoutSession
which uses ensurePolarCustomer
.
That means on checkout we create a Polar customer.
We also connect it to our user via prismaUserDelegate.update
where we update the paymentProcessorUserId
.
Checkout also doesn't mean the customer will make a purchase.
Why do we
ensurePolarCustomer
here?For a first-time order, a customer record is created and linked to the Wasp user on the Polar side. The connection isn't saved on the Wasp side until the webhook event is handled.
What I meant why not do:
const customer = await ensurePolarCustomer(userId, userEmail);
const session = await createPolarCheckoutSession({
productId: paymentPlan.getPaymentProcessorPlanId(),
customerId: customer.id,
mode: paymentPlanEffectToPolarMode(paymentPlan.effect),
});
await prismaUserDelegate.update({
where: {
id: userId,
},
data: {
paymentProcessorUserId: customer.id,
},
});
return {
session: {
id: session.id,
url: session.url,
},
};
This way we reduce the size input and output to createPolarCheckoutSession
. Which did unnecessary work inside of it.
I ran into an issue when switching between sandbox and production environments because the Polar customer ID is different in each.
That is how it should be.
One should use different database between sandbox and production.
This is okay.
The webhook handlers look up the user by their Wasp user ID, not their Polar customer ID, to ensure that the event is always associated with the correct user, even if the environment changes.
We shouldn't handle that.
The different environment should be used with a different database.
The externalCustomerId value has to passed in to the checkout session in order to be retrieved in the webhook event and I included the email to make sure the Polar record is updated if the Wasp user ever changes their address.
This should be handled in the "change user email action".
When we update the user's email we should also update it on their payment processor.
Currently Wasp doesn't support email change so we shouldn't bother with this.
If somebody implements it it's their responsibility to handle that.
import type { PrismaClient } from '@prisma/client'; | ||
import type { PaymentPlanId, SubscriptionStatus } from '../plans'; | ||
|
||
export const updateUserPolarPaymentDetails = async ( |
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.
This works too.
I just though why not use functions you already wrote as helper functions inside of this one.
But this works too.
Description
Adds support for Polar payments platform. Closes #441
Related to wasp-lang/wasp#3034 as we need to remove
Stitches
internally in order to be able to use the correctmoduleResolution
that Polar's SDK depends on in our tsconfig.Contributor Checklist