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:
Etienne
2026-06-17 18:20:11 +02:00
committed by GitHub
parent 177afde866
commit d99e479be8
48 changed files with 1562 additions and 837 deletions
@@ -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:
@@ -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;
}
}
@@ -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',
]);