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
@@ -29,6 +29,7 @@ import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
@@ -89,6 +90,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingUpdateSubscriptionPriceCommand,
BillingSyncPlansDataCommand,
BillingUsageService,
BillingUsageCacheService,
BillingUsageCapService,
BillingPriceService,
BillingCreditRolloverService,
@@ -107,6 +109,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingPortalWorkspaceService,
BillingService,
BillingUsageService,
BillingUsageCacheService,
BillingUsageCapService,
BillingCreditRolloverService,
ResourceCreditService,
@@ -71,12 +71,13 @@ export class BillingResolver {
)
async billingPortalSession(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { returnUrlPath }: BillingSessionInput,
@Args() { returnUrlPath, forPaymentMethodUpdate }: BillingSessionInput,
) {
return {
url: await this.billingPortalWorkspaceService.computeBillingPortalSessionURLOrThrow(
workspace,
returnUrlPath,
forPaymentMethodUpdate,
),
};
}
@@ -2,7 +2,7 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
@ArgsType()
export class BillingSessionInput {
@@ -10,4 +10,9 @@ export class BillingSessionInput {
@IsString()
@IsOptional()
returnUrlPath?: string;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
forPaymentMethodUpdate?: boolean;
}
@@ -1,6 +1,6 @@
/* @license Enterprise */
import { ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
@@ -41,6 +41,11 @@ export class BillingCustomerEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, unique: true })
stripeCustomerId: string;
// Null means unknown (customer created before the flag existed and not synced yet).
@Field(() => Boolean, { nullable: true })
@Column({ nullable: true, type: 'boolean' })
hasPaymentMethod: boolean | null;
@Column({
type: 'bigint',
nullable: false,
@@ -5,6 +5,8 @@ export enum BillingWebhookEvent {
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
CUSTOMER_CREATED = 'customer.created',
PAYMENT_METHOD_ATTACHED = 'payment_method.attached',
PAYMENT_METHOD_DETACHED = 'payment_method.detached',
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
PRODUCT_CREATED = 'product.created',
@@ -183,6 +183,7 @@ export class BillingPortalWorkspaceService {
async computeBillingPortalSessionURLOrThrow(
workspace: WorkspaceEntity,
returnUrlPath?: string,
forPaymentMethodUpdate?: boolean,
) {
const lastSubscription = await this.billingSubscriptionRepository.findOne(
workspace.id,
@@ -202,20 +203,17 @@ export class BillingPortalWorkspaceService {
throw new Error('Error: missing stripeCustomerId');
}
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
});
const returnUrl = this.buildReturnUrl(workspace, returnUrlPath);
if (returnUrlPath) {
frontBaseUrl.pathname = returnUrlPath;
}
const returnUrl = frontBaseUrl.toString();
const session =
await this.stripeBillingPortalService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
const session = forPaymentMethodUpdate
? await this.stripeBillingPortalService.createBillingPortalSessionForPaymentMethodUpdate(
stripeCustomerId,
returnUrl,
)
: await this.stripeBillingPortalService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
assertIsDefinedOrThrow(
session.url,
@@ -233,14 +231,7 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: string,
returnUrlPath?: string,
) {
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
});
if (returnUrlPath) {
frontBaseUrl.pathname = returnUrlPath;
}
const returnUrl = frontBaseUrl.toString();
const returnUrl = this.buildReturnUrl(workspace, returnUrlPath);
const session =
await this.stripeBillingPortalService.createBillingPortalSessionForPaymentMethodUpdate(
@@ -259,6 +250,24 @@ export class BillingPortalWorkspaceService {
return session.url;
}
private buildReturnUrl(workspace: WorkspaceEntity, returnUrlPath?: string) {
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
});
if (!isDefined(returnUrlPath)) {
return frontBaseUrl.toString();
}
const resolvedUrl = new URL(returnUrlPath, frontBaseUrl);
if (resolvedUrl.origin !== frontBaseUrl.origin) {
return frontBaseUrl.toString();
}
return resolvedUrl.toString();
}
private getDefaultResourceCreditPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
) {
@@ -28,6 +28,7 @@ import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/bil
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.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 { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@@ -37,6 +38,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
@@ -62,12 +64,20 @@ export class BillingSubscriptionService {
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
private readonly enterprisePlanService: EnterprisePlanService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly billingUsageCacheService: BillingUsageCacheService,
) {}
async getBillingSubscriptions(workspaceId: string) {
return await this.billingSubscriptionRepository.find(workspaceId);
}
async getBillingCustomer(
workspaceId: string,
): Promise<BillingCustomerEntity | null> {
return await this.billingCustomerRepository.findOneBy(workspaceId, {});
}
async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
@@ -267,11 +277,22 @@ export class BillingSubscriptionService {
},
);
await this.syncSubscriptionToDatabase(
billingSubscription.workspaceId,
updatedSubscription.id,
);
await this.billingSubscriptionItemRepository.update(
{ stripeSubscriptionId: updatedSubscription.id },
{ hasReachedCurrentPeriodCap: false },
);
await this.billingUsageCacheService.flushAvailableCredits(workspace.id);
await this.workspaceCacheService.invalidateAndRecompute(workspace.id, [
'currentBillingSubscription',
]);
return {
status: getSubscriptionStatus(updatedSubscription.status),
hasPaymentMethod: true,
@@ -0,0 +1,67 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-key.util';
import { buildBillingUsageAvailableCreditsCachePattern } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-pattern.util';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
@Injectable()
export class BillingUsageCacheService {
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
private readonly billingUsageCacheStorage: CacheStorageService,
) {}
async getAvailableCredits(
workspaceId: string,
periodStart: Date | string,
): Promise<number | undefined> {
return this.billingUsageCacheStorage.get<number>(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
);
}
async warmAvailableCredits(
workspaceId: string,
periodStart: Date | string,
periodEnd: Date | string,
availableCredits: number,
): Promise<void> {
const ttlMs = Math.max(new Date(periodEnd).getTime() - Date.now(), 0);
await this.billingUsageCacheStorage.set(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
availableCredits,
ttlMs,
);
}
async decrementAvailableCredits(
workspaceId: string,
periodStart: Date | string,
usedCredits: number,
): Promise<number> {
return this.billingUsageCacheStorage.incrBy(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
-usedCredits,
);
}
async invalidateAvailableCredits(
workspaceId: string,
periodStart: Date | string,
): Promise<void> {
await this.billingUsageCacheStorage.del(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
);
}
async flushAvailableCredits(workspaceId: string): Promise<void> {
await this.billingUsageCacheStorage.flushByPattern(
buildBillingUsageAvailableCreditsCachePattern(workspaceId),
);
}
}
@@ -21,11 +21,8 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-key.util';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
@@ -45,8 +42,7 @@ export class BillingUsageService {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
private readonly billingUsageCacheStorage: CacheStorageService,
private readonly billingUsageCacheService: BillingUsageCacheService,
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
@@ -167,36 +163,6 @@ export class BillingUsageService {
};
}
async flushAvailableCreditsFromCache(workspaceId: string): Promise<void> {
await this.billingUsageCacheStorage.flushByPattern(
`available-credits:${workspaceId}:*`,
);
}
private async warmAvailableCreditsInCache(
workspaceId: string,
periodStart: Date | string,
periodEnd: Date | string,
availableCredits: number,
): Promise<void> {
const ttlMs = Math.max(new Date(periodEnd).getTime() - Date.now(), 0);
await this.billingUsageCacheStorage.set(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
availableCredits,
ttlMs,
);
}
private async getAvailableCreditsFromCache(
workspaceId: string,
periodStart: Date | string,
): Promise<number | undefined> {
return this.billingUsageCacheStorage.get<number>(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
);
}
private async getAvailableCreditsFromClickHouse({
workspaceId,
currentPeriodStart,
@@ -300,10 +266,11 @@ export class BillingUsageService {
const { currentPeriodStart, currentPeriodEnd } = currentBillingSubscription;
const cachedAvailableCredits = await this.getAvailableCreditsFromCache(
workspaceId,
currentPeriodStart,
);
const cachedAvailableCredits =
await this.billingUsageCacheService.getAvailableCredits(
workspaceId,
currentPeriodStart,
);
const availableCredits = isDefined(cachedAvailableCredits)
? cachedAvailableCredits
@@ -313,7 +280,7 @@ export class BillingUsageService {
});
if (!isDefined(cachedAvailableCredits)) {
await this.warmAvailableCreditsInCache(
await this.billingUsageCacheService.warmAvailableCredits(
workspaceId,
currentPeriodStart,
currentPeriodEnd,
@@ -322,12 +289,10 @@ export class BillingUsageService {
}
const decrementedAvailableCredits =
await this.billingUsageCacheStorage.incrBy(
buildBillingUsageAvailableCreditsCacheKey(
workspaceId,
currentPeriodStart,
),
-usedCredits,
await this.billingUsageCacheService.decrementAvailableCredits(
workspaceId,
currentPeriodStart,
usedCredits,
);
const hasJustReachedCap =
@@ -343,15 +308,6 @@ export class BillingUsageService {
return decrementedAvailableCredits;
}
async invalidateAvailableCreditsInCache(
workspaceId: string,
periodStart: Date,
): Promise<void> {
await this.billingUsageCacheStorage.del(
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
);
}
async hasAvailableCredits(workspaceId: string): Promise<boolean> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return true;
@@ -380,7 +336,7 @@ export class BillingUsageService {
const subscription = currentBillingSubscription;
const cached = await this.getAvailableCreditsFromCache(
const cached = await this.billingUsageCacheService.getAvailableCredits(
subscription.workspaceId,
subscription.currentPeriodStart,
);
@@ -394,7 +350,7 @@ export class BillingUsageService {
currentPeriodStart: subscription.currentPeriodStart,
});
await this.warmAvailableCreditsInCache(
await this.billingUsageCacheService.warmAvailableCredits(
subscription.workspaceId,
subscription.currentPeriodStart,
subscription.currentPeriodEnd,
@@ -59,6 +59,7 @@ export class StripeCustomerService {
await this.billingCustomerRepository.save(workspaceId, {
stripeCustomerId: customer.id,
hasPaymentMethod: false,
});
return customer;
@@ -0,0 +1,5 @@
export const buildBillingUsageAvailableCreditsCachePattern = (
workspaceId: string,
): string => {
return `available-credits:${workspaceId}:*`;
};