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:
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user