From bce5be85a398c9c089dabf82aea3de44bdde7153 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Thu, 12 Dec 2024 04:00:39 -0300 Subject: [PATCH] add info to customer table and stripe customer data (#9004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --------- Co-authored-by: FĂ©lix Malfait --- .../billing/billing.controller.ts | 11 +- .../core-modules/billing/billing.module.ts | 9 +- .../enums/billing-webhook-events.enum.ts | 3 + ...ts => update-subscription-quantity.job.ts} | 10 +- .../billing-workspace-member.listener.ts | 14 +- .../billing-portal.workspace-service.ts | 4 +- .../billing-webhook-entitlement.service.ts | 52 ++++++ .../billing-webhook-subscription.service.ts | 114 ++++++++++++ .../services/billing-webhook.service.ts | 171 ------------------ .../billing/stripe/stripe.service.ts | 20 +- ...ent-to-entitlement-repository-data.util.ts | 23 +++ ...-event-to-customer-repository-data.util.ts | 14 ++ ...-subscription-item-repository-data.util.ts | 23 +++ ...nt-to-subscription-repository-data.util.ts | 67 +++++++ .../core-modules/message-queue/jobs.module.ts | 10 +- 15 files changed, 344 insertions(+), 201 deletions(-) rename packages/twenty-server/src/engine/core-modules/billing/jobs/{update-subscription.job.ts => update-subscription-quantity.job.ts} (86%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts 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 9cac8ec106..61179279c1 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 @@ -16,7 +16,8 @@ import { } from 'src/engine/core-modules/billing/billing.exception'; import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; +import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.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') export class BillingController { @@ -24,7 +25,8 @@ export class BillingController { constructor( private readonly stripeService: StripeService, - private readonly billingWehbookService: BillingWebhookService, + private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService, + private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService, private readonly billingSubscriptionService: BillingSubscriptionService, ) {} @@ -61,7 +63,7 @@ export class BillingController { return; } - await this.billingWehbookService.processStripeEvent( + await this.billingWebhookSubscriptionService.processStripeEvent( workspaceId, event.data, ); @@ -70,7 +72,7 @@ export class BillingController { event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED ) { try { - await this.billingWehbookService.processCustomerActiveEntitlement( + await this.billingWebhookEntitlementService.processStripeEvent( event.data, ); } catch (error) { @@ -82,6 +84,7 @@ export class BillingController { } } } + 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 0664c74682..9b42e88788 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 @@ -13,14 +13,15 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; 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 { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; +import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.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'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ imports: [ @@ -46,7 +47,8 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma controllers: [BillingController], providers: [ BillingSubscriptionService, - BillingWebhookService, + BillingWebhookSubscriptionService, + BillingWebhookEntitlementService, BillingPortalWorkspaceService, BillingResolver, BillingWorkspaceMemberListener, @@ -55,7 +57,6 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma exports: [ BillingSubscriptionService, BillingPortalWorkspaceService, - BillingWebhookService, BillingService, ], }) 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 efb1e5f571..132e796fc5 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,4 +4,7 @@ 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', } diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts rename to packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index 5843a4f36b..301322448f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -7,14 +7,14 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -export type UpdateSubscriptionJobData = { workspaceId: string }; +export type UpdateSubscriptionQuantityJobData = { workspaceId: string }; @Processor({ queueName: MessageQueue.billingQueue, scope: Scope.REQUEST, }) -export class UpdateSubscriptionJob { - protected readonly logger = new Logger(UpdateSubscriptionJob.name); +export class UpdateSubscriptionQuantityJob { + protected readonly logger = new Logger(UpdateSubscriptionQuantityJob.name); constructor( private readonly billingSubscriptionService: BillingSubscriptionService, @@ -22,8 +22,8 @@ export class UpdateSubscriptionJob { private readonly twentyORMManager: TwentyORMManager, ) {} - @Process(UpdateSubscriptionJob.name) - async handle(data: UpdateSubscriptionJobData): Promise { + @Process(UpdateSubscriptionQuantityJob.name) + async handle(data: UpdateSubscriptionQuantityJobData): Promise { const workspaceMemberRepository = await this.twentyORMManager.getRepository( 'workspaceMember', diff --git a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts index 9911bec57a..1cfe6efb93 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { - UpdateSubscriptionJob, - UpdateSubscriptionJobData, -} from 'src/engine/core-modules/billing/jobs/update-subscription.job'; + UpdateSubscriptionQuantityJob, + UpdateSubscriptionQuantityJobData, +} from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; @@ -11,8 +13,6 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; -import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; @Injectable() export class BillingWorkspaceMemberListener { @@ -33,8 +33,8 @@ export class BillingWorkspaceMemberListener { return; } - await this.messageQueueService.add( - UpdateSubscriptionJob.name, + await this.messageQueueService.add( + UpdateSubscriptionQuantityJob.name, { workspaceId: payload.workspaceId }, ); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index a601108a8f..344b6d1899 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -6,12 +6,11 @@ import { Repository } from 'typeorm'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class BillingPortalWorkspaceService { @@ -19,7 +18,6 @@ export class BillingPortalWorkspaceService { constructor( private readonly stripeService: StripeService, private readonly domainManagerService: DomainManagerService, - private readonly environmentService: EnvironmentService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @InjectRepository(UserWorkspace, 'core') diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts new file mode 100644 index 0000000000..1f34877b7e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import Stripe from 'stripe'; +import { Repository } from 'typeorm'; + +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util'; +@Injectable() +export class BillingWebhookEntitlementService { + protected readonly logger = new Logger(BillingWebhookEntitlementService.name); + constructor( + @InjectRepository(BillingSubscription, 'core') + private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingEntitlement, 'core') + private readonly billingEntitlementRepository: Repository, + ) {} + + async processStripeEvent( + data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, + ) { + const billingSubscription = + await this.billingSubscriptionRepository.findOne({ + where: { stripeCustomerId: data.object.customer }, + }); + + if (!billingSubscription) { + throw new BillingException( + 'Billing customer not found', + BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND, + ); + } + + const workspaceId = billingSubscription.workspaceId; + + await this.billingEntitlementRepository.upsert( + transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( + workspaceId, + data, + ), + { + conflictPaths: ['workspaceId', 'key'], + skipUpdateIfNoValuesChanged: true, + }, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts new file mode 100644 index 0000000000..3d773e47ce --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import Stripe from 'stripe'; +import { Repository } from 'typeorm'; + +import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; +import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util'; +import { transformStripeSubscriptionEventToSubscriptionItemRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util'; +import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util'; +import { + Workspace, + WorkspaceActivationStatus, +} from 'src/engine/core-modules/workspace/workspace.entity'; +@Injectable() +export class BillingWebhookSubscriptionService { + protected readonly logger = new Logger( + BillingWebhookSubscriptionService.name, + ); + constructor( + private readonly stripeService: StripeService, + @InjectRepository(BillingSubscription, 'core') + private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingSubscriptionItem, 'core') + private readonly billingSubscriptionItemRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + @InjectRepository(BillingCustomer, 'core') + private readonly billingCustomerRepository: Repository, + ) {} + + async processStripeEvent( + workspaceId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, + ) { + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + }); + + if (!workspace) { + return; + } + + await this.billingCustomerRepository.upsert( + transformStripeSubscriptionEventToCustomerRepositoryData( + workspaceId, + data, + ), + { + conflictPaths: ['workspaceId', 'stripeCustomerId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + + await this.billingSubscriptionRepository.upsert( + transformStripeSubscriptionEventToSubscriptionRepositoryData( + workspaceId, + data, + ), + { + conflictPaths: ['stripeSubscriptionId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + + const billingSubscription = + await this.billingSubscriptionRepository.findOneOrFail({ + where: { stripeSubscriptionId: data.object.id }, + }); + + await this.billingSubscriptionItemRepository.upsert( + transformStripeSubscriptionEventToSubscriptionItemRepositoryData( + billingSubscription.id, + data, + ), + { + conflictPaths: ['billingSubscriptionId', 'stripeProductId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + + if ( + data.object.status === SubscriptionStatus.Canceled || + data.object.status === SubscriptionStatus.Unpaid + ) { + await this.workspaceRepository.update(workspaceId, { + activationStatus: WorkspaceActivationStatus.INACTIVE, + }); + } + + if ( + (data.object.status === SubscriptionStatus.Active || + data.object.status === SubscriptionStatus.Trialing || + data.object.status === SubscriptionStatus.PastDue) && + workspace.activationStatus == WorkspaceActivationStatus.INACTIVE + ) { + await this.workspaceRepository.update(workspaceId, { + activationStatus: WorkspaceActivationStatus.ACTIVE, + }); + } + + await this.stripeService.updateCustomerMetadataWorkspaceId( + String(data.object.customer), + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts deleted file mode 100644 index d458061247..0000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import Stripe from 'stripe'; -import { Repository } from 'typeorm'; - -import { - BillingException, - BillingExceptionCode, -} from 'src/engine/core-modules/billing/billing.exception'; -import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; -import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; -import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; -import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum'; -import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { - Workspace, - WorkspaceActivationStatus, -} from 'src/engine/core-modules/workspace/workspace.entity'; - -@Injectable() -export class BillingWebhookService { - protected readonly logger = new Logger(BillingWebhookService.name); - constructor( - @InjectRepository(BillingSubscription, 'core') - private readonly billingSubscriptionRepository: Repository, - @InjectRepository(BillingEntitlement, 'core') - private readonly billingEntitlementRepository: Repository, - @InjectRepository(BillingSubscriptionItem, 'core') - private readonly billingSubscriptionItemRepository: Repository, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, - ) {} - - async processStripeEvent( - workspaceId: string, - data: - | Stripe.CustomerSubscriptionUpdatedEvent.Data - | Stripe.CustomerSubscriptionCreatedEvent.Data - | Stripe.CustomerSubscriptionDeletedEvent.Data, - ) { - const workspace = await this.workspaceRepository.findOne({ - where: { id: workspaceId }, - }); - - if (!workspace) { - return; - } - - await this.billingSubscriptionRepository.upsert( - { - workspaceId, - stripeCustomerId: data.object.customer as string, - stripeSubscriptionId: data.object.id, - status: data.object.status as SubscriptionStatus, - 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), - metadata: data.object.metadata, - collectionMethod: - data.object.collection_method.toUpperCase() as BillingSubscriptionCollectionMethod, - automaticTax: data.object.automatic_tax ?? undefined, - cancellationDetails: data.object.cancellation_details ?? undefined, - endedAt: data.object.ended_at - ? new Date(data.object.ended_at * 1000) - : undefined, - trialStart: data.object.trial_start - ? new Date(data.object.trial_start * 1000) - : undefined, - trialEnd: data.object.trial_end - ? new Date(data.object.trial_end * 1000) - : undefined, - cancelAt: data.object.cancel_at - ? new Date(data.object.cancel_at * 1000) - : undefined, - canceledAt: data.object.canceled_at - ? new Date(data.object.canceled_at * 1000) - : undefined, - }, - { - conflictPaths: ['stripeSubscriptionId'], - skipUpdateIfNoValuesChanged: true, - }, - ); - - const billingSubscription = - await this.billingSubscriptionRepository.findOneOrFail({ - where: { stripeSubscriptionId: data.object.id }, - }); - - await this.billingSubscriptionItemRepository.upsert( - data.object.items.data.map((item) => { - return { - billingSubscriptionId: billingSubscription.id, - stripeSubscriptionId: data.object.id, - stripeProductId: item.price.product as string, - stripePriceId: item.price.id, - stripeSubscriptionItemId: item.id, - quantity: item.quantity, - metadata: item.metadata, - billingThresholds: item.billing_thresholds ?? undefined, - }; - }), - { - conflictPaths: ['billingSubscriptionId', 'stripeProductId'], - skipUpdateIfNoValuesChanged: true, - }, - ); - - if ( - data.object.status === SubscriptionStatus.Canceled || - data.object.status === SubscriptionStatus.Unpaid - ) { - await this.workspaceRepository.update(workspaceId, { - activationStatus: WorkspaceActivationStatus.INACTIVE, - }); - } - - if ( - (data.object.status === SubscriptionStatus.Active || - data.object.status === SubscriptionStatus.Trialing || - data.object.status === SubscriptionStatus.PastDue) && - workspace.activationStatus == WorkspaceActivationStatus.INACTIVE - ) { - await this.workspaceRepository.update(workspaceId, { - activationStatus: WorkspaceActivationStatus.ACTIVE, - }); - } - } - - async processCustomerActiveEntitlement( - data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, - ) { - const billingSubscription = - await this.billingSubscriptionRepository.findOne({ - where: { stripeCustomerId: data.object.customer }, - }); - - if (!billingSubscription) { - throw new BillingException( - 'Billing customer not found', - BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND, - ); - } - - const workspaceId = billingSubscription.workspaceId; - const stripeCustomerId = data.object.customer; - - const activeEntitlementsKeys = data.object.entitlements.data.map( - (entitlement) => entitlement.lookup_key, - ); - - await this.billingEntitlementRepository.upsert( - Object.values(BillingEntitlementKey).map((key) => { - return { - workspaceId, - key, - value: activeEntitlementsKeys.includes(key), - stripeCustomerId, - }; - }), - { - conflictPaths: ['workspaceId', 'key'], - skipUpdateIfNoValuesChanged: true, - }, - ); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index 7e4a7e2570..efd9c02acb 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -5,9 +5,9 @@ import Stripe from 'stripe'; import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @Injectable() export class StripeService { @@ -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 + // Indeed when the checkout session is created, the customer is created and the tax calculation is done + // If we create a customer before the checkout session, the tax calculation is not done and the checkout session will fail + // I think that it's not risk worth to create a customer before the checkout session, it would only complicate the code for no signigicant gain async collectLastInvoice(stripeSubscriptionId: string) { const subscription = await this.stripe.subscriptions.retrieve( @@ -148,6 +151,19 @@ export class StripeService { ); } + async updateCustomerMetadataWorkspaceId( + stripeCustomerId: string, + workspaceId: string, + ) { + await this.stripe.customers.update(stripeCustomerId, { + metadata: { workspaceId: workspaceId }, + }); + } + + async getCustomer(stripeCustomerId: string) { + return await this.stripe.customers.retrieve(stripeCustomerId); + } + formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] { const productPrices: ProductPriceEntity[] = Object.values( prices diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts new file mode 100644 index 0000000000..7577f9a90a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.ts @@ -0,0 +1,23 @@ +import Stripe from 'stripe'; + +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; + +export const transformStripeEntitlementUpdatedEventToEntitlementRepositoryData = + ( + workspaceId: string, + data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, + ) => { + const stripeCustomerId = data.object.customer; + const activeEntitlementsKeys = data.object.entitlements.data.map( + (entitlement) => entitlement.lookup_key, + ); + + return Object.values(BillingEntitlementKey).map((key) => { + return { + workspaceId, + key, + value: activeEntitlementsKeys.includes(key), + stripeCustomerId, + }; + }); + }; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts new file mode 100644 index 0000000000..3cb313e27c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util.ts @@ -0,0 +1,14 @@ +import Stripe from 'stripe'; + +export const transformStripeSubscriptionEventToCustomerRepositoryData = ( + workspaceId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, +) => { + return { + workspaceId, + stripeCustomerId: String(data.object.customer), + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts new file mode 100644 index 0000000000..9817937c33 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util.ts @@ -0,0 +1,23 @@ +import Stripe from 'stripe'; + +export const transformStripeSubscriptionEventToSubscriptionItemRepositoryData = + ( + billingSubscriptionId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, + ) => { + return data.object.items.data.map((item) => { + return { + billingSubscriptionId, + stripeSubscriptionId: data.object.id, + stripeProductId: String(item.price.product), + stripePriceId: item.price.id, + stripeSubscriptionItemId: item.id, + quantity: item.quantity, + metadata: item.metadata, + billingThresholds: item.billing_thresholds ?? 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 new file mode 100644 index 0000000000..2b684dac48 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util.ts @@ -0,0 +1,67 @@ +import Stripe from 'stripe'; + +import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; + +export const transformStripeSubscriptionEventToSubscriptionRepositoryData = ( + workspaceId: string, + data: + | Stripe.CustomerSubscriptionUpdatedEvent.Data + | Stripe.CustomerSubscriptionCreatedEvent.Data + | Stripe.CustomerSubscriptionDeletedEvent.Data, +) => { + return { + workspaceId, + stripeCustomerId: String(data.object.customer), + stripeSubscriptionId: data.object.id, + status: getSubscriptionStatus(data.object.status), + 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), + metadata: data.object.metadata, + collectionMethod: + BillingSubscriptionCollectionMethod[ + data.object.collection_method.toUpperCase() + ], + automaticTax: data.object.automatic_tax ?? undefined, + cancellationDetails: data.object.cancellation_details ?? undefined, + endedAt: data.object.ended_at + ? new Date(data.object.ended_at * 1000) + : undefined, + trialStart: data.object.trial_start + ? new Date(data.object.trial_start * 1000) + : undefined, + trialEnd: data.object.trial_end + ? new Date(data.object.trial_end * 1000) + : undefined, + cancelAt: data.object.cancel_at + ? new Date(data.object.cancel_at * 1000) + : undefined, + canceledAt: data.object.canceled_at + ? new Date(data.object.canceled_at * 1000) + : undefined, + }; +}; + +const getSubscriptionStatus = (status: Stripe.Subscription.Status) => { + switch (status) { + case 'active': + return SubscriptionStatus.Active; + case 'canceled': + return SubscriptionStatus.Canceled; + case 'incomplete': + return SubscriptionStatus.Incomplete; + case 'incomplete_expired': + return SubscriptionStatus.IncompleteExpired; + case 'past_due': + return SubscriptionStatus.PastDue; + case 'paused': + return SubscriptionStatus.Paused; + case 'trialing': + return SubscriptionStatus.Trialing; + case 'unpaid': + return SubscriptionStatus.Unpaid; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 7e624cc672..2061e9889a 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -8,15 +8,15 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; -import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job'; +import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; +import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job'; +import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserModule } from 'src/engine/core-modules/user/user.module'; import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; -import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job'; -import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job'; @@ -26,8 +26,8 @@ import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-c import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; -import { WorkflowModule } from 'src/modules/workflow/workflow.module'; import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module'; +import { WorkflowModule } from 'src/modules/workflow/workflow.module'; @Module({ imports: [ @@ -57,7 +57,7 @@ import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module'; CleanInactiveWorkspaceJob, EmailSenderJob, DataSeedDemoWorkspaceJob, - UpdateSubscriptionJob, + UpdateSubscriptionQuantityJob, HandleWorkspaceMemberDeletedJob, ], })