add info to customer table and stripe customer data (#9004)

Solves (https://github.com/twentyhq/private-issues/issues/194)

**TLDR**

Updates the billingCustomer table data using stripe webhooks event, also
updates the customer's metadata in stripe, in order to contain the
workspaceId associated to this customer.

**In order to test**
Billing:

- Set IS_BILLING_ENABLED to true
- Add your BILLING_STRIPE_SECRET and BILLING_STRIPE_API_KEY
- Add your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID (use the one in testMode
> Base Plan)
- 

Authenticate with your account in the stripe CLI
Run the command: stripe listen --forward-to
http://localhost:3000/billing/webhooks

Run the twenty workker

Authenticate yourself on the app choose a plan and run the app normally.
In stripe and in posgress the customer table data should be added.

**Next steps**
Learn more about integrations tests and implement some for this PR.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Ana Sofia Marin Alexandre 2024-12-12 04:00:39 -03:00 committed by GitHub
parent c776179ecc
commit bce5be85a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 344 additions and 201 deletions

View File

@ -16,7 +16,8 @@ import {
} from 'src/engine/core-modules/billing/billing.exception'; } from 'src/engine/core-modules/billing/billing.exception';
import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
@Controller('billing') @Controller('billing')
export class BillingController { export class BillingController {
@ -24,7 +25,8 @@ export class BillingController {
constructor( constructor(
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
private readonly billingWehbookService: BillingWebhookService, private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingSubscriptionService: BillingSubscriptionService,
) {} ) {}
@ -61,7 +63,7 @@ export class BillingController {
return; return;
} }
await this.billingWehbookService.processStripeEvent( await this.billingWebhookSubscriptionService.processStripeEvent(
workspaceId, workspaceId,
event.data, event.data,
); );
@ -70,7 +72,7 @@ export class BillingController {
event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED
) { ) {
try { try {
await this.billingWehbookService.processCustomerActiveEntitlement( await this.billingWebhookEntitlementService.processStripeEvent(
event.data, event.data,
); );
} catch (error) { } catch (error) {
@ -82,6 +84,7 @@ export class BillingController {
} }
} }
} }
res.status(200).end(); res.status(200).end();
} }
} }

View File

