add sync customer command and drop subscription customer constraint (#9131)

**TLDR:**
Solves (https://github.com/twentyhq/private-issues/issues/212)
Add command to sync customer data from stripe to BillingCustomerTable
for all active workspaces. Drop foreign key contraint on billingCustomer
in BillingSubscription (in order to not break the DB).

**In order to test:**

- Billing should be enabled
- Have some workspaces that are active and whose id's are not mentioned
in BillingCustomer (but the customer are present in stripe).

Run the command: 
`npx nx run twenty-server:command billing:sync-customer-data`

Take into consideration
Due that all the previous subscriptions in Stripe have the workspaceId
in their metadata, we use that information as source of true for the
data sync

**Things to do:**

- Add tests for Billing utils
- Separate StripeService into multipleServices
(stripeSubscriptionService, stripePriceService etc) perhaps add them in
(https://github.com/twentyhq/private-issues/issues/201)?
This commit is contained in:
Ana Sofia Marin Alexandre 2024-12-19 07:30:05 -03:00 committed by GitHub
parent e84176dc0d
commit 028e5cd940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 112 additions and 6 deletions

View File

@ -30,15 +30,9 @@ export class AddConstraintsOnBillingTables1734450749954
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "core"."billingEntitlement" ADD CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356" FOREIGN KEY ("stripeCustomerId") REFERENCES "core"."billingCustomer"("stripeCustomerId") ON DELETE CASCADE ON UPDATE NO ACTION`, `ALTER TABLE "core"."billingEntitlement" ADD CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356" FOREIGN KEY ("stripeCustomerId") REFERENCES "core"."billingCustomer"("stripeCustomerId") ON DELETE CASCADE ON UPDATE NO ACTION`,
); );
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_9120b7586c3471463480b58d20a" FOREIGN KEY ("stripeCustomerId") REFERENCES "core"."billingCustomer"("stripeCustomerId") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_9120b7586c3471463480b58d20a"`,
);
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356"`, `ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356"`,
); );

View File

@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingController } from 'src/engine/core-modules/billing/billing.controller'; import { BillingController } from 'src/engine/core-modules/billing/billing.controller';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver'; import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity'; import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
@ -59,6 +60,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
BillingWebhookProductService, BillingWebhookProductService,
BillingWebhookPriceService, BillingWebhookPriceService,
BillingRestApiExceptionFilter, BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
], ],
exports: [ exports: [
BillingSubscriptionService, BillingSubscriptionService,

View File

@ -0,0 +1,97 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
interface SyncCustomerDataCommandOptions
extends ActiveWorkspacesCommandOptions {}
@Command({
name: 'billing:sync-customer-data',
description: 'Sync customer data from Stripe for all active workspaces',
})
export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly stripeService: StripeService,
@InjectRepository(BillingCustomer, 'core')
protected readonly billingCustomerRepository: Repository<BillingCustomer>,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: SyncCustomerDataCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to sync customer data');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.syncCustomerDataForWorkspace(workspaceId, options);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async syncCustomerDataForWorkspace(
workspaceId: string,
options: SyncCustomerDataCommandOptions,
): Promise<void> {
const billingCustomer = await this.billingCustomerRepository.findOne({
where: {
workspaceId,
},
});
if (!options.dryRun && !billingCustomer) {
const stripeCustomerId =
await this.stripeService.getStripeCustomerIdFromWorkspaceId(
workspaceId,
);
if (stripeCustomerId) {
await this.billingCustomerRepository.upsert(
{
stripeCustomerId,
workspaceId,
},
{
conflictPaths: ['workspaceId'],
},
);
}
}
if (options.verbose) {
this.logger.log(
chalk.yellow(`Added ${workspaceId} to billingCustomer table`),
);
}
}
}

View File

@ -82,6 +82,7 @@ export class BillingSubscription {
{ {
nullable: false, nullable: false,
onDelete: 'CASCADE', onDelete: 'CASCADE',
createForeignKeyConstraints: false,
}, },
) )
@JoinColumn({ @JoinColumn({

View File

@ -194,4 +194,16 @@ export class StripeService {
return productPrices.sort((a, b) => a.unitAmount - b.unitAmount); return productPrices.sort((a, b) => a.unitAmount - b.unitAmount);
} }
async getStripeCustomerIdFromWorkspaceId(workspaceId: string) {
const subscription = await this.stripe.subscriptions.search({
query: `metadata['workspaceId']:'${workspaceId}'`,
limit: 1,
});
const stripeCustomerId = subscription.data[0].customer
? String(subscription.data[0].customer)
: undefined;
return stripeCustomerId;
}
} }