mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
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:
parent
c776179ecc
commit
bce5be85a3
@ -16,7 +16,8 @@ import {
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
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 { 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';
|
||||
@Controller('billing')
|
||||
export class BillingController {
|
||||
@ -24,7 +25,8 @@ export class BillingController {
|
||||
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly billingWehbookService: BillingWebhookService,
|
||||
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
||||
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {}
|
||||
|
||||
@ -61,7 +63,7 @@ export class BillingController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingWehbookService.processStripeEvent(
|
||||
await this.billingWebhookSubscriptionService.processStripeEvent(
|
||||
workspaceId,
|
||||
event.data,
|
||||
);
|
||||
@ -70,7 +72,7 @@ export class BillingController {
|
||||
event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED
|
||||
) {
|
||||
try {
|
||||
await this.billingWehbookService.processCustomerActiveEntitlement(
|
||||
await this.billingWebhookEntitlementService.processStripeEvent(
|
||||
event.data,
|
||||
);
|
||||
} catch (error) {
|
||||
@ -82,6 +84,7 @@ export class BillingController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
||||
}
|
||||
|
@ -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 { 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 { 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 { 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 { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-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({
|
||||
imports: [
|
||||
@ -46,7 +47,8 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
|
||||
controllers: [BillingController],
|
||||
providers: [
|
||||
BillingSubscriptionService,
|
||||
BillingWebhookService,
|
||||
BillingWebhookSubscriptionService,
|
||||
BillingWebhookEntitlementService,
|
||||
BillingPortalWorkspaceService,
|
||||
BillingResolver,
|
||||
BillingWorkspaceMemberListener,
|
||||
@ -55,7 +57,6 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
|
||||
exports: [
|
||||
BillingSubscriptionService,
|
||||
BillingPortalWorkspaceService,
|
||||
BillingWebhookService,
|
||||
BillingService,
|
||||
],
|
||||
})
|
||||
|
@ -4,4 +4,7 @@ export enum WebhookEvent {
|
||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
||||
CUSTOMER_CREATED = 'customer.created',
|
||||
CUSTOMER_DELETED = 'customer.deleted',
|
||||
CUSTOMER_UPDATED = 'customer.updated',
|
||||
}
|
||||
|
@ -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 { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
export type UpdateSubscriptionJobData = { workspaceId: string };
|
||||
export type UpdateSubscriptionQuantityJobData = { workspaceId: string };
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.billingQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class UpdateSubscriptionJob {
|
||||
protected readonly logger = new Logger(UpdateSubscriptionJob.name);
|
||||
export class UpdateSubscriptionQuantityJob {
|
||||
protected readonly logger = new Logger(UpdateSubscriptionQuantityJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
@ -22,8 +22,8 @@ export class UpdateSubscriptionJob {
|
||||
private readonly twentyORMManager: TwentyORMManager,
|
||||
) {}
|
||||
|
||||
@Process(UpdateSubscriptionJob.name)
|
||||
async handle(data: UpdateSubscriptionJobData): Promise<void> {
|
||||
@Process(UpdateSubscriptionQuantityJob.name)
|
||||
async handle(data: UpdateSubscriptionQuantityJobData): Promise<void> {
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
'workspaceMember',
|
@ -1,9 +1,11 @@
|
||||
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 {
|
||||
UpdateSubscriptionJob,
|
||||
UpdateSubscriptionJobData,
|
||||
} from 'src/engine/core-modules/billing/jobs/update-subscription.job';
|
||||
UpdateSubscriptionQuantityJob,
|
||||
UpdateSubscriptionQuantityJobData,
|
||||
} from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
|
||||
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 { 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 { 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 { 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()
|
||||
export class BillingWorkspaceMemberListener {
|
||||
@ -33,8 +33,8 @@ export class BillingWorkspaceMemberListener {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<UpdateSubscriptionJobData>(
|
||||
UpdateSubscriptionJob.name,
|
||||
await this.messageQueueService.add<UpdateSubscriptionQuantityJobData>(
|
||||
UpdateSubscriptionQuantityJob.name,
|
||||
{ workspaceId: payload.workspaceId },
|
||||
);
|
||||
}
|
||||
|
@ -6,12 +6,11 @@ import { Repository } from 'typeorm';
|
||||
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 { 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingPortalWorkspaceService {
|
||||
@ -19,7 +18,6 @@ export class BillingPortalWorkspaceService {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -5,9 +5,9 @@ import Stripe from 'stripe';
|
||||
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 { 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
@ -114,7 +114,10 @@ export class StripeService {
|
||||
success_url: successUrl,
|
||||
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) {
|
||||
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[] {
|
||||
const productPrices: ProductPriceEntity[] = Object.values(
|
||||
prices
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
};
|
@ -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),
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
});
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
@ -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 { AuthModule } from 'src/engine/core-modules/auth/auth.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 { 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 { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
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 { 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';
|
||||
@ -26,8 +26,8 @@ import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-c
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.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 { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -57,7 +57,7 @@ import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
|
||||
CleanInactiveWorkspaceJob,
|
||||
EmailSenderJob,
|
||||
DataSeedDemoWorkspaceJob,
|
||||
UpdateSubscriptionJob,
|
||||
UpdateSubscriptionQuantityJob,
|
||||
HandleWorkspaceMemberDeletedJob,
|
||||
],
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user