@ -13,14 +13,15 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({ @Module({
imports: [ imports: [
@ -46,7 +47,8 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
controllers: [BillingController], controllers: [BillingController],
providers: [ providers: [
BillingSubscriptionService, BillingSubscriptionService,
BillingWebhookService, BillingWebhookSubscriptionService,
BillingWebhookEntitlementService,
BillingPortalWorkspaceService, BillingPortalWorkspaceService,
BillingResolver, BillingResolver,
BillingWorkspaceMemberListener, BillingWorkspaceMemberListener,
@ -55,7 +57,6 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
exports: [ exports: [
BillingSubscriptionService, BillingSubscriptionService,
BillingPortalWorkspaceService, BillingPortalWorkspaceService,
BillingWebhookService,
BillingService, BillingService,
], ],
}) })

View File

@ -4,4 +4,7 @@ export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated', CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
CUSTOMER_CREATED = 'customer.created',
CUSTOMER_DELETED = 'customer.deleted',
CUSTOMER_UPDATED = 'customer.updated',
} }

View File

@ -7,14 +7,14 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type UpdateSubscriptionJobData = { workspaceId: string }; export type UpdateSubscriptionQuantityJobData = { workspaceId: string };
@Processor({ @Processor({
queueName: MessageQueue.billingQueue, queueName: MessageQueue.billingQueue,
scope: Scope.REQUEST, scope: Scope.REQUEST,
}) })
export class UpdateSubscriptionJob { export class UpdateSubscriptionQuantityJob {
protected readonly logger = new Logger(UpdateSubscriptionJob.name); protected readonly logger = new Logger(UpdateSubscriptionQuantityJob.name);
constructor( constructor(
private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingSubscriptionService: BillingSubscriptionService,
@ -22,8 +22,8 @@ export class UpdateSubscriptionJob {
private readonly twentyORMManager: TwentyORMManager, private readonly twentyORMManager: TwentyORMManager,
) {} ) {}
@Process(UpdateSubscriptionJob.name) @Process(UpdateSubscriptionQuantityJob.name)
async handle(data: UpdateSubscriptionJobData): Promise<void> { async handle(data: UpdateSubscriptionQuantityJobData): Promise<void> {
const workspaceMemberRepository = const workspaceMemberRepository =
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>( await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>(
'workspaceMember', 'workspaceMember',

View File

@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { import {
UpdateSubscriptionJob, UpdateSubscriptionQuantityJob,
UpdateSubscriptionJobData, UpdateSubscriptionQuantityJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription.job'; } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@ -11,8 +13,6 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable() @Injectable()
export class BillingWorkspaceMemberListener { export class BillingWorkspaceMemberListener {
@ -33,8 +33,8 @@ export class BillingWorkspaceMemberListener {
return; return;
} }
await this.messageQueueService.add<UpdateSubscriptionJobData>( await this.messageQueueService.add<UpdateSubscriptionQuantityJobData>(
UpdateSubscriptionJob.name, UpdateSubscriptionQuantityJob.name,
{ workspaceId: payload.workspaceId }, { workspaceId: payload.workspaceId },
); );
} }

View File

@ -6,12 +6,11 @@ import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable() @Injectable()
export class BillingPortalWorkspaceService { export class BillingPortalWorkspaceService {
@ -19,7 +18,6 @@ export class BillingPortalWorkspaceService {
constructor( constructor(
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(UserWorkspace, 'core') @InjectRepository(UserWorkspace, 'core')

View File

@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util';
@Injectable()
export class BillingWebhookEntitlementService {
protected readonly logger = new Logger(BillingWebhookEntitlementService.name);
constructor(
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
) {}
async processStripeEvent(
data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOne({
where: { stripeCustomerId: data.object.customer },
});
if (!billingSubscription) {
throw new BillingException(
'Billing customer not found',
BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND,
);
}
const workspaceId = billingSubscription.workspaceId;
await this.billingEntitlementRepository.upsert(
transformStripeEntitlementUpdatedEventToEntitlementRepositoryData(
workspaceId,
data,
),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
}
}

View File

@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util';
import { transformStripeSubscriptionEventToSubscriptionItemRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util';
import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingWebhookSubscriptionService {
protected readonly logger = new Logger(
BillingWebhookSubscriptionService.name,
);
constructor(
private readonly stripeService: StripeService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>,
) {}
async processStripeEvent(
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
return;
}
await this.billingCustomerRepository.upsert(
transformStripeSubscriptionEventToCustomerRepositoryData(
workspaceId,
data,
),
{
conflictPaths: ['workspaceId', 'stripeCustomerId'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.billingSubscriptionRepository.upsert(
transformStripeSubscriptionEventToSubscriptionRepositoryData(
workspaceId,
data,
),
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId: data.object.id },
});
await this.billingSubscriptionItemRepository.upsert(
transformStripeSubscriptionEventToSubscriptionItemRepositoryData(
billingSubscription.id,
data,
),
{
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
);
if (
data.object.status === SubscriptionStatus.Canceled ||
data.object.status === SubscriptionStatus.Unpaid
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.INACTIVE,
});
}
if (
(data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue) &&
workspace.activationStatus == WorkspaceActivationStatus.INACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
}
await this.stripeService.updateCustomerMetadataWorkspaceId(
String(data.object.customer),
workspaceId,
);
}
}

View File

@ -1,171 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingWebhookService {
protected readonly logger = new Logger(BillingWebhookService.name);
constructor(
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
async processStripeEvent(
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
return;
}
await this.billingSubscriptionRepository.upsert(
{
workspaceId,
stripeCustomerId: data.object.customer as string,
stripeSubscriptionId: data.object.id,
status: data.object.status as SubscriptionStatus,
interval: data.object.items.data[0].plan.interval,
cancelAtPeriodEnd: data.object.cancel_at_period_end,
currency: data.object.currency.toUpperCase(),
currentPeriodEnd: new Date(data.object.current_period_end * 1000),
currentPeriodStart: new Date(data.object.current_period_start * 1000),
metadata: data.object.metadata,
collectionMethod:
data.object.collection_method.toUpperCase() as BillingSubscriptionCollectionMethod,
automaticTax: data.object.automatic_tax ?? undefined,
cancellationDetails: data.object.cancellation_details ?? undefined,
endedAt: data.object.ended_at
? new Date(data.object.ended_at * 1000)
: undefined,
trialStart: data.object.trial_start
? new Date(data.object.trial_start * 1000)
: undefined,
trialEnd: data.object.trial_end
? new Date(data.object.trial_end * 1000)
: undefined,
cancelAt: data.object.cancel_at
? new Date(data.object.cancel_at * 1000)
: undefined,
canceledAt: data.object.canceled_at
? new Date(data.object.canceled_at * 1000)
: undefined,
},
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId: data.object.id },
});
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
return {
billingSubscriptionId: billingSubscription.id,
stripeSubscriptionId: data.object.id,
stripeProductId: item.price.product as string,
stripePriceId: item.price.id,
stripeSubscriptionItemId: item.id,
quantity: item.quantity,
metadata: item.metadata,
billingThresholds: item.billing_thresholds ?? undefined,
};
}),
{
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
);
if (
data.object.status === SubscriptionStatus.Canceled ||
data.object.status === SubscriptionStatus.Unpaid
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.INACTIVE,
});
}
if (
(data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue) &&
workspace.activationStatus == WorkspaceActivationStatus.INACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
}
}
async processCustomerActiveEntitlement(
data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOne({
where: { stripeCustomerId: data.object.customer },
});
if (!billingSubscription) {
throw new BillingException(
'Billing customer not found',
BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND,
);
}
const workspaceId = billingSubscription.workspaceId;
const stripeCustomerId = data.object.customer;
const activeEntitlementsKeys = data.object.entitlements.data.map(
(entitlement) => entitlement.lookup_key,
);
await this.billingEntitlementRepository.upsert(
Object.values(BillingEntitlementKey).map((key) => {
return {
workspaceId,
key,
value: activeEntitlementsKeys.includes(key),
stripeCustomerId,
};
}),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
},
);
}
}

View File

@ -5,9 +5,9 @@ import Stripe from 'stripe';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable() @Injectable()
export class StripeService { export class StripeService {
@ -114,7 +114,10 @@ export class StripeService {
success_url: successUrl, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
}); });
} } // I prefered to not create a customer with metadat before the checkout, because it would break the tax calculation
// Indeed when the checkout session is created, the customer is created and the tax calculation is done
// If we create a customer before the checkout session, the tax calculation is not done and the checkout session will fail
// I think that it's not risk worth to create a customer before the checkout session, it would only complicate the code for no signigicant gain
async collectLastInvoice(stripeSubscriptionId: string) { async collectLastInvoice(stripeSubscriptionId: string) {
const subscription = await this.stripe.subscriptions.retrieve( const subscription = await this.stripe.subscriptions.retrieve(
@ -148,6 +151,19 @@ export class StripeService {
); );
} }
async updateCustomerMetadataWorkspaceId(
stripeCustomerId: string,
workspaceId: string,
) {
await this.stripe.customers.update(stripeCustomerId, {
metadata: { workspaceId: workspaceId },
});
}
async getCustomer(stripeCustomerId: string) {
return await this.stripe.customers.retrieve(stripeCustomerId);
}
formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] { formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] {
const productPrices: ProductPriceEntity[] = Object.values( const productPrices: ProductPriceEntity[] = Object.values(
prices prices

View File

@ -0,0 +1,23 @@
import Stripe from 'stripe';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
export const transformStripeEntitlementUpdatedEventToEntitlementRepositoryData =
(
workspaceId: string,
data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data,
) => {
const stripeCustomerId = data.object.customer;
const activeEntitlementsKeys = data.object.entitlements.data.map(
(entitlement) => entitlement.lookup_key,
);
return Object.values(BillingEntitlementKey).map((key) => {
return {
workspaceId,
key,
value: activeEntitlementsKeys.includes(key),
stripeCustomerId,
};
});
};

View File

@ -0,0 +1,14 @@
import Stripe from 'stripe';
export const transformStripeSubscriptionEventToCustomerRepositoryData = (
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) => {
return {
workspaceId,
stripeCustomerId: String(data.object.customer),
};
};

View File

@ -0,0 +1,23 @@
import Stripe from 'stripe';
export const transformStripeSubscriptionEventToSubscriptionItemRepositoryData =
(
billingSubscriptionId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) => {
return data.object.items.data.map((item) => {
return {
billingSubscriptionId,
stripeSubscriptionId: data.object.id,
stripeProductId: String(item.price.product),
stripePriceId: item.price.id,
stripeSubscriptionItemId: item.id,
quantity: item.quantity,
metadata: item.metadata,
billingThresholds: item.billing_thresholds ?? undefined,
};
});
};

View File

@ -0,0 +1,67 @@
import Stripe from 'stripe';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
export const transformStripeSubscriptionEventToSubscriptionRepositoryData = (
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) => {
return {
workspaceId,
stripeCustomerId: String(data.object.customer),
stripeSubscriptionId: data.object.id,
status: getSubscriptionStatus(data.object.status),
interval: data.object.items.data[0].plan.interval,
cancelAtPeriodEnd: data.object.cancel_at_period_end,
currency: data.object.currency.toUpperCase(),
currentPeriodEnd: new Date(data.object.current_period_end * 1000),
currentPeriodStart: new Date(data.object.current_period_start * 1000),
metadata: data.object.metadata,
collectionMethod:
BillingSubscriptionCollectionMethod[
data.object.collection_method.toUpperCase()
],
automaticTax: data.object.automatic_tax ?? undefined,
cancellationDetails: data.object.cancellation_details ?? undefined,
endedAt: data.object.ended_at
? new Date(data.object.ended_at * 1000)
: undefined,
trialStart: data.object.trial_start
? new Date(data.object.trial_start * 1000)
: undefined,
trialEnd: data.object.trial_end
? new Date(data.object.trial_end * 1000)
: undefined,
cancelAt: data.object.cancel_at
? new Date(data.object.cancel_at * 1000)
: undefined,
canceledAt: data.object.canceled_at
? new Date(data.object.canceled_at * 1000)
: undefined,
};
};
const getSubscriptionStatus = (status: Stripe.Subscription.Status) => {
switch (status) {
case 'active':
return SubscriptionStatus.Active;
case 'canceled':
return SubscriptionStatus.Canceled;
case 'incomplete':
return SubscriptionStatus.Incomplete;
case 'incomplete_expired':
return SubscriptionStatus.IncompleteExpired;
case 'past_due':
return SubscriptionStatus.PastDue;
case 'paused':
return SubscriptionStatus.Paused;
case 'trialing':
return SubscriptionStatus.Trialing;
case 'unpaid':
return SubscriptionStatus.Unpaid;
}
};

View File

@ -8,15 +8,15 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module'; import { WorkspaceQueryRunnerJobModule } from 'src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job'; import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserModule } from 'src/engine/core-modules/user/user.module'; import { UserModule } from 'src/engine/core-modules/user/user.module';
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job'; import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job'; import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
@ -26,8 +26,8 @@ import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-c
import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module'; import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
@Module({ @Module({
imports: [ imports: [
@ -57,7 +57,7 @@ import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
CleanInactiveWorkspaceJob, CleanInactiveWorkspaceJob,
EmailSenderJob, EmailSenderJob,
DataSeedDemoWorkspaceJob, DataSeedDemoWorkspaceJob,
UpdateSubscriptionJob, UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob, HandleWorkspaceMemberDeletedJob,
], ],
}) })