feat(billing) - facilitate top up in ai chat (#21645)
Today, when a trialing user hits their AI usage cap inside the Ask AI chat, ending the trial bounces them to the Stripe billing portal (and, for card-less users, loses their place in the conversation). This PR makes activating a paid plan / topping up credits feel seamless from within the chat: Trial users with a card on file activate their subscription in place, without leaving the app. Trial users without a card are sent to the Stripe payment-method portal and, on return, the trial is ended automatically and they're dropped back into the exact Ask AI thread they came from. Credit-exhaustion and trial banners now reflect whether a payment method exists (Add Credit Card vs Subscribe Now / End Trial Period) and upgrade inline via a confirmation modal instead of redirecting to Settings. Uploading Screen Recording 2026-06-16 at 07.51.12.mov… https://github.com/user-attachments/assets/4ea77273-da63-4b32-b6f1-5ac9e9560651 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21645?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+3
-1
@@ -123,8 +123,10 @@ export class BillingWebhookController {
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.CUSTOMER_CREATED:
|
||||
case BillingWebhookEvent.PAYMENT_METHOD_ATTACHED:
|
||||
case BillingWebhookEvent.PAYMENT_METHOD_DETACHED:
|
||||
return await this.billingWebhookCustomerService.processStripeEvent(
|
||||
event.data,
|
||||
event,
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
|
||||
|
||||
+109
-1
@@ -1,6 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -9,17 +12,44 @@ import {
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isString } from '@sniptt/guards';
|
||||
@Injectable()
|
||||
export class BillingWebhookCustomerService {
|
||||
protected readonly logger = new Logger(BillingWebhookCustomerService.name);
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository -- resolves workspaceId from a Stripe customerId before any workspace context exists
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepositoryUnscoped: Repository<BillingCustomerEntity>,
|
||||
private readonly stripeCustomerService: StripeCustomerService,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(data: Stripe.CustomerCreatedEvent.Data) {
|
||||
async processStripeEvent(
|
||||
event:
|
||||
| Stripe.CustomerCreatedEvent
|
||||
| Stripe.PaymentMethodAttachedEvent
|
||||
| Stripe.PaymentMethodDetachedEvent,
|
||||
) {
|
||||
if (event.type === BillingWebhookEvent.CUSTOMER_CREATED) {
|
||||
return this.processCustomerCreated(event.data);
|
||||
}
|
||||
|
||||
if (event.type === BillingWebhookEvent.PAYMENT_METHOD_ATTACHED) {
|
||||
return this.processPaymentMethodAttachedEvent(event.data);
|
||||
}
|
||||
|
||||
if (event.type === BillingWebhookEvent.PAYMENT_METHOD_DETACHED) {
|
||||
return this.processPaymentMethodDetachedEvent(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async processCustomerCreated(data: Stripe.CustomerCreatedEvent.Data) {
|
||||
const { id: stripeCustomerId, metadata } = data.object;
|
||||
|
||||
const workspaceId = metadata?.workspaceId;
|
||||
@@ -40,4 +70,82 @@ export class BillingWebhookCustomerService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async processPaymentMethodAttachedEvent(
|
||||
data: Stripe.PaymentMethodAttachedEvent.Data,
|
||||
) {
|
||||
const stripeCustomerId = this.extractStripeCustomerId(data.object.customer);
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
await this.getWorkspaceIdFromStripeCustomerId(stripeCustomerId);
|
||||
|
||||
if (!workspaceId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ hasPaymentMethod: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async processPaymentMethodDetachedEvent(
|
||||
data: Stripe.PaymentMethodDetachedEvent.Data,
|
||||
) {
|
||||
const stripeCustomerId = this.extractStripeCustomerId(
|
||||
data.previous_attributes?.customer,
|
||||
);
|
||||
|
||||
if (!isDefined(stripeCustomerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
await this.getWorkspaceIdFromStripeCustomerId(stripeCustomerId);
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPaymentMethod =
|
||||
await this.stripeCustomerService.hasPaymentMethod(stripeCustomerId);
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ hasPaymentMethod },
|
||||
);
|
||||
}
|
||||
|
||||
private async getWorkspaceIdFromStripeCustomerId(
|
||||
stripeCustomerId: string,
|
||||
): Promise<string | null> {
|
||||
const billingCustomer =
|
||||
await this.billingCustomerRepositoryUnscoped.findOne({
|
||||
where: { stripeCustomerId },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
return billingCustomer?.workspaceId ?? null;
|
||||
}
|
||||
|
||||
private extractStripeCustomerId(
|
||||
customer:
|
||||
| string
|
||||
| Stripe.Customer
|
||||
| Stripe.DeletedCustomer
|
||||
| null
|
||||
| undefined,
|
||||
): string | null {
|
||||
if (!customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isString(customer) ? customer : customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
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 { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -61,7 +61,7 @@ export class BillingWebhookSubscriptionService {
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly billingUsageCacheService: BillingUsageCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class BillingWebhookSubscriptionService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.billingUsageService.flushAvailableCreditsFromCache(workspace.id);
|
||||
await this.billingUsageCacheService.flushAvailableCredits(workspace.id);
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspace.id, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user