add command to sync plan data from stripe, doing testing (#9177)

**Solves**
[ https://github.com/twentyhq/private-issues/issues/211 ]

**TLDR:**

Add a command that fetches the plans product, meters and price in stripe
and whrites it to the DataBase. For now it fetches only active products.

**In order to test**

- Set IS_BILLING_ENABLED=true
- Run `npx nx database:reset twenty-server` if you don't have the
billing tables in your data base schema
-  run `npx nx run twenty-server:command billing:sync-plans-data -v`

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Ana Sofia Marin Alexandre
2024-12-31 12:10:48 -03:00
committed by GitHub
parent 6e0002b874
commit d4d8883794
8 changed files with 352 additions and 24 deletions

View File

@ -4,6 +4,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 { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-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';
@ -61,6 +62,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
BillingWebhookPriceService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
BillingSyncPlansDataCommand,
],
exports: [
BillingSubscriptionService,

View File

@ -0,0 +1,160 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import Stripe from 'stripe';
import { Repository } from 'typeorm';
import {
BaseCommandOptions,
BaseCommandRunner,
} from 'src/database/commands/base.command';
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util';
import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util';
import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util';
import { transformStripeProductDataToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util';
@Command({
name: 'billing:sync-plans-data',
description:
'Fetches from stripe the plans data (meter, product and price) and upserts it into the database',
})
export class BillingSyncPlansDataCommand extends BaseCommandRunner {
private readonly batchSize = 5;
constructor(
@InjectRepository(BillingPrice, 'core')
private readonly billingPriceRepository: Repository<BillingPrice>,
@InjectRepository(BillingProduct, 'core')
private readonly billingProductRepository: Repository<BillingProduct>,
@InjectRepository(BillingMeter, 'core')
private readonly billingMeterRepository: Repository<BillingMeter>,
private readonly stripeService: StripeService,
) {
super();
}
private async upsertMetersRepositoryData(
meters: Stripe.Billing.Meter[],
options: BaseCommandOptions,
) {
meters.map(async (meter) => {
try {
if (!options.dryRun) {
await this.billingMeterRepository.upsert(
transformStripeMeterDataToMeterRepositoryData(meter),
{
conflictPaths: ['stripeMeterId'],
},
);
}
this.logger.log(`Upserted meter: ${meter.id}`);
} catch (error) {
this.logger.error(`Error upserting meter ${meter.id}: ${error}`);
}
});
}
private async upsertProductRepositoryData(
product: Stripe.Product,
options: BaseCommandOptions,
) {
try {
if (!options.dryRun) {
await this.billingProductRepository.upsert(
transformStripeProductDataToProductRepositoryData(product),
{
conflictPaths: ['stripeProductId'],
},
);
}
this.logger.log(`Upserted product: ${product.id}`);
} catch (error) {
this.logger.error(`Error upserting product ${product.id}: ${error}`);
}
}
private async getBillingPrices(
products: Stripe.Product[],
options: BaseCommandOptions,
): Promise<Stripe.Price[][]> {
return await Promise.all(
products.map(async (product) => {
if (!isStripeValidProductMetadata(product.metadata)) {
this.logger.log(
`Product: ${product.id} purposefully not inserted, invalid metadata format: ${JSON.stringify(
product.metadata,
)}`,
);
return [];
}
await this.upsertProductRepositoryData(product, options);
const prices = await this.stripeService.getPricesByProductId(
product.id,
);
this.logger.log(
`${prices.length} prices found for product: ${product.id}`,
);
return prices;
}),
);
}
private async processBillingPricesByProductBatches(
products: Stripe.Product[],
options: BaseCommandOptions,
) {
const prices: Stripe.Price[][] = [];
for (let start = 0; start < products.length; start += this.batchSize) {
const end =
start + this.batchSize > products.length
? products.length
: start + this.batchSize;
const batch = products.slice(start, end);
const batchPrices = await this.getBillingPrices(batch, options);
prices.push(...batchPrices);
this.logger.log(
`Processed batch ${start / this.batchSize + 1} of products`,
);
}
return prices;
}
override async executeBaseCommand(
passedParams: string[],
options: BaseCommandOptions,
): Promise<void> {
const billingMeters = await this.stripeService.getAllMeters();
await this.upsertMetersRepositoryData(billingMeters, options);
const billingProducts = await this.stripeService.getAllProducts();
const billingPrices = await this.processBillingPricesByProductBatches(
billingProducts,
options,
);
const transformedPrices = billingPrices.flatMap((prices) =>
prices.map((price) =>
transformStripePriceDataToPriceRepositoryData(price),
),
);
this.logger.log(`Upserting ${transformedPrices.length} transformed prices`);
if (!options.dryRun) {
await this.billingPriceRepository.upsert(transformedPrices, {
conflictPaths: ['stripePriceId'],
});
}
}
}

View File

@ -1,7 +1,6 @@
import { registerEnumType } from '@nestjs/graphql';
export enum BillingPlanKey {
BASE = 'BASE',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}

View File

@ -8,6 +8,7 @@ import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util';
import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util';
@Injectable()
export class BillingWebhookProductService {
@ -21,9 +22,7 @@ export class BillingWebhookProductService {
data: Stripe.ProductCreatedEvent.Data | Stripe.ProductUpdatedEvent.Data,
) {
const metadata = data.object.metadata;
const isStripeValidProductMetadata =
this.isStripeValidProductMetadata(metadata);
const productRepositoryData = isStripeValidProductMetadata
const productRepositoryData = isStripeValidProductMetadata(metadata)
? {
...transformStripeProductEventToProductRepositoryData(data),
metadata,
@ -51,24 +50,12 @@ export class BillingWebhookProductService {
}
isValidBillingPlanKey(planKey?: string) {
switch (planKey) {
case BillingPlanKey.BASE:
return true;
case BillingPlanKey.PRO:
return true;
default:
return false;
}
return Object.values(BillingPlanKey).includes(planKey as BillingPlanKey);
}
isValidPriceUsageBased(priceUsageBased?: string) {
switch (priceUsageBased) {
case BillingUsageType.METERED:
return true;
case BillingUsageType.LICENSED:
return true;
default:
return false;
}
return Object.values(BillingUsageType).includes(
priceUsageBased as BillingUsageType,
);
}
}

View File

@ -168,10 +168,6 @@ export class StripeService {
});
}
async getCustomer(stripeCustomerId: string) {
return await this.stripe.customers.retrieve(stripeCustomerId);
}
async getMeter(stripeMeterId: string) {
return await this.stripe.billing.meters.retrieve(stripeMeterId);
}
@ -214,4 +210,24 @@ export class StripeService {
return stripeCustomerId;
}
async getAllProducts() {
const products = await this.stripe.products.list();
return products.data;
}
async getPricesByProductId(productId: string) {
const prices = await this.stripe.prices.search({
query: `product:'${productId}'`,
});
return prices.data;
}
async getAllMeters() {
const meters = await this.stripe.billing.meters.list();
return meters.data;
}
}

View File

@ -0,0 +1,39 @@
import Stripe from 'stripe';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
export function isStripeValidProductMetadata(
metadata: Stripe.Metadata,
): metadata is BillingProductMetadata {
if (Object.keys(metadata).length === 0) {
return true;
}
const hasBillingPlanKey = isValidBillingPlanKey(metadata.planKey);
const hasPriceUsageBased = isValidPriceUsageBased(metadata.priceUsageBased);
return hasBillingPlanKey && hasPriceUsageBased;
}
const isValidBillingPlanKey = (planKey?: string) => {
switch (planKey) {
case BillingPlanKey.ENTERPRISE:
return true;
case BillingPlanKey.PRO:
return true;
default:
return false;
}
};
const isValidPriceUsageBased = (priceUsageBased?: string) => {
switch (priceUsageBased) {
case BillingUsageType.METERED:
return true;
case BillingUsageType.LICENSED:
return true;
default:
return false;
}
};

View File

@ -0,0 +1,104 @@
import Stripe from 'stripe';
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
export const transformStripePriceDataToPriceRepositoryData = (
data: Stripe.Price,
) => {
return {
stripePriceId: data.id,
active: data.active,
stripeProductId: String(data.product),
stripeMeterId: data.recurring?.meter,
currency: data.currency.toUpperCase(),
nickname: data.nickname === null ? undefined : data.nickname,
taxBehavior: data.tax_behavior
? getTaxBehavior(data.tax_behavior)
: undefined,
type: getBillingPriceType(data.type),
billingScheme: getBillingPriceBillingScheme(data.billing_scheme),
unitAmountDecimal:
data.unit_amount_decimal === null ? undefined : data.unit_amount_decimal,
unitAmount: data.unit_amount ? Number(data.unit_amount) : undefined,
transformQuantity:
data.transform_quantity === null ? undefined : data.transform_quantity,
usageType: data.recurring?.usage_type
? getBillingPriceUsageType(data.recurring.usage_type)
: undefined,
interval: data.recurring?.interval
? getBillingPriceInterval(data.recurring.interval)
: undefined,
currencyOptions:
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
tiersMode: data.tiers_mode
? getBillingPriceTiersMode(data.tiers_mode)
: undefined,
recurring: data.recurring === null ? undefined : data.recurring,
};
};
const getTaxBehavior = (data: Stripe.Price.TaxBehavior) => {
switch (data) {
case 'exclusive':
return BillingPriceTaxBehavior.EXCLUSIVE;
case 'inclusive':
return BillingPriceTaxBehavior.INCLUSIVE;
case 'unspecified':
return BillingPriceTaxBehavior.UNSPECIFIED;
}
};
const getBillingPriceType = (data: Stripe.Price.Type) => {
switch (data) {
case 'one_time':
return BillingPriceType.ONE_TIME;
case 'recurring':
return BillingPriceType.RECURRING;
}
};
const getBillingPriceBillingScheme = (data: Stripe.Price.BillingScheme) => {
switch (data) {
case 'per_unit':
return BillingPriceBillingScheme.PER_UNIT;
case 'tiered':
return BillingPriceBillingScheme.TIERED;
}
};
const getBillingPriceUsageType = (data: Stripe.Price.Recurring.UsageType) => {
switch (data) {
case 'licensed':
return BillingUsageType.LICENSED;
case 'metered':
return BillingUsageType.METERED;
}
};
const getBillingPriceTiersMode = (data: Stripe.Price.TiersMode) => {
switch (data) {
case 'graduated':
return BillingPriceTiersMode.GRADUATED;
case 'volume':
return BillingPriceTiersMode.VOLUME;
}
};
const getBillingPriceInterval = (data: Stripe.Price.Recurring.Interval) => {
switch (data) {
case 'month':
return SubscriptionInterval.Month;
case 'day':
return SubscriptionInterval.Day;
case 'week':
return SubscriptionInterval.Week;
case 'year':
return SubscriptionInterval.Year;
}
};

View File

@ -0,0 +1,21 @@
import Stripe from 'stripe';
export const transformStripeProductDataToProductRepositoryData = (
data: Stripe.Product,
) => {
return {
stripeProductId: data.id,
name: data.name,
active: data.active,
description: data.description,
images: data.images,
marketingFeatures: data.marketing_features,
defaultStripePriceId: data.default_price
? String(data.default_price)
: undefined,
unitLabel: data.unit_label === null ? undefined : data.unit_label,
url: data.url === null ? undefined : data.url,
taxCode: data.tax_code ? String(data.tax_code) : undefined,
metadata: data.metadata,
};
};