Skip to content
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

Merged
merged 5 commits into from
Dec 12, 2024

Conversation

anamarn
Copy link
Contributor

@anamarn anamarn commented Dec 10, 2024

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:

  • Set IS_BILLING_ENABLED to true
  • Add your BILLING_STRIPE_SECRET and BILLING_STRIPE_API_KEY
  • Add your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID (use the one in testMode > Base Plan)

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.

@anamarn anamarn requested a review from FelixMalfait December 10, 2024 14:11
Copy link
Contributor

@greptile-apps greptile-apps bot left a 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 in billing.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

Comment on lines 76 to 81
if (customer.deleted) {
throw new BillingException(
'Stripe customer deleted',
BillingExceptionCode.BILLING_CUSTOMER_DELETED,
);
}
Copy link
Contributor

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

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

Comment on lines 41 to 50
await this.billingEntitlementRepository.upsert(
transformStripeEntitlementEventToEntitlementRepositoryData(
workspaceId,
data,
),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
Copy link
Contributor

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

Comment on lines +47 to +49
if (!workspace) {
return;
}
Copy link
Contributor

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

Comment on lines +73 to +76
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId: data.object.id },
});
Copy link
Contributor

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

Comment on lines 34 to 43
await this.billingCustomerRepository.upsert(
{
workspaceId: stripeMetadataWorkspaceId,
stripeCustomerId: data.object.id,
},
{
conflictPaths: ['workspaceId', 'stripeCustomerId'],
skipUpdateIfNoValuesChanged: true,
},
);
Copy link
Contributor

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

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,
Copy link
Contributor

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

Comment on lines +154 to +161
async updateCustomerMetadataWorkspaceId(
stripeCustomerId: string,
workspaceId: string,
) {
await this.stripe.customers.update(stripeCustomerId, {
metadata: { workspaceId: workspaceId },
});
}
Copy link
Contributor

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,
Copy link
Contributor

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,
Copy link
Member

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?

Copy link
Contributor Author

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)

Copy link
Member

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) {
Copy link
Member

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

Copy link
Member

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(
Copy link
Member

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) {
Copy link
Member

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(
Copy link
Member

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',
Copy link
Member

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
Copy link
Member

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
Copy link
Member

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?

Copy link
Contributor Author

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)
Copy link
Member

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(
Copy link
Member

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

Copy link
Member

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)
Copy link
Member

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(
Copy link
Member

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
Copy link
Member

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?

Copy link
Contributor Author

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

Copy link
Member

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 = (
Copy link
Member

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

Copy link
Member

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,
Copy link
Member

Choose a reason for hiding this comment

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

as string?

Copy link
Contributor Author

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 =
Copy link
Member

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(
Copy link
Member

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
Copy link
Member

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,
Copy link
Member

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?

Copy link
Member

@charlesBochet charlesBochet left a comment

Choose a reason for hiding this comment

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

very clear! bravo!

@charlesBochet charlesBochet merged commit bce5be8 into main Dec 12, 2024
19 checks passed
@charlesBochet charlesBochet deleted the feat/real-time-billing-table-population branch December 12, 2024 07:00
Copy link

Thanks @anamarn for your contribution!
This marks your 13th PR on the repo. You're top 3% of all our contributors 🎉
See contributor page - Share on LinkedIn - Share on Twitter

Contributions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants