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)
This commit is contained in:
Etienne
2025-04-15 18:02:35 +02:00
committed by GitHub
parent dee779179b
commit 797bb0559a
9 changed files with 97 additions and 11 deletions

View File

@ -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: {

View File

@ -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',

View File

@ -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,

View File

@ -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<BillingProduct>,
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(

View File

@ -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',

View File

@ -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:

View File

@ -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<BillingSubscription>,
@InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {}
@ -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');

View File

@ -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<Stripe.Checkout.Session> {
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

View File

@ -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<BillingCustomer>,
) {}
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,
},
);
}
}