add stripe alert listening and cap on subscriptionItems (#11330)
in this PR : - reverting https://github.com/twentyhq/twenty/pull/11319 > at trial period end, subscriptions switch to 'past_due' status if payment method not set up - adding cap on subscriptionItems and updating them when receiving alert event + refreshing them when beginning a new subscription cycle closes https://github.com/twentyhq/core-team-issues/issues/606
This commit is contained in:
@ -6,9 +6,9 @@ import { InformationBannerReconnectAccountInsufficientPermissions } from '@/info
|
|||||||
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
||||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { SubscriptionStatus } from '~/generated-metadata/graphql';
|
|
||||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||||
|
import { SubscriptionStatus } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
const StyledInformationBannerWrapper = styled.div`
|
const StyledInformationBannerWrapper = styled.div`
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -40,7 +40,7 @@ export const InformationBannerWrapper = () => {
|
|||||||
<InformationBannerReconnectAccountInsufficientPermissions />
|
<InformationBannerReconnectAccountInsufficientPermissions />
|
||||||
<InformationBannerReconnectAccountEmailAliases />
|
<InformationBannerReconnectAccountEmailAliases />
|
||||||
{displayBillingSubscriptionPausedBanner && (
|
{displayBillingSubscriptionPausedBanner && (
|
||||||
<InformationBannerBillingSubscriptionPaused />
|
<InformationBannerBillingSubscriptionPaused /> // TODO: remove this once paused subscriptions are deprecated
|
||||||
)}
|
)}
|
||||||
{displayBillingSubscriptionCanceledBanner && (
|
{displayBillingSubscriptionCanceledBanner && (
|
||||||
<InformationBannerNoBillingSubscription />
|
<InformationBannerNoBillingSubscription />
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name =
|
||||||
|
'AddHasReachedCurrentPeriodCapColumnInBillingSubscriptionItemTable1743577268972';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."billingSubscriptionItem" ADD "hasReachedCurrentPeriodCap" boolean NOT NULL DEFAULT false`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "hasReachedCurrentPeriodCap"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,9 @@ import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billi
|
|||||||
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
||||||
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 { 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 { 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';
|
||||||
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
|
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
|
||||||
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
|
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
|
||||||
@ -38,6 +40,8 @@ export class BillingController {
|
|||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
private readonly billingWebhookProductService: BillingWebhookProductService,
|
private readonly billingWebhookProductService: BillingWebhookProductService,
|
||||||
private readonly billingWebhookPriceService: BillingWebhookPriceService,
|
private readonly billingWebhookPriceService: BillingWebhookPriceService,
|
||||||
|
private readonly billingWebhookAlertService: BillingWebhookAlertService,
|
||||||
|
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/webhooks')
|
@Post('/webhooks')
|
||||||
@ -100,6 +104,16 @@ export class BillingController {
|
|||||||
event.data,
|
event.data,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case BillingWebhookEvent.ALERT_TRIGGERED:
|
||||||
|
return await this.billingWebhookAlertService.processStripeEvent(
|
||||||
|
event.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
case BillingWebhookEvent.INVOICE_FINALIZED:
|
||||||
|
return await this.billingWebhookInvoiceService.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: {
|
||||||
|
|||||||
@ -24,7 +24,9 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
|
|||||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||||
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 { 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 { 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';
|
||||||
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
|
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-product.service';
|
||||||
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
|
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service';
|
||||||
@ -73,6 +75,8 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
|||||||
BillingService,
|
BillingService,
|
||||||
BillingWebhookProductService,
|
BillingWebhookProductService,
|
||||||
BillingWebhookPriceService,
|
BillingWebhookPriceService,
|
||||||
|
BillingWebhookAlertService,
|
||||||
|
BillingWebhookInvoiceService,
|
||||||
BillingRestApiExceptionFilter,
|
BillingRestApiExceptionFilter,
|
||||||
BillingSyncCustomerDataCommand,
|
BillingSyncCustomerDataCommand,
|
||||||
BillingSyncPlansDataCommand,
|
BillingSyncPlansDataCommand,
|
||||||
|
|||||||
@ -63,4 +63,7 @@ export class BillingSubscriptionItem {
|
|||||||
|
|
||||||
@Column({ nullable: true, type: 'numeric' })
|
@Column({ nullable: true, type: 'numeric' })
|
||||||
quantity: number | null;
|
quantity: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
hasReachedCurrentPeriodCap: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export enum SubscriptionStatus {
|
|||||||
Incomplete = 'incomplete',
|
Incomplete = 'incomplete',
|
||||||
IncompleteExpired = 'incomplete_expired',
|
IncompleteExpired = 'incomplete_expired',
|
||||||
PastDue = 'past_due',
|
PastDue = 'past_due',
|
||||||
Paused = 'paused',
|
Paused = 'paused', // TODO: remove this once paused subscriptions are deprecated
|
||||||
Trialing = 'trialing',
|
Trialing = 'trialing',
|
||||||
Unpaid = 'unpaid',
|
Unpaid = 'unpaid',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,4 +10,6 @@ export enum BillingWebhookEvent {
|
|||||||
PRODUCT_UPDATED = 'product.updated',
|
PRODUCT_UPDATED = 'product.updated',
|
||||||
PRICE_CREATED = 'price.created',
|
PRICE_CREATED = 'price.created',
|
||||||
PRICE_UPDATED = 'price.updated',
|
PRICE_UPDATED = 'price.updated',
|
||||||
|
ALERT_TRIGGERED = 'billing.alert.triggered',
|
||||||
|
INVOICE_FINALIZED = 'invoice.finalized',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
import { JsonContains, Repository } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingException,
|
BillingException,
|
||||||
@ -35,11 +35,11 @@ export class BillingPlanService {
|
|||||||
}): Promise<BillingProduct[]> {
|
}): Promise<BillingProduct[]> {
|
||||||
const products = await this.billingProductRepository.find({
|
const products = await this.billingProductRepository.find({
|
||||||
where: {
|
where: {
|
||||||
metadata: {
|
metadata: JsonContains({
|
||||||
planKey,
|
|
||||||
priceUsageBased,
|
priceUsageBased,
|
||||||
|
planKey,
|
||||||
isBaseProduct,
|
isBaseProduct,
|
||||||
},
|
}),
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
relations: ['billingPrices'],
|
relations: ['billingPrices'],
|
||||||
|
|||||||
@ -65,7 +65,6 @@ export class BillingPortalWorkspaceService {
|
|||||||
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||||
quantity,
|
quantity,
|
||||||
billingPricesPerPlan,
|
billingPricesPerPlan,
|
||||||
forTrialSubscription: !isDefined(subscription),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkoutSession =
|
const checkoutSession =
|
||||||
@ -128,11 +127,9 @@ export class BillingPortalWorkspaceService {
|
|||||||
private getStripeSubscriptionLineItems({
|
private getStripeSubscriptionLineItems({
|
||||||
quantity,
|
quantity,
|
||||||
billingPricesPerPlan,
|
billingPricesPerPlan,
|
||||||
forTrialSubscription,
|
|
||||||
}: {
|
}: {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||||
forTrialSubscription: boolean;
|
|
||||||
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||||
if (billingPricesPerPlan) {
|
if (billingPricesPerPlan) {
|
||||||
return [
|
return [
|
||||||
@ -140,11 +137,9 @@ export class BillingPortalWorkspaceService {
|
|||||||
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
||||||
quantity,
|
quantity,
|
||||||
},
|
},
|
||||||
...(forTrialSubscription
|
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
||||||
? []
|
price: price.stripePriceId,
|
||||||
: billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
})),
|
||||||
price: price.stripePriceId,
|
|
||||||
}))),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
|
|||||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
|
||||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||||
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
|
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
|
||||||
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';
|
||||||
@ -199,50 +198,4 @@ export class BillingSubscriptionService {
|
|||||||
|
|
||||||
return subscriptionItemsToUpdate;
|
return subscriptionItemsToUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
async convertTrialSubscriptionToSubscriptionWithMeteredProducts(
|
|
||||||
billingSubscription: BillingSubscription,
|
|
||||||
) {
|
|
||||||
const meteredProducts = (
|
|
||||||
await this.billingProductRepository.find({
|
|
||||||
where: {
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
relations: ['billingPrices'],
|
|
||||||
})
|
|
||||||
).filter(
|
|
||||||
(product) =>
|
|
||||||
product.metadata.priceUsageBased === BillingUsageType.METERED,
|
|
||||||
);
|
|
||||||
|
|
||||||
// subscription update to enable metered product billing
|
|
||||||
await this.stripeSubscriptionService.updateSubscription(
|
|
||||||
billingSubscription.stripeSubscriptionId,
|
|
||||||
{
|
|
||||||
trial_settings: {
|
|
||||||
end_behavior: {
|
|
||||||
missing_payment_method: 'cancel',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const meteredProduct of meteredProducts) {
|
|
||||||
const meteredProductPrice = meteredProduct.billingPrices.find(
|
|
||||||
(price) => price.active,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!meteredProductPrice) {
|
|
||||||
throw new BillingException(
|
|
||||||
`Cannot find active price for product ${meteredProduct.id}`,
|
|
||||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.stripeSubscriptionItemService.createSubscriptionItem(
|
|
||||||
billingSubscription.stripeSubscriptionId,
|
|
||||||
meteredProductPrice.stripePriceId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export class StripeCheckoutService {
|
|||||||
),
|
),
|
||||||
trial_settings: {
|
trial_settings: {
|
||||||
end_behavior: {
|
end_behavior: {
|
||||||
missing_payment_method: 'pause',
|
missing_payment_method: 'create_invoice',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,16 +27,4 @@ export class StripeSubscriptionItemService {
|
|||||||
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
|
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
|
||||||
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
|
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSubscriptionItem(
|
|
||||||
stripeSubscriptionId: string,
|
|
||||||
stripePriceId: string,
|
|
||||||
quantity?: number | undefined,
|
|
||||||
) {
|
|
||||||
await this.stripe.subscriptionItems.create({
|
|
||||||
subscription: stripeSubscriptionId,
|
|
||||||
price: stripePriceId,
|
|
||||||
quantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,11 +76,4 @@ export class StripeSubscriptionService {
|
|||||||
items: stripeSubscriptionItemsToUpdate,
|
items: stripeSubscriptionItemsToUpdate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSubscription(
|
|
||||||
stripeSubscriptionId: string,
|
|
||||||
data: Stripe.SubscriptionUpdateParams,
|
|
||||||
) {
|
|
||||||
await this.stripe.subscriptions.update(stripeSubscriptionId, data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
/* @license Enterprise */
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
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 { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.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';
|
||||||
|
|
||||||
|
const TRIAL_PERIOD_ALERT_TITLE = 'TRIAL_PERIOD_ALERT'; // to set in Stripe config
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingWebhookAlertService {
|
||||||
|
protected readonly logger = new Logger(BillingWebhookAlertService.name);
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
|
@InjectRepository(BillingProduct, 'core')
|
||||||
|
private readonly billingProductRepository: Repository<BillingProduct>,
|
||||||
|
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||||
|
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) {
|
||||||
|
const { customer: stripeCustomerId, alert } = data.object;
|
||||||
|
|
||||||
|
const stripeMeterId = alert.usage_threshold?.meter as string | undefined;
|
||||||
|
|
||||||
|
if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
|
||||||
|
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||||
|
where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
|
||||||
|
relations: ['billingSubscriptionItems'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) return;
|
||||||
|
|
||||||
|
const product = await this.billingProductRepository.findOne({
|
||||||
|
where: {
|
||||||
|
billingPrices: { stripeMeterId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new BillingException(
|
||||||
|
`Product associated to meter ${stripeMeterId} not found`,
|
||||||
|
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.billingSubscriptionItemRepository.update(
|
||||||
|
{
|
||||||
|
billingSubscriptionId: subscription.id,
|
||||||
|
stripeProductId: product.stripeProductId,
|
||||||
|
},
|
||||||
|
{ hasReachedCurrentPeriodCap: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||||
|
|
||||||
|
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingWebhookInvoiceService {
|
||||||
|
protected readonly logger = new Logger(BillingWebhookInvoiceService.name);
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||||
|
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async processStripeEvent(data: Stripe.InvoiceFinalizedEvent.Data) {
|
||||||
|
const { billing_reason: billingReason, subscription } = data.object;
|
||||||
|
|
||||||
|
const stripeSubscriptionId = subscription as string | undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDefined(stripeSubscriptionId) &&
|
||||||
|
billingReason === SUBSCRIPTION_CYCLE_BILLING_REASON
|
||||||
|
) {
|
||||||
|
await this.billingSubscriptionItemRepository.update(
|
||||||
|
{ stripeSubscriptionId },
|
||||||
|
{ hasReachedCurrentPeriodCap: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,12 +13,10 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
|
|||||||
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 { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
|
||||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||||
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
|
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
|
||||||
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
||||||
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
|
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
|
||||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
|
||||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
@ -29,19 +27,6 @@ import {
|
|||||||
CleanWorkspaceDeletionWarningUserVarsJobData,
|
CleanWorkspaceDeletionWarningUserVarsJobData,
|
||||||
} from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job';
|
} from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job';
|
||||||
|
|
||||||
const BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS = {
|
|
||||||
[WorkspaceActivationStatus.ACTIVE]: [
|
|
||||||
SubscriptionStatus.Active,
|
|
||||||
SubscriptionStatus.Trialing,
|
|
||||||
SubscriptionStatus.PastDue,
|
|
||||||
],
|
|
||||||
[WorkspaceActivationStatus.SUSPENDED]: [
|
|
||||||
SubscriptionStatus.Canceled,
|
|
||||||
SubscriptionStatus.Unpaid,
|
|
||||||
SubscriptionStatus.Paused,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingWebhookSubscriptionService {
|
export class BillingWebhookSubscriptionService {
|
||||||
protected readonly logger = new Logger(
|
protected readonly logger = new Logger(
|
||||||
@ -61,7 +46,6 @@ export class BillingWebhookSubscriptionService {
|
|||||||
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||||
@InjectRepository(FeatureFlag, 'core')
|
@InjectRepository(FeatureFlag, 'core')
|
||||||
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processStripeEvent(
|
async processStripeEvent(
|
||||||
@ -114,13 +98,6 @@ export class BillingWebhookSubscriptionService {
|
|||||||
throw new Error('Billing subscription not found');
|
throw new Error('Billing subscription not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveWorkspaceCompatibleSubscription = billingSubscriptions.some(
|
|
||||||
(subscription) =>
|
|
||||||
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
|
||||||
WorkspaceActivationStatus.ACTIVE
|
|
||||||
].includes(subscription.status),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.billingSubscriptionItemRepository.upsert(
|
await this.billingSubscriptionItemRepository.upsert(
|
||||||
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
||||||
updatedBillingSubscription.id,
|
updatedBillingSubscription.id,
|
||||||
@ -132,30 +109,9 @@ export class BillingWebhookSubscriptionService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const wasTrialOrPausedSubscription = [
|
|
||||||
SubscriptionStatus.Trialing,
|
|
||||||
SubscriptionStatus.Paused,
|
|
||||||
].includes(data.previous_attributes?.status as SubscriptionStatus);
|
|
||||||
|
|
||||||
const isMeteredProductBillingEnabled =
|
|
||||||
await this.featureFlagRepository.findOneBy({
|
|
||||||
key: FeatureFlagKey.IsMeteredProductBillingEnabled,
|
|
||||||
workspaceId,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wasTrialOrPausedSubscription && isMeteredProductBillingEnabled) {
|
|
||||||
await this.billingSubscriptionService.convertTrialSubscriptionToSubscriptionWithMeteredProducts(
|
|
||||||
updatedBillingSubscription,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
this.shouldSuspendWorkspace(data) &&
|
||||||
WorkspaceActivationStatus.SUSPENDED
|
workspace.activationStatus == WorkspaceActivationStatus.ACTIVE
|
||||||
].includes(data.object.status as SubscriptionStatus) &&
|
|
||||||
workspace.activationStatus == WorkspaceActivationStatus.ACTIVE &&
|
|
||||||
!hasActiveWorkspaceCompatibleSubscription
|
|
||||||
) {
|
) {
|
||||||
await this.workspaceRepository.update(workspaceId, {
|
await this.workspaceRepository.update(workspaceId, {
|
||||||
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
||||||
@ -163,9 +119,7 @@ export class BillingWebhookSubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
!this.shouldSuspendWorkspace(data) &&
|
||||||
WorkspaceActivationStatus.ACTIVE
|
|
||||||
].includes(data.object.status as SubscriptionStatus) &&
|
|
||||||
workspace.activationStatus == WorkspaceActivationStatus.SUSPENDED
|
workspace.activationStatus == WorkspaceActivationStatus.SUSPENDED
|
||||||
) {
|
) {
|
||||||
await this.workspaceRepository.update(workspaceId, {
|
await this.workspaceRepository.update(workspaceId, {
|
||||||
@ -188,4 +142,28 @@ export class BillingWebhookSubscriptionService {
|
|||||||
stripeCustomerId: data.object.customer,
|
stripeCustomerId: data.object.customer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldSuspendWorkspace(
|
||||||
|
data:
|
||||||
|
| Stripe.CustomerSubscriptionUpdatedEvent.Data
|
||||||
|
| Stripe.CustomerSubscriptionCreatedEvent.Data
|
||||||
|
| Stripe.CustomerSubscriptionDeletedEvent.Data,
|
||||||
|
) {
|
||||||
|
const timeSinceTrialEnd = Date.now() / 1000 - (data.object.trial_end || 0);
|
||||||
|
const hasTrialJustEnded =
|
||||||
|
timeSinceTrialEnd < 60 * 60 * 24 && timeSinceTrialEnd > 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
SubscriptionStatus.Canceled,
|
||||||
|
SubscriptionStatus.Unpaid,
|
||||||
|
SubscriptionStatus.Paused, // TODO: remove this once paused subscriptions are deprecated
|
||||||
|
].includes(data.object.status as SubscriptionStatus) ||
|
||||||
|
(hasTrialJustEnded && data.object.status === SubscriptionStatus.PastDue)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user