Skip to content

Commit

Permalink
add sync customer command and drop subscription customer constraint (#…
Browse files Browse the repository at this point in the history
…9131)

**TLDR:**
Solves (twentyhq/private-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
(twentyhq/private-issues#201)?
  • Loading branch information
anamarn authored Dec 19, 2024
1 parent e84176d commit 028e5cd
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,9 @@ export class AddConstraintsOnBillingTables1734450749954
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`,
);
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> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_9120b7586c3471463480b58d20a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356"`,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';

import { BillingController } from 'src/engine/core-modules/billing/billing.controller';
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 { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
Expand Down Expand Up @@ -59,6 +60,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
BillingWebhookProductService,
BillingWebhookPriceService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
],
exports: [
BillingSubscriptionService,
Expand Down
Original file line number Diff line number Diff line change
@@ -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`),
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export class BillingSubscription {
{
nullable: false,
onDelete: 'CASCADE',
createForeignKeyConstraints: false,
},
)
@JoinColumn({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,16 @@ export class StripeService {

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;
}
}

0 comments on commit 028e5cd

Please sign in to comment.