diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index a7dc7fe5f..6ca6e73fb 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -19,6 +19,7 @@ import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webh import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service'; +import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service'; import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; @Controller('billing') @@ -31,6 +32,7 @@ export class BillingController { private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService, private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService, private readonly billingSubscriptionService: BillingSubscriptionService, + private readonly billingWebhookProductService: BillingWebhookProductService, ) {} @Post('/webhooks') @@ -88,6 +90,13 @@ export class BillingController { } } + if ( + event.type === WebhookEvent.PRODUCT_CREATED || + event.type === WebhookEvent.PRODUCT_UPDATED + ) { + await this.billingWebhookProductService.processStripeEvent(event.data); + } + res.status(200).end(); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 6fc9e399b..fabffc03f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -15,6 +15,7 @@ import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/ import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service'; +import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service'; import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; @@ -54,6 +55,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; BillingResolver, BillingWorkspaceMemberListener, BillingService, + BillingWebhookProductService, BillingRestApiExceptionFilter, ], exports: [ diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts index 132e796fc..c821dc49c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts @@ -4,7 +4,6 @@ export enum WebhookEvent { CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated', - CUSTOMER_CREATED = 'customer.created', - CUSTOMER_DELETED = 'customer.deleted', - CUSTOMER_UPDATED = 'customer.updated', + PRODUCT_CREATED = 'product.created', + PRODUCT_UPDATED = 'product.updated', } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts new file mode 100644 index 000000000..4a7ed22bb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import Stripe from 'stripe'; +import { Repository } from 'typeorm'; + +import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type'; +import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util'; +@Injectable() +export class BillingWebhookProductService { + protected readonly logger = new Logger(BillingWebhookProductService.name); + constructor( + @InjectRepository(BillingProduct, 'core') + private readonly billingProductRepository: Repository, + ) {} + + async processStripeEvent( + data: Stripe.ProductCreatedEvent.Data | Stripe.ProductUpdatedEvent.Data, + ) { + const metadata = data.object.metadata; + const isStripeValidProductMetadata = + this.isStripeValidProductMetadata(metadata); + const productRepositoryData = isStripeValidProductMetadata + ? { + ...transformStripeProductEventToProductRepositoryData(data), + metadata, + } + : transformStripeProductEventToProductRepositoryData(data); + + await this.billingProductRepository.upsert(productRepositoryData, { + conflictPaths: ['stripeProductId'], + skipUpdateIfNoValuesChanged: true, + }); + } + + isStripeValidProductMetadata( + metadata: Stripe.Metadata, + ): metadata is BillingProductMetadata { + if (Object.keys(metadata).length === 0) { + return true; + } + const hasBillingPlanKey = this.isValidBillingPlanKey(metadata?.planKey); + const hasPriceUsageBased = this.isValidPriceUsageBased( + metadata?.priceUsageBased, + ); + + return hasBillingPlanKey && hasPriceUsageBased; + } + + isValidBillingPlanKey(planKey: string | undefined) { + switch (planKey) { + case BillingPlanKey.BASE_PLAN: + return true; + case BillingPlanKey.PRO_PLAN: + return true; + default: + return false; + } + } + + isValidPriceUsageBased(priceUsageBased: string | undefined) { + switch (priceUsageBased) { + case BillingUsageType.METERED: + return true; + case BillingUsageType.LICENSED: + return true; + default: + return false; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts index 5cbe7cf08..39245f0ae 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts @@ -1,7 +1,10 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -export type BillingProductMetadata = { - planKey: BillingPlanKey; - priceUsageBased: BillingUsageType; -}; +export type BillingProductMetadata = + | { + planKey: BillingPlanKey; + priceUsageBased: BillingUsageType; + [key: string]: string; + } + | Record; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts new file mode 100644 index 000000000..6d4cf80c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util.ts @@ -0,0 +1,20 @@ +import Stripe from 'stripe'; + +export const transformStripeProductEventToProductRepositoryData = ( + data: Stripe.ProductUpdatedEvent.Data | Stripe.ProductCreatedEvent.Data, +) => { + return { + stripeProductId: data.object.id, + name: data.object.name, + active: data.object.active, + description: data.object.description, + images: data.object.images, + marketingFeatures: data.object.marketing_features, + defaultStripePriceId: data.object.default_price + ? String(data.object.default_price) + : undefined, + unitLabel: data.object.unit_label ?? undefined, + url: data.object.url ?? undefined, + taxCode: data.object.tax_code ? String(data.object.tax_code) : undefined, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts index 2b684dac4..3dacc09dc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts @@ -18,8 +18,8 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = ( interval: data.object.items.data[0].plan.interval, cancelAtPeriodEnd: data.object.cancel_at_period_end, currency: data.object.currency.toUpperCase(), - currentPeriodEnd: new Date(data.object.current_period_end * 1000), - currentPeriodStart: new Date(data.object.current_period_start * 1000), + currentPeriodEnd: getDateFromTimestamp(data.object.current_period_end), + currentPeriodStart: getDateFromTimestamp(data.object.current_period_start), metadata: data.object.metadata, collectionMethod: BillingSubscriptionCollectionMethod[ @@ -28,19 +28,19 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = ( automaticTax: data.object.automatic_tax ?? undefined, cancellationDetails: data.object.cancellation_details ?? undefined, endedAt: data.object.ended_at - ? new Date(data.object.ended_at * 1000) + ? getDateFromTimestamp(data.object.ended_at) : undefined, trialStart: data.object.trial_start - ? new Date(data.object.trial_start * 1000) + ? getDateFromTimestamp(data.object.trial_start) : undefined, trialEnd: data.object.trial_end - ? new Date(data.object.trial_end * 1000) + ? getDateFromTimestamp(data.object.trial_end) : undefined, cancelAt: data.object.cancel_at - ? new Date(data.object.cancel_at * 1000) + ? getDateFromTimestamp(data.object.cancel_at) : undefined, canceledAt: data.object.canceled_at - ? new Date(data.object.canceled_at * 1000) + ? getDateFromTimestamp(data.object.canceled_at) : undefined, }; }; @@ -65,3 +65,7 @@ const getSubscriptionStatus = (status: Stripe.Subscription.Status) => { return SubscriptionStatus.Unpaid; } }; + +const getDateFromTimestamp = (timestamp: number) => { + return new Date(timestamp * 1000); +};