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 { 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 { 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 { 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 { 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 { 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';
|
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 billingWebhookPriceService: BillingWebhookPriceService,
|
||||||
private readonly billingWebhookAlertService: BillingWebhookAlertService,
|
private readonly billingWebhookAlertService: BillingWebhookAlertService,
|
||||||
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
|
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
|
||||||
|
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/webhooks')
|
@Post('/webhooks')
|
||||||
@ -114,6 +116,11 @@ export class BillingController {
|
|||||||
event.data,
|
event.data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case BillingWebhookEvent.CUSTOMER_CREATED:
|
||||||
|
return await this.billingWebhookCustomerService.processStripeEvent(
|
||||||
|
event.data,
|
||||||
|
);
|
||||||
|
|
||||||
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
|
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
|
||||||
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED:
|
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED:
|
||||||
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: {
|
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export enum BillingExceptionCode {
|
|||||||
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
|
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
|
||||||
BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_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_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_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
|
||||||
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
|
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
|
||||||
BILLING_MISSING_REQUEST_BODY = 'BILLING_MISSING_REQUEST_BODY',
|
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 { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
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 { 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 { 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 { 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';
|
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,
|
BillingWebhookPriceService,
|
||||||
BillingWebhookAlertService,
|
BillingWebhookAlertService,
|
||||||
BillingWebhookInvoiceService,
|
BillingWebhookInvoiceService,
|
||||||
|
BillingWebhookCustomerService,
|
||||||
BillingRestApiExceptionFilter,
|
BillingRestApiExceptionFilter,
|
||||||
BillingSyncCustomerDataCommand,
|
BillingSyncCustomerDataCommand,
|
||||||
BillingSyncPlansDataCommand,
|
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 { 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 { 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 { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
|
|
||||||
@ -31,6 +33,8 @@ export class BillingAddWorkflowSubscriptionItemCommand extends ActiveOrSuspended
|
|||||||
@InjectRepository(BillingProduct, 'core')
|
@InjectRepository(BillingProduct, 'core')
|
||||||
protected readonly billingProductRepository: Repository<BillingProduct>,
|
protected readonly billingProductRepository: Repository<BillingProduct>,
|
||||||
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||||
|
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||||
|
private readonly twentyConfigService: TwentyConfigService,
|
||||||
) {
|
) {
|
||||||
super(workspaceRepository, twentyORMGlobalManager);
|
super(workspaceRepository, twentyORMGlobalManager);
|
||||||
}
|
}
|
||||||
@ -81,6 +85,18 @@ export class BillingAddWorkflowSubscriptionItemCommand extends ActiveOrSuspended
|
|||||||
subscription.stripeSubscriptionId,
|
subscription.stripeSubscriptionId,
|
||||||
associatedWorkflowMeteredPrice.stripePriceId,
|
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(
|
this.logger.log(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export enum BillingWebhookEvent {
|
|||||||
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
||||||
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||||
|
CUSTOMER_CREATED = 'customer.created',
|
||||||
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||||
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
||||||
PRODUCT_CREATED = 'product.created',
|
PRODUCT_CREATED = 'product.created',
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
|
|||||||
case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
|
case BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
|
case BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_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_PRODUCT_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
||||||
|
|||||||
@ -3,14 +3,15 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { isDefined } from 'class-validator';
|
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingException,
|
BillingException,
|
||||||
BillingExceptionCode,
|
BillingExceptionCode,
|
||||||
} from 'src/engine/core-modules/billing/billing.exception';
|
} 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 { 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 { 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';
|
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
|
||||||
@ -30,6 +31,8 @@ export class BillingPortalWorkspaceService {
|
|||||||
private readonly domainManagerService: DomainManagerService,
|
private readonly domainManagerService: DomainManagerService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
|
@InjectRepository(BillingCustomer, 'core')
|
||||||
|
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||||
@InjectRepository(UserWorkspace, 'core')
|
@InjectRepository(UserWorkspace, 'core')
|
||||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||||
) {}
|
) {}
|
||||||
@ -56,12 +59,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = await this.billingSubscriptionRepository.findOneBy({
|
const customer = await this.billingCustomerRepository.findOne({
|
||||||
workspaceId: workspace.id,
|
where: { workspaceId: workspace.id },
|
||||||
|
relations: ['billingSubscriptions'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const stripeCustomerId = subscription?.stripeCustomerId;
|
|
||||||
|
|
||||||
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||||
quantity,
|
quantity,
|
||||||
billingPricesPerPlan,
|
billingPricesPerPlan,
|
||||||
@ -74,10 +76,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
stripeSubscriptionLineItems,
|
stripeSubscriptionLineItems,
|
||||||
successUrl,
|
successUrl,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
stripeCustomerId,
|
stripeCustomerId: customer?.stripeCustomerId,
|
||||||
plan,
|
plan,
|
||||||
requirePaymentMethod,
|
requirePaymentMethod,
|
||||||
withTrialPeriod: !isDefined(subscription),
|
withTrialPeriod:
|
||||||
|
!isDefined(customer) || customer.billingSubscriptions.length === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(checkoutSession.url, 'Error: missing checkout.session.url');
|
assert(checkoutSession.url, 'Error: missing checkout.session.url');
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isDefined } from 'class-validator';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||||
@ -47,6 +48,17 @@ export class StripeCheckoutService {
|
|||||||
requirePaymentMethod?: boolean;
|
requirePaymentMethod?: boolean;
|
||||||
withTrialPeriod: boolean;
|
withTrialPeriod: boolean;
|
||||||
}): Promise<Stripe.Checkout.Session> {
|
}): 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({
|
return await this.stripe.checkout.sessions.create({
|
||||||
line_items: stripeSubscriptionLineItems,
|
line_items: stripeSubscriptionLineItems,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
@ -73,10 +85,7 @@ export class StripeCheckoutService {
|
|||||||
automatic_tax: { enabled: !!requirePaymentMethod },
|
automatic_tax: { enabled: !!requirePaymentMethod },
|
||||||
tax_id_collection: { enabled: !!requirePaymentMethod },
|
tax_id_collection: { enabled: !!requirePaymentMethod },
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
customer_update: stripeCustomerId
|
customer_update: { name: 'auto', address: 'auto' },
|
||||||
? { name: 'auto', address: 'auto' }
|
|
||||||
: undefined,
|
|
||||||
customer_email: stripeCustomerId ? undefined : user.email,
|
|
||||||
success_url: successUrl,
|
success_url: successUrl,
|
||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
payment_method_collection: requirePaymentMethod
|
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