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',
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+30
-21
@@ -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,
|
||||
) {
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+67
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-58
@@ -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,
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export class StripeCustomerService {
|
||||
|
||||
await this.billingCustomerRepository.save(workspaceId, {
|
||||
stripeCustomerId: customer.id,
|
||||
hasPaymentMethod: false,
|
||||
});
|
||||
|
||||
return customer;
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const buildBillingUsageAvailableCreditsCachePattern = (
|
||||
workspaceId: string,
|
||||
): string => {
|
||||
return `available-credits:${workspaceId}:*`;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/applica
|
||||
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
|
||||
@@ -282,6 +283,17 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => BillingCustomerEntity, { nullable: true })
|
||||
async billingCustomer(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<BillingCustomerEntity | null> {
|
||||
if (!this.twentyConfigService.isBillingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.billingSubscriptionService.getBillingCustomer(workspace.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Number)
|
||||
async workspaceMembersCount(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
|
||||
+6
@@ -1,5 +1,7 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { BillingException } from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
|
||||
import {
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
@@ -13,6 +15,10 @@ import {
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
|
||||
export const aiGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof BillingException) {
|
||||
return billingGraphqlApiExceptionHandler(error);
|
||||
}
|
||||
|
||||
if (error instanceof AiException) {
|
||||
switch (error.code) {
|
||||
case AiExceptionCode.AGENT_NOT_FOUND:
|
||||
|
||||
Reference in New Issue
Block a user