-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
add info to customer table and stripe customer data #9004
Conversation
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.
PR Summary
This PR refactors the billing webhook handling by splitting it into specialized services for customers, subscriptions, and entitlements, while adding functionality to sync customer metadata between Stripe and the local database.
- Added new
BillingWebhookCustomerService
to handle customer lifecycle events and metadata synchronization - Split
BillingWebhookService
into three focused services (Customer
,Subscription
,Entitlement
) for better separation of concerns - Added transformer utilities in
/utils
to standardize Stripe event data conversion - Added handling for
CUSTOMER_CREATED/UPDATED/DELETED
webhook events inbilling.controller.ts
- Enhanced error handling with new
BILLING_CUSTOMER_DELETED
exception code
💡 (3/5) Reply to the bot's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!
11 file(s) reviewed, 13 comment(s)
Edit PR Review Bot Settings | Greptile
if (customer.deleted) { | ||
throw new BillingException( | ||
'Stripe customer deleted', | ||
BillingExceptionCode.BILLING_CUSTOMER_DELETED, | ||
); | ||
} |
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.
logic: Throwing an exception here will cause the request to fail without sending a response, since there's no try/catch. Should return an error response instead.
import { transformStripeEntitlementEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/billing-webhook-entitlement-transformer.util'; | ||
@Injectable() | ||
export class BillingWebhookEntitlementService { | ||
protected readonly logger = new Logger(BillingWebhookEntitlementService.name); |
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.
style: logger is initialized but never used in the service
await this.billingEntitlementRepository.upsert( | ||
transformStripeEntitlementEventToEntitlementRepositoryData( | ||
workspaceId, | ||
data, | ||
), | ||
{ | ||
conflictPaths: ['workspaceId', 'key'], | ||
skipUpdateIfNoValuesChanged: true, | ||
}, | ||
); |
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.
style: consider wrapping this operation in a transaction to ensure data consistency if the upsert fails
if (!workspace) { | ||
return; | ||
} |
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.
style: silently returning on missing workspace could mask configuration issues - consider logging a warning
const billingSubscription = | ||
await this.billingSubscriptionRepository.findOneOrFail({ | ||
where: { stripeSubscriptionId: data.object.id }, | ||
}); |
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.
logic: findOneOrFail could throw an exception if subscription not found - needs try/catch
await this.billingCustomerRepository.upsert( | ||
{ | ||
workspaceId: stripeMetadataWorkspaceId, | ||
stripeCustomerId: data.object.id, | ||
}, | ||
{ | ||
conflictPaths: ['workspaceId', 'stripeCustomerId'], | ||
skipUpdateIfNoValuesChanged: true, | ||
}, | ||
); |
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.
logic: No error handling around the upsert operation. Should wrap in try/catch to handle potential database errors.
); | ||
} | ||
|
||
async verifyStripeCustomerMetadata(stripeCustomer: Stripe.Customer) { |
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.
logic: verifyStripeCustomerMetadata returns undefined in success case and workspaceId in error case - this is counterintuitive and could cause bugs. Consider standardizing the return type.
stripeCustomerId: data.object.customer as string, | ||
stripeSubscriptionId: data.object.id, | ||
status: data.object.status as SubscriptionStatus, | ||
interval: data.object.items.data[0].plan.interval, |
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.
logic: Accessing items.data[0] without checking array length could cause runtime errors if subscription has no items
async updateCustomerMetadataWorkspaceId( | ||
stripeCustomerId: string, | ||
workspaceId: string, | ||
) { | ||
await this.stripe.customers.update(stripeCustomerId, { | ||
metadata: { workspaceId: workspaceId }, | ||
}); | ||
} |
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.
style: consider handling case where customer update fails - currently no error handling or retries
) => { | ||
return { | ||
workspaceId, | ||
stripeCustomerId: data.object.customer as string, |
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.
logic: Type casting customer as string without validation could be unsafe if customer is not a string ID
workspaceId, | ||
event.data, | ||
); | ||
const customer = await this.stripeService.getCustomer( | ||
event.data.object.customer as string, |
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 need to cast as?
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.
because the event.data.object.customer is a Stripe.Subscription.customer which can be a
string | Stripe.Customer | Stripe.DeletedCustomer.
Thus we force the cast to string if we want to retrieve the customer from stripe using the stripeId (string)
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.
as
is a Typescript keyword, it doesn't really perform real casting as I think you're implying here. Agree with Charles that it feels like a code smell. Are you sure it isn't always returning a customer object and then you can access .id?
); | ||
} | ||
|
||
async verifyStripeCustomerMetadata(stripeCustomer: Stripe.Customer) { |
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 think this function is not useful and does not do what we expect it to do (it's returning undefined sometimes, sometimes the workspaceId).
We could rename it to: getWorkspaceIdFromBillingCustomer
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 feel we actually don't need this method, let's just move this logic to the subscription webhook service
workspaceId, | ||
event.data, | ||
); | ||
const customer = await this.stripeService.getCustomer( |
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 should move this logic into billingWebhookSubscriptionService to not pollute the controller which is handling all events (it will end up being very long if we keed adding code here)
customer, | ||
); | ||
|
||
if (stripeMetadataNeedsUpdate) { |
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 is actually a workspaceId!
); | ||
|
||
if (updatedStripeWorkspaceId) { | ||
await this.stripeService.updateCustomerMetadataWorkspaceId( |
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 think this should be moved within processStripeCustomerEvent too
@@ -11,4 +11,5 @@ export class BillingException extends CustomException { | |||
|
|||
export enum BillingExceptionCode { | |||
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', | |||
BILLING_CUSTOMER_DELETED = 'BILLING_CUSTOMER_DELETED', |
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 is it an issue, we don't want to update deleted customers subscription?
}); | ||
|
||
if (!billingCustomer && !stripeMetadataWorkspaceId) { | ||
return; //do nothing |
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.
comment not useful!
}); | ||
|
||
if (!billingCustomer && !stripeMetadataWorkspaceId) { | ||
return; //do nothing |
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 nothing btw, is this a normal behavior?
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 function was done that if the customer is not registered in our database and there is no metadata we don't give the workspaceId necessary to update the stripe customer. It is the normal behaviour , I will update the logic in order to have clearer coding patterns
return; //do nothing | ||
} | ||
if (billingCustomer && !stripeMetadataWorkspaceId) { | ||
return billingCustomer.workspaceId; //send stripe to update (stripe customer metadata is not yet up to date) |
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.
no comments!
private readonly billingCustomerRepository: Repository<BillingCustomer>, | ||
) {} | ||
|
||
async processStripeCustomerEvent( |
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 function should not return a workspaceId (you need it because you are putting some other logic depending in the controller). If it does we should make the naming clear
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.
(but it does not IMO)
return; //do nothing | ||
} | ||
if (billingCustomer && !stripeMetadataWorkspaceId) { | ||
return billingCustomer.workspaceId; //send stripe to update (stripe customer metadata is not yet up to date) |
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.
no!
}); | ||
|
||
await this.billingSubscriptionItemRepository.upsert( | ||
transformStripeSubscriptionEventToSubscriptionItemRepositoryData( |
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.
nice, very clear
@@ -114,7 +114,10 @@ export class StripeService { | |||
success_url: successUrl, | |||
cancel_url: cancelUrl, | |||
}); | |||
} | |||
} // I prefered to not create a customer with metadat before the checkout, because it would break the tax calculation |
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.
understand the comment but why is it in here?
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.
because Felix suggested this method for adding the customer metadata
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 think what Charles meant is that in most cases we keep this kind of comment for the PR but don't leave them in code
|
||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; | ||
|
||
export const transformStripeEntitlementEventToEntitlementRepositoryData = ( |
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.
file name does not meet function name
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.
based on data type, looks the name should reflect the fact this is an UpdatedEvent
) => { | ||
return { | ||
workspaceId, | ||
stripeCustomerId: data.object.customer as string, |
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.
as string?
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.
Because
data.object.customer is of type
(property) Stripe.Price.product: string | Stripe.Product | Stripe.DeletedProduct
This is the same method we use to upsert data in BillingSubscriptionItemRepository (it hasn't changed). Do we need to refactorize?
}; | ||
}; | ||
|
||
export const transformStripeSubscriptionEventToSubscriptionItemRepositoryData = |
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 util, one file!
@@ -61,16 +65,38 @@ export class BillingController { | |||
return; | |||
} | |||
|
|||
await this.billingWehbookService.processStripeEvent( | |||
await this.billingWebhookSubscriptionService.processStripeSubscriptionEvent( |
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.
processStripeEvent? (Can it be something else than a subscriptionEvent if if's in the subscription service already)
@@ -114,7 +114,10 @@ export class StripeService { | |||
success_url: successUrl, | |||
cancel_url: cancelUrl, | |||
}); | |||
} | |||
} // I prefered to not create a customer with metadat before the checkout, because it would break the tax calculation |
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 think what Charles meant is that in most cases we keep this kind of comment for the PR but don't leave them in code
workspaceId, | ||
event.data, | ||
); | ||
const customer = await this.stripeService.getCustomer( | ||
event.data.object.customer as string, |
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.
as
is a Typescript keyword, it doesn't really perform real casting as I think you're implying here. Agree with Charles that it feels like a code smell. Are you sure it isn't always returning a customer object and then you can access .id?
…twentyhq/twenty into feat/real-time-billing-table-population
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.
very clear! bravo!
Thanks @anamarn for your contribution! |
Solves (https://github.com/twentyhq/private-issues/issues/194)
TLDR
Updates the billingCustomer table data using stripe webhooks event, also updates the customer's metadata in stripe, in order to contain the workspaceId associated to this customer.
In order to test
Billing:
Authenticate with your account in the stripe CLI
Run the command: stripe listen --forward-to http://localhost:3000/billing/webhooks
Run the twenty workker
Authenticate yourself on the app choose a plan and run the app normally. In stripe and in posgress the customer table data should be added.
Next steps
Learn more about integrations tests and implement some for this PR.