feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
This commit is contained in:
@@ -22,6 +22,8 @@ export class BillingGaugeService implements OnModuleInit {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
// Observability gauges count subscriptions across every workspace.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
@@ -92,6 +93,9 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
ResourceCreditService,
|
||||
BillingGaugeService,
|
||||
WorkspaceBillingSubscriptionCacheService,
|
||||
provideWorkspaceScopedRepository(BillingEntitlementEntity),
|
||||
provideWorkspaceScopedRepository(BillingCustomerEntity),
|
||||
provideWorkspaceScopedRepository(BillingSubscriptionEntity),
|
||||
],
|
||||
exports: [
|
||||
BillingSubscriptionService,
|
||||
|
||||
+11
-18
@@ -1,17 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.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';
|
||||
@Command({
|
||||
name: 'billing:sync-customer-data',
|
||||
description: 'Sync customer data from Stripe for all active workspaces',
|
||||
@@ -20,8 +18,8 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
protected readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
protected readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
@@ -30,11 +28,10 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne(
|
||||
workspaceId,
|
||||
{ where: {} },
|
||||
);
|
||||
|
||||
if (!options.dryRun && !billingCustomer) {
|
||||
const stripeCustomerId =
|
||||
@@ -44,13 +41,9 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo
|
||||
|
||||
if (typeof stripeCustomerId === 'string') {
|
||||
await this.billingCustomerRepository.upsert(
|
||||
{
|
||||
stripeCustomerId,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['workspaceId'],
|
||||
},
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ conflictPaths: ['workspaceId'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -1,15 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command, Option } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
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 { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
|
||||
|
||||
@@ -24,8 +20,6 @@ export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWork
|
||||
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
protected readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||
) {
|
||||
|
||||
+8
-4
@@ -1,12 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
describe('BillingCreditRolloverService', () => {
|
||||
let service: BillingCreditRolloverService;
|
||||
let billingUsageService: jest.Mocked<
|
||||
@@ -25,7 +24,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingCustomerEntity),
|
||||
provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity),
|
||||
useValue: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
@@ -38,7 +37,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
);
|
||||
billingUsageService = module.get(BillingUsageService);
|
||||
billingCustomerRepository = module.get(
|
||||
getRepositoryToken(BillingCustomerEntity),
|
||||
getWorkspaceScopedRepositoryToken(BillingCustomerEntity),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,6 +61,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
await service.processRolloverOnPeriodTransition(baseParams);
|
||||
|
||||
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
|
||||
'ws_123',
|
||||
{ stripeCustomerId: 'cus_123' },
|
||||
{ creditBalanceMicro: 700 },
|
||||
);
|
||||
@@ -75,6 +75,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
await service.processRolloverOnPeriodTransition(baseParams);
|
||||
|
||||
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
|
||||
'ws_123',
|
||||
{ stripeCustomerId: 'cus_123' },
|
||||
{ creditBalanceMicro: 1000 },
|
||||
);
|
||||
@@ -88,6 +89,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
await service.processRolloverOnPeriodTransition(baseParams);
|
||||
|
||||
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
|
||||
'ws_123',
|
||||
{ stripeCustomerId: 'cus_123' },
|
||||
{ creditBalanceMicro: 0 },
|
||||
);
|
||||
@@ -101,6 +103,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
await service.processRolloverOnPeriodTransition(baseParams);
|
||||
|
||||
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
|
||||
'ws_123',
|
||||
{ stripeCustomerId: 'cus_123' },
|
||||
{ creditBalanceMicro: 0 },
|
||||
);
|
||||
@@ -115,6 +118,7 @@ describe('BillingCreditRolloverService', () => {
|
||||
await service.processRolloverOnPeriodTransition(params);
|
||||
|
||||
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
|
||||
'ws_123',
|
||||
{ stripeCustomerId: 'cus_123' },
|
||||
{ creditBalanceMicro: 500 },
|
||||
);
|
||||
|
||||
+17
-16
@@ -20,7 +20,8 @@ import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/ser
|
||||
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';
|
||||
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import {
|
||||
arrangeBillingPriceRepositoryFindOneOrFail,
|
||||
arrangeBillingProductServiceGetProductPrices,
|
||||
@@ -48,7 +49,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
let module: TestingModule;
|
||||
let service: BillingSubscriptionUpdateService;
|
||||
let billingSubscriptionRepository: jest.Mocked<
|
||||
Repository<BillingSubscriptionEntity>
|
||||
WorkspaceScopedRepository<BillingSubscriptionEntity>
|
||||
>;
|
||||
let billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>;
|
||||
let billingProductService: jest.Mocked<BillingProductService>;
|
||||
@@ -136,7 +137,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscriptionEntity),
|
||||
provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
|
||||
useValue: repoMock<BillingSubscriptionEntity>(),
|
||||
},
|
||||
{
|
||||
@@ -161,7 +162,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
|
||||
service = module.get(BillingSubscriptionUpdateService);
|
||||
billingSubscriptionRepository = module.get(
|
||||
getRepositoryToken(BillingSubscriptionEntity),
|
||||
getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
|
||||
);
|
||||
billingPriceRepository = module.get(getRepositoryToken(BillingPriceEntity));
|
||||
billingProductService = module.get(BillingProductService);
|
||||
@@ -229,7 +230,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
}) as BillingPriceEntity,
|
||||
]);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan: BillingPlanKey.ENTERPRISE,
|
||||
});
|
||||
@@ -355,7 +356,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan: BillingPlanKey.ENTERPRISE,
|
||||
});
|
||||
@@ -464,7 +465,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan: BillingPlanKey.PRO,
|
||||
});
|
||||
@@ -577,7 +578,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan: BillingPlanKey.PRO,
|
||||
});
|
||||
@@ -654,7 +655,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
}) as BillingPriceEntity,
|
||||
]);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval: SubscriptionInterval.Year,
|
||||
});
|
||||
@@ -775,7 +776,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval: SubscriptionInterval.Year,
|
||||
});
|
||||
@@ -884,7 +885,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval: SubscriptionInterval.Month,
|
||||
});
|
||||
@@ -997,7 +998,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval: SubscriptionInterval.Month,
|
||||
});
|
||||
@@ -1058,7 +1059,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
{},
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.SEATS,
|
||||
newSeats: 2,
|
||||
});
|
||||
@@ -1162,7 +1163,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.SEATS,
|
||||
newSeats: 2,
|
||||
});
|
||||
@@ -1236,7 +1237,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
{},
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.SEATS,
|
||||
newSeats: 1,
|
||||
});
|
||||
@@ -1340,7 +1341,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
);
|
||||
|
||||
await service.updateSubscription('sub_db_1', {
|
||||
await service.updateSubscription('ws_1', 'sub_db_1', {
|
||||
type: SubscriptionUpdateType.SEATS,
|
||||
newSeats: 1,
|
||||
});
|
||||
|
||||
+15
-9
@@ -7,7 +7,7 @@ import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/bil
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
describe('ResourceCreditService', () => {
|
||||
let service: ResourceCreditService;
|
||||
let billingSubscriptionRepository: jest.Mocked<any>;
|
||||
@@ -39,7 +39,7 @@ describe('ResourceCreditService', () => {
|
||||
providers: [
|
||||
ResourceCreditService,
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscriptionEntity),
|
||||
provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
@@ -55,7 +55,7 @@ describe('ResourceCreditService', () => {
|
||||
|
||||
service = module.get<ResourceCreditService>(ResourceCreditService);
|
||||
billingSubscriptionRepository = module.get(
|
||||
getRepositoryToken(BillingSubscriptionEntity),
|
||||
getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -126,8 +126,10 @@ describe('ResourceCreditService', () => {
|
||||
|
||||
billingSubscriptionRepository.findOne.mockResolvedValue(subscription);
|
||||
|
||||
const result =
|
||||
await service.getResourceCreditRolloverParameters('sub_123');
|
||||
const result = await service.getResourceCreditRolloverParameters(
|
||||
'ws_1',
|
||||
'sub_123',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ tierQuantity: 5000, unitPriceCents: 5 });
|
||||
});
|
||||
@@ -135,8 +137,10 @@ describe('ResourceCreditService', () => {
|
||||
it('returns null when subscription not found', async () => {
|
||||
billingSubscriptionRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result =
|
||||
await service.getResourceCreditRolloverParameters('sub_123');
|
||||
const result = await service.getResourceCreditRolloverParameters(
|
||||
'ws_1',
|
||||
'sub_123',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -146,8 +150,10 @@ describe('ResourceCreditService', () => {
|
||||
billingSubscriptionItems: [],
|
||||
});
|
||||
|
||||
const result =
|
||||
await service.getResourceCreditRolloverParameters('sub_123');
|
||||
const result = await service.getResourceCreditRolloverParameters(
|
||||
'ws_1',
|
||||
'sub_123',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import { type BillingProductService } from 'src/engine/core-modules/billing/serv
|
||||
import { type BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
import { type StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
|
||||
|
||||
import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { buildSubscription } from './build-subscription.util';
|
||||
|
||||
export const repoMock = <T extends ObjectLiteral>() =>
|
||||
@@ -81,7 +81,7 @@ export const buildDefaultMeteredTiers = (
|
||||
|
||||
export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
|
||||
billingSubscriptionRepository: jest.Mocked<
|
||||
Repository<BillingSubscriptionEntity>
|
||||
WorkspaceScopedRepository<BillingSubscriptionEntity>
|
||||
>,
|
||||
params: {
|
||||
planKey?: BillingPlanKey;
|
||||
|
||||
+5
-6
@@ -1,19 +1,17 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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';
|
||||
@Injectable()
|
||||
export class BillingCreditRolloverService {
|
||||
constructor(
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {}
|
||||
|
||||
async processRolloverOnPeriodTransition({
|
||||
@@ -37,6 +35,7 @@ export class BillingCreditRolloverService {
|
||||
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ creditBalanceMicro: rolloverAmount },
|
||||
);
|
||||
|
||||
+19
-15
@@ -29,7 +29,8 @@ import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-mod
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
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';
|
||||
@Injectable()
|
||||
export class BillingPortalWorkspaceService {
|
||||
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
|
||||
@@ -38,10 +39,10 @@ export class BillingPortalWorkspaceService {
|
||||
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
@@ -156,10 +157,13 @@ export class BillingPortalWorkspaceService {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const customer = await this.billingCustomerRepository.findOne({
|
||||
where: { workspaceId: workspace.id },
|
||||
relations: ['billingSubscriptions'],
|
||||
});
|
||||
const customer = await this.billingCustomerRepository.findOne(
|
||||
workspace.id,
|
||||
{
|
||||
where: {},
|
||||
relations: ['billingSubscriptions'],
|
||||
},
|
||||
);
|
||||
|
||||
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
@@ -180,13 +184,13 @@ export class BillingPortalWorkspaceService {
|
||||
workspace: WorkspaceEntity,
|
||||
returnUrlPath?: string,
|
||||
) {
|
||||
const lastSubscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
status: Not(SubscriptionStatus.Canceled),
|
||||
const lastSubscription = await this.billingSubscriptionRepository.findOne(
|
||||
workspace.id,
|
||||
{
|
||||
where: { status: Not(SubscriptionStatus.Canceled) },
|
||||
order: { createdAt: 'DESC' },
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
);
|
||||
|
||||
if (!lastSubscription) {
|
||||
throw new Error('Error: missing subscription');
|
||||
|
||||
+21
-10
@@ -37,7 +37,8 @@ import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/cor
|
||||
import { getCurrentResourceCreditSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-resource-credit-subscription-item-or-throw.util';
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
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';
|
||||
export type SubscriptionStripePrices = {
|
||||
licensedPriceId: string;
|
||||
seats: number;
|
||||
@@ -57,8 +58,8 @@ export class BillingSubscriptionUpdateService {
|
||||
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
@@ -77,7 +78,11 @@ export class BillingSubscriptionUpdateService {
|
||||
newResourceCreditPriceId: resourceCreditPriceId,
|
||||
} as const;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
|
||||
await this.updateSubscription(
|
||||
workspaceId,
|
||||
billingSubscription.id,
|
||||
subscriptionUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSwitchResourceCreditPrice(
|
||||
@@ -95,7 +100,11 @@ export class BillingSubscriptionUpdateService {
|
||||
newResourceCreditPriceId: currentResourceCreditPrice.stripePriceId,
|
||||
} as const;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
|
||||
await this.updateSubscription(
|
||||
workspace.id,
|
||||
billingSubscription.id,
|
||||
subscriptionUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSwitchPlan(workspaceId: string) {
|
||||
@@ -108,7 +117,7 @@ export class BillingSubscriptionUpdateService {
|
||||
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription)
|
||||
.billingProduct?.metadata.planKey;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
await this.updateSubscription(workspaceId, billingSubscription.id, {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan: currentPlan,
|
||||
});
|
||||
@@ -122,7 +131,7 @@ export class BillingSubscriptionUpdateService {
|
||||
|
||||
const currentInterval = billingSubscription.interval;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
await this.updateSubscription(workspaceId, billingSubscription.id, {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval: currentInterval,
|
||||
});
|
||||
@@ -136,7 +145,7 @@ export class BillingSubscriptionUpdateService {
|
||||
|
||||
const currentInterval = billingSubscription.interval;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
await this.updateSubscription(workspaceId, billingSubscription.id, {
|
||||
type: SubscriptionUpdateType.INTERVAL,
|
||||
newInterval:
|
||||
currentInterval === SubscriptionInterval.Month
|
||||
@@ -155,7 +164,7 @@ export class BillingSubscriptionUpdateService {
|
||||
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription)
|
||||
.billingProduct?.metadata.planKey;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
await this.updateSubscription(workspaceId, billingSubscription.id, {
|
||||
type: SubscriptionUpdateType.PLAN,
|
||||
newPlan:
|
||||
currentPlan === BillingPlanKey.ENTERPRISE
|
||||
@@ -170,17 +179,19 @@ export class BillingSubscriptionUpdateService {
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, {
|
||||
await this.updateSubscription(workspaceId, billingSubscription.id, {
|
||||
type: SubscriptionUpdateType.SEATS,
|
||||
newSeats,
|
||||
});
|
||||
}
|
||||
|
||||
async updateSubscription(
|
||||
workspaceId: string,
|
||||
subscriptionId: string,
|
||||
subscriptionUpdate: SubscriptionUpdate,
|
||||
): Promise<void> {
|
||||
const subscription = await this.billingSubscriptionRepository.findOneOrFail(
|
||||
workspaceId,
|
||||
{
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
|
||||
+37
-27
@@ -35,7 +35,8 @@ import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/util
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
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';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
@Injectable()
|
||||
export class BillingSubscriptionService {
|
||||
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
||||
@@ -44,38 +45,49 @@ export class BillingSubscriptionService {
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
private readonly billingPriceService: BillingPriceService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
@InjectRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: Repository<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: WorkspaceScopedRepository<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
// Stripe webhooks resolve by stripeCustomerId before any workspaceId
|
||||
// is known. Used only when the criteria has no workspaceId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
private readonly billingSubscriptionRepositoryUnscoped: Repository<BillingSubscriptionEntity>,
|
||||
private readonly stripeCustomerService: StripeCustomerService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
async getBillingSubscriptions(workspaceId: string) {
|
||||
return await this.billingSubscriptionRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
return await this.billingSubscriptionRepository.find(workspaceId);
|
||||
}
|
||||
|
||||
async getCurrentBillingSubscription(criteria: {
|
||||
workspaceId?: string;
|
||||
stripeCustomerId?: string;
|
||||
}): Promise<BillingSubscriptionEntity | undefined> {
|
||||
const notCanceledSubscriptions =
|
||||
await this.billingSubscriptionRepository.find({
|
||||
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
],
|
||||
});
|
||||
const baseFindOptions = {
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
],
|
||||
};
|
||||
|
||||
const notCanceledSubscriptions = isDefined(criteria.workspaceId)
|
||||
? await this.billingSubscriptionRepository.find(criteria.workspaceId, {
|
||||
...baseFindOptions,
|
||||
where: { status: Not(SubscriptionStatus.Canceled) },
|
||||
})
|
||||
: await this.billingSubscriptionRepositoryUnscoped.find({
|
||||
...baseFindOptions,
|
||||
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
|
||||
});
|
||||
|
||||
if (notCanceledSubscriptions.length > 1) {
|
||||
throw new BillingException(
|
||||
@@ -190,9 +202,7 @@ export class BillingSubscriptionService {
|
||||
const hasValidEnterprisePlan = this.enterprisePlanService.isValid();
|
||||
|
||||
const entitlements = isBillingEnabled
|
||||
? await this.billingEntitlementRepository.find({
|
||||
where: { workspaceId },
|
||||
})
|
||||
? await this.billingEntitlementRepository.find(workspaceId)
|
||||
: [];
|
||||
|
||||
const entitlementsByKey = entitlements.reduce(
|
||||
@@ -216,11 +226,10 @@ export class BillingSubscriptionService {
|
||||
workspaceId: string,
|
||||
key: BillingEntitlementKey,
|
||||
): Promise<boolean> {
|
||||
const entitlement = await this.billingEntitlementRepository.findOneBy({
|
||||
const entitlement = await this.billingEntitlementRepository.findOne(
|
||||
workspaceId,
|
||||
key,
|
||||
value: true,
|
||||
});
|
||||
{ where: { key, value: true } },
|
||||
);
|
||||
|
||||
return entitlement?.value ?? false;
|
||||
}
|
||||
@@ -278,6 +287,7 @@ export class BillingSubscriptionService {
|
||||
);
|
||||
|
||||
await this.billingCustomerRepository.upsert(
|
||||
workspaceId,
|
||||
transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, {
|
||||
object: subscription,
|
||||
}),
|
||||
@@ -288,6 +298,7 @@ export class BillingSubscriptionService {
|
||||
);
|
||||
|
||||
await this.billingSubscriptionRepository.upsert(
|
||||
workspaceId,
|
||||
transformStripeSubscriptionEventToDatabaseSubscription(
|
||||
workspaceId,
|
||||
subscription,
|
||||
@@ -298,9 +309,8 @@ export class BillingSubscriptionService {
|
||||
},
|
||||
);
|
||||
|
||||
const billingSubscriptions = await this.billingSubscriptionRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
const billingSubscriptions =
|
||||
await this.billingSubscriptionRepository.find(workspaceId);
|
||||
|
||||
const currentBillingSubscription = billingSubscriptions.find(
|
||||
(sub) => sub.stripeSubscriptionId === subscription.id,
|
||||
|
||||
+23
-19
@@ -1,10 +1,8 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
@@ -27,6 +25,8 @@ import { CacheStorageService } from 'src/engine/core-modules/cache-storage/servi
|
||||
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';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
type UsageSumRow = {
|
||||
@@ -37,15 +37,15 @@ type UsageSumRow = {
|
||||
export class BillingUsageService {
|
||||
protected readonly logger = new Logger(BillingUsageService.name);
|
||||
constructor(
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
|
||||
private readonly billingUsageCacheStorage: CacheStorageService,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageCapService: BillingUsageCapService,
|
||||
@@ -123,9 +123,10 @@ export class BillingUsageService {
|
||||
? item.freeTrialQuantity
|
||||
: item.creditAmount;
|
||||
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: { workspaceId },
|
||||
});
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne(
|
||||
workspaceId,
|
||||
{ where: {} },
|
||||
);
|
||||
const rolloverCredits = billingCustomer?.creditBalanceMicro ?? 0;
|
||||
|
||||
return {
|
||||
@@ -199,14 +200,17 @@ export class BillingUsageService {
|
||||
workspaceId: string;
|
||||
currentPeriodStart: Date | string;
|
||||
}): Promise<number> {
|
||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: { workspaceId, currentPeriodStart: new Date(currentPeriodStart) },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
});
|
||||
const subscription = await this.billingSubscriptionRepository.findOne(
|
||||
workspaceId,
|
||||
{
|
||||
where: { currentPeriodStart: new Date(currentPeriodStart) },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
throw new BillingException(
|
||||
@@ -218,9 +222,9 @@ export class BillingUsageService {
|
||||
const resourceUsageCap = this.getResourceUsageCap(subscription);
|
||||
|
||||
const { creditBalanceMicro: creditBalance } =
|
||||
await this.billingCustomerRepository.findOneOrFail({
|
||||
await this.billingCustomerRepository.findOneOrFail(workspaceId, {
|
||||
select: { creditBalanceMicro: true },
|
||||
where: { workspaceId },
|
||||
where: {},
|
||||
});
|
||||
|
||||
const usage = await this.getCurrentPeriodCreditsUsed(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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';
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
protected readonly logger = new Logger(BillingService.name);
|
||||
@@ -19,8 +18,8 @@ export class BillingService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingProductService: BillingProductService,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
isBillingEnabled() {
|
||||
@@ -34,9 +33,10 @@ export class BillingService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: { workspaceId },
|
||||
});
|
||||
const subscription = await this.billingSubscriptionRepository.findOne(
|
||||
workspaceId,
|
||||
{ where: {} },
|
||||
);
|
||||
|
||||
return isDefined(subscription);
|
||||
}
|
||||
|
||||
+19
-14
@@ -1,14 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
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';
|
||||
export type ResourceCreditPricingInfo = {
|
||||
tierCap: number;
|
||||
unitPriceCents: number;
|
||||
@@ -19,8 +18,8 @@ export class ResourceCreditService {
|
||||
protected readonly logger = new Logger(ResourceCreditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
extractResourceCreditPricingInfo(
|
||||
@@ -57,18 +56,24 @@ export class ResourceCreditService {
|
||||
};
|
||||
}
|
||||
|
||||
async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{
|
||||
async getResourceCreditRolloverParameters(
|
||||
workspaceId: string,
|
||||
subscriptionId: string,
|
||||
): Promise<{
|
||||
tierQuantity: number;
|
||||
unitPriceCents: number;
|
||||
} | null> {
|
||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
});
|
||||
const subscription = await this.billingSubscriptionRepository.findOne(
|
||||
workspaceId,
|
||||
{
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
return null;
|
||||
|
||||
+5
-8
@@ -1,16 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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';
|
||||
@Injectable()
|
||||
export class StripeCustomerService {
|
||||
protected readonly logger = new Logger(StripeCustomerService.name);
|
||||
@@ -19,8 +17,8 @@ export class StripeCustomerService {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSDKService: StripeSDKService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
@@ -59,9 +57,8 @@ export class StripeCustomerService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.billingCustomerRepository.save({
|
||||
await this.billingCustomerRepository.save(workspaceId, {
|
||||
stripeCustomerId: customer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return customer;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/ser
|
||||
import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
@Module({
|
||||
imports: [
|
||||
DomainServerConfigModule,
|
||||
@@ -40,6 +40,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
|
||||
StripeBillingMeterEventService,
|
||||
StripeCreditGrantService,
|
||||
StripeInvoiceService,
|
||||
provideWorkspaceScopedRepository(BillingCustomerEntity),
|
||||
],
|
||||
exports: [
|
||||
StripeWebhookService,
|
||||
|
||||
Reference in New Issue
Block a user