From 797bb0559a8c5032de36da925f42b17950804f7a Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 15 Apr 2025 18:02:35 +0200 Subject: [PATCH] create stripe customer before checking out + update on command (#11578) two distincts fix in this PR - add billing threshold for current users (in migration command) - create stripe customer before checking out in order to enable cloud user to create multiple workspaces (with associated stripe customer - closes https://github.com/twentyhq/core-team-issues/issues/852) --- .../billing/billing.controller.ts | 7 +++ .../core-modules/billing/billing.exception.ts | 1 + .../core-modules/billing/billing.module.ts | 2 + ...-add-workflow-subscription-item.command.ts | 16 +++++++ .../enums/billing-webhook-events.enum.ts | 1 + .../filters/billing-api-exception.filter.ts | 1 + .../billing-portal.workspace-service.ts | 17 ++++--- .../services/stripe-checkout.service.ts | 17 +++++-- .../billing-webhook-customer.service.ts | 46 +++++++++++++++++++ 9 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service.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 f1cb55607..0635cba2e 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 @@ -23,6 +23,7 @@ import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/f import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service'; import { BillingWebhookAlertService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service'; +import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service'; import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service'; import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service'; @@ -42,6 +43,7 @@ export class BillingController { private readonly billingWebhookPriceService: BillingWebhookPriceService, private readonly billingWebhookAlertService: BillingWebhookAlertService, private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService, + private readonly billingWebhookCustomerService: BillingWebhookCustomerService, ) {} @Post('/webhooks') @@ -114,6 +116,11 @@ export class BillingController { event.data, ); + case BillingWebhookEvent.CUSTOMER_CREATED: + return await this.billingWebhookCustomerService.processStripeEvent( + event.data, + ); + case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED: case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED: case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: { diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index 75430e1cd..00998f346 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -16,6 +16,7 @@ export enum BillingExceptionCode { BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND', BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND', BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', + BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND', BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND', BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED', BILLING_MISSING_REQUEST_BODY = 'BILLING_MISSING_REQUEST_BODY', 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 f5991a3b3..9124de2bc 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 @@ -27,6 +27,7 @@ import { BillingUsageService } from 'src/engine/core-modules/billing/services/bi import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; import { BillingWebhookAlertService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service'; +import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-entitlement.service'; import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-invoice.service'; import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-price.service'; @@ -80,6 +81,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi BillingWebhookPriceService, BillingWebhookAlertService, BillingWebhookInvoiceService, + BillingWebhookCustomerService, BillingRestApiExceptionFilter, BillingSyncCustomerDataCommand, BillingSyncPlansDataCommand, diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts index 3e87dce38..bb2cb01a9 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command.ts @@ -14,6 +14,8 @@ import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -31,6 +33,8 @@ export class BillingAddWorkflowSubscriptionItemCommand extends ActiveOrSuspended @InjectRepository(BillingProduct, 'core') protected readonly billingProductRepository: Repository, private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, + private readonly stripeSubscriptionService: StripeSubscriptionService, + private readonly twentyConfigService: TwentyConfigService, ) { super(workspaceRepository, twentyORMGlobalManager); } @@ -81,6 +85,18 @@ export class BillingAddWorkflowSubscriptionItemCommand extends ActiveOrSuspended subscription.stripeSubscriptionId, associatedWorkflowMeteredPrice.stripePriceId, ); + + await this.stripeSubscriptionService.updateSubscription( + subscription.stripeSubscriptionId, + { + billing_thresholds: { + amount_gte: this.twentyConfigService.get( + 'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT', + ), + reset_billing_cycle_anchor: false, + }, + }, + ); } this.logger.log( 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 713cc5d42..44ac088b6 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,6 +4,7 @@ export enum BillingWebhookEvent { CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', + CUSTOMER_CREATED = 'customer.created', SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated', PRODUCT_CREATED = 'product.created', diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts index 34904f2d2..42fc2b9ea 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts @@ -40,6 +40,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter { case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND: case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND: case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND: + case BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND: case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND: case BillingExceptionCode.BILLING_PLAN_NOT_FOUND: case BillingExceptionCode.BILLING_METER_NOT_FOUND: 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 dad9b8706..d603cdc3b 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 @@ -3,14 +3,15 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { isDefined } from 'class-validator'; import Stripe from 'stripe'; +import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { BillingException, BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; +import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; @@ -30,6 +31,8 @@ export class BillingPortalWorkspaceService { private readonly domainManagerService: DomainManagerService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingCustomer, 'core') + private readonly billingCustomerRepository: Repository, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, ) {} @@ -56,12 +59,11 @@ export class BillingPortalWorkspaceService { workspaceId: workspace.id, }); - const subscription = await this.billingSubscriptionRepository.findOneBy({ - workspaceId: workspace.id, + const customer = await this.billingCustomerRepository.findOne({ + where: { workspaceId: workspace.id }, + relations: ['billingSubscriptions'], }); - const stripeCustomerId = subscription?.stripeCustomerId; - const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({ quantity, billingPricesPerPlan, @@ -74,10 +76,11 @@ export class BillingPortalWorkspaceService { stripeSubscriptionLineItems, successUrl, cancelUrl, - stripeCustomerId, + stripeCustomerId: customer?.stripeCustomerId, plan, requirePaymentMethod, - withTrialPeriod: !isDefined(subscription), + withTrialPeriod: + !isDefined(customer) || customer.billingSubscriptions.length === 0, }); assert(checkoutSession.url, 'Error: missing checkout.session.url'); diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts index ebe55ef16..dfd7f6730 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; +import { isDefined } from 'class-validator'; import Stripe from 'stripe'; import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; @@ -47,6 +48,17 @@ export class StripeCheckoutService { requirePaymentMethod?: boolean; withTrialPeriod: boolean; }): Promise { + if (!isDefined(stripeCustomerId)) { + const customer = await this.stripe.customers.create({ + email: user.email, + metadata: { + workspaceId, + }, + }); + + stripeCustomerId = customer.id; + } + return await this.stripe.checkout.sessions.create({ line_items: stripeSubscriptionLineItems, mode: 'subscription', @@ -73,10 +85,7 @@ export class StripeCheckoutService { automatic_tax: { enabled: !!requirePaymentMethod }, tax_id_collection: { enabled: !!requirePaymentMethod }, customer: stripeCustomerId, - customer_update: stripeCustomerId - ? { name: 'auto', address: 'auto' } - : undefined, - customer_email: stripeCustomerId ? undefined : user.email, + customer_update: { name: 'auto', address: 'auto' }, success_url: successUrl, cancel_url: cancelUrl, payment_method_collection: requirePaymentMethod diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service.ts new file mode 100644 index 000000000..dde779ff9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-customer.service.ts @@ -0,0 +1,46 @@ +/* @license Enterprise */ + +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 { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; + +@Injectable() +export class BillingWebhookCustomerService { + protected readonly logger = new Logger(BillingWebhookCustomerService.name); + constructor( + @InjectRepository(BillingCustomer, 'core') + private readonly billingCustomerRepository: Repository, + ) {} + + async processStripeEvent(data: Stripe.CustomerCreatedEvent.Data) { + const { id: stripeCustomerId, metadata } = data.object; + + const workspaceId = metadata?.workspaceId; + + if (!workspaceId) { + throw new BillingException( + 'Workspace ID is required for customer events', + BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND, + ); + } + + await this.billingCustomerRepository.upsert( + { + stripeCustomerId, + workspaceId, + }, + { + conflictPaths: ['workspaceId'], + skipUpdateIfNoValuesChanged: true, + }, + ); + } +}