add price and meter dynamically add foreign keys in billing (#9100)

**TLDR**
Solves: https://github.com/twentyhq/private-issues/issues/199
Partially solves: https://github.com/twentyhq/private-issues/issues/221
(more details below)

Updates the BillingMeter and BillingPrice tables while listening to the
events "price.created" and "price.updated" from the stripe webhook. Also
added the foreign keys, that couldn't be added to the BillingEntities.

**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.

**Take Into Consideration**

In a previous migration the foreign key to workpaceId was taken down
this was due to the separation of the migrations if billing is enabled.
Because we want to separate in these two categories: we will be
polluting the Common Migrations with relations to tables that don't
exists. This will be addressed in a PR in the next sprint (perhaps a
decorator?)


**Doing**
Testing migrations, when we are in main and when billing is enabled.
This commit is contained in:
Ana Sofia Marin Alexandre 2024-12-17 15:54:56 -03:00 committed by GitHub
parent e492efb79e
commit 55dc5983a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 348 additions and 30 deletions

View File

@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddConstraintsOnBillingTables1734450749954
implements MigrationInterface
{
name = 'AddConstraintsOnBillingTables1734450749954';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" DROP CONSTRAINT "IndexOnWorkspaceIdAndStripeCustomerIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "UQ_6a989264cab5ee2d4b424e78526" UNIQUE ("stripeSubscriptionItemId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "quantity"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD "quantity" numeric`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" ADD CONSTRAINT "UQ_53c2ef50e9611082f83d760897d" UNIQUE ("workspaceId")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IndexOnActiveSubscriptionPerWorkspace" ON "core"."billingSubscription" ("workspaceId") WHERE status IN ('trialing', 'active', 'past_due')`,
);
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"`,
);
await queryRunner.query(
`DROP INDEX "core"."IndexOnActiveSubscriptionPerWorkspace"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" DROP CONSTRAINT "UQ_53c2ef50e9611082f83d760897d"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP COLUMN "quantity"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD "quantity" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" DROP CONSTRAINT "UQ_6a989264cab5ee2d4b424e78526"`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" ADD CONSTRAINT "IndexOnWorkspaceIdAndStripeCustomerIdUnique" UNIQUE ("workspaceId", "stripeCustomerId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."billingSubscriptionItem" ADD CONSTRAINT "IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique" UNIQUE ("billingSubscriptionId", "stripeSubscriptionItemId")`,
);
}
}

View File

@ -19,6 +19,7 @@ import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webh
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service';
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.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';
@ -33,6 +34,7 @@ export class BillingController {
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWebhookProductService: BillingWebhookProductService,
private readonly billingWebhookPriceService: BillingWebhookPriceService,
) {}
@Post('/webhooks')
@ -96,6 +98,21 @@ export class BillingController {
) {
await this.billingWebhookProductService.processStripeEvent(event.data);
}
if (
event.type === WebhookEvent.PRICE_CREATED ||
event.type === WebhookEvent.PRICE_UPDATED
) {
try {
await this.billingWebhookPriceService.processStripeEvent(event.data);
} catch (error) {
if (
error instanceof BillingException &&
error.code === BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND
) {
res.status(404).end();
}
}
}
res.status(200).end();
}

View File

@ -11,4 +11,5 @@ export class BillingException extends CustomException {
export enum BillingExceptionCode {
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
}

View File

@ -15,6 +15,7 @@ import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/
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 { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service';
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.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';
@ -56,6 +57,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
BillingWorkspaceMemberListener,
BillingService,
BillingWebhookProductService,
BillingWebhookPriceService,
BillingRestApiExceptionFilter,
],
exports: [

View File

@ -8,7 +8,6 @@ import {
OneToMany,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
@ -18,10 +17,6 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
@Entity({ name: 'billingCustomer', schema: 'core' })
@ObjectType('billingCustomer')
@Unique('IndexOnWorkspaceIdAndStripeCustomerIdUnique', [
'workspaceId',
'stripeCustomerId',
])
export class BillingCustomer {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
@ -36,7 +31,7 @@ export class BillingCustomer {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: false, type: 'uuid' })
@Column({ nullable: false, type: 'uuid', unique: true })
workspaceId: string;
@Column({ nullable: false, unique: true })

View File

@ -52,7 +52,6 @@ export class BillingEntitlement {
(billingCustomer) => billingCustomer.billingEntitlements,
{
onDelete: 'CASCADE',
createForeignKeyConstraints: false, // TODO: remove this once the customer table is populated
},
)
@JoinColumn({

View File

@ -16,10 +16,6 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
'billingSubscriptionId',
'stripeProductId',
])
@Unique('IndexOnBillingSubscriptionIdAndStripeSubscriptionItemIdUnique', [
'billingSubscriptionId',
'stripeSubscriptionItemId',
])
export class BillingSubscriptionItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@ -60,9 +56,9 @@ export class BillingSubscriptionItem {
@Column({ nullable: false })
stripePriceId: string;
@Column({ nullable: false })
stripeSubscriptionItemId: string; //TODO: add unique
@Column({ nullable: false, unique: true })
stripeSubscriptionItemId: string;
@Column({ nullable: false })
quantity: number; //TODO: add nullable and modify stripe service
@Column({ nullable: true, type: 'numeric' })
quantity: number | null;
}

View File

@ -6,6 +6,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
@ -25,6 +26,10 @@ registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
@Entity({ name: 'billingSubscription', schema: 'core' })
@Index('IndexOnActiveSubscriptionPerWorkspace', ['workspaceId'], {
unique: true,
where: `status IN ('trialing', 'active', 'past_due')`,
})
@ObjectType('BillingSubscription')
export class BillingSubscription {
@IDField(() => UUIDScalarType)
@ -76,14 +81,14 @@ export class BillingSubscription {
(billingCustomer) => billingCustomer.billingSubscriptions,
{
nullable: false,
createForeignKeyConstraints: false,
onDelete: 'CASCADE',
},
)
@JoinColumn({
referencedColumnName: 'stripeCustomerId',
name: 'stripeCustomerId',
})
billingCustomer: Relation<BillingCustomer>; //let's see if it works
billingCustomer: Relation<BillingCustomer>;
@Column({ nullable: false, default: false })
cancelAtPeriodEnd: boolean;

View File

@ -6,4 +6,6 @@ export enum WebhookEvent {
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
PRODUCT_CREATED = 'product.created',
PRODUCT_UPDATED = 'product.updated',
PRICE_CREATED = 'price.created',
PRICE_UPDATED = 'price.updated',
}

View File

@ -0,0 +1,67 @@
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 { 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 { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util';
import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util';
@Injectable()
export class BillingWebhookPriceService {
protected readonly logger = new Logger(BillingWebhookPriceService.name);
constructor(
private readonly stripeService: StripeService,
@InjectRepository(BillingPrice, 'core')
private readonly billingPriceRepository: Repository<BillingPrice>,
@InjectRepository(BillingMeter, 'core')
private readonly billingMeterRepository: Repository<BillingMeter>,
@InjectRepository(BillingProduct, 'core')
private readonly billingProductRepository: Repository<BillingProduct>,
) {}
async processStripeEvent(
data: Stripe.PriceCreatedEvent.Data | Stripe.PriceUpdatedEvent.Data,
) {
const stripeProductId = String(data.object.product);
const product = await this.billingProductRepository.findOne({
where: { stripeProductId },
});
if (!product) {
throw new BillingException(
'Billing product not found',
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
const meterId = data.object.recurring?.meter;
if (meterId) {
const meterData = await this.stripeService.getMeter(meterId);
await this.billingMeterRepository.upsert(
transformStripeMeterDataToMeterRepositoryData(meterData),
{
conflictPaths: ['stripeMeterId'],
skipUpdateIfNoValuesChanged: true,
},
);
}
await this.billingPriceRepository.upsert(
transformStripePriceEventToPriceRepositoryData(data),
{
conflictPaths: ['stripePriceId'],
skipUpdateIfNoValuesChanged: true,
},
);
}
}

View File

@ -50,7 +50,7 @@ export class BillingWebhookProductService {
return hasBillingPlanKey && hasPriceUsageBased;
}
isValidBillingPlanKey(planKey: string | undefined) {
isValidBillingPlanKey(planKey?: string) {
switch (planKey) {
case BillingPlanKey.BASE_PLAN:
return true;
@ -61,7 +61,7 @@ export class BillingWebhookProductService {
}
}
isValidPriceUsageBased(priceUsageBased: string | undefined) {
isValidPriceUsageBased(priceUsageBased?: string) {
switch (priceUsageBased) {
case BillingUsageType.METERED:
return true;

View File

@ -54,7 +54,7 @@ export class BillingWebhookSubscriptionService {
data,
),
{
conflictPaths: ['workspaceId', 'stripeCustomerId'],
conflictPaths: ['workspaceId'],
skipUpdateIfNoValuesChanged: true,
},
);

View File

@ -114,10 +114,7 @@ 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(
@ -146,7 +143,10 @@ export class StripeService {
stripeSubscriptionItem.stripeSubscriptionItemId,
{
price: stripePriceId,
quantity: stripeSubscriptionItem.quantity,
quantity:
stripeSubscriptionItem.quantity === null
? undefined
: stripeSubscriptionItem.quantity,
},
);
}
@ -164,6 +164,10 @@ export class StripeService {
return await this.stripe.customers.retrieve(stripeCustomerId);
}
async getMeter(stripeMeterId: string) {
return await this.stripe.billing.meters.retrieve(stripeMeterId);
}
formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] {
const productPrices: ProductPriceEntity[] = Object.values(
prices

View File

@ -0,0 +1,40 @@
import Stripe from 'stripe';
import { BillingMeterEventTimeWindow } from 'src/engine/core-modules/billing/enums/billing-meter-event-time-window.enum';
import { BillingMeterStatus } from 'src/engine/core-modules/billing/enums/billing-meter-status.enum';
export const transformStripeMeterDataToMeterRepositoryData = (
data: Stripe.Billing.Meter,
) => {
return {
stripeMeterId: data.id,
displayName: data.display_name,
eventName: data.event_name,
status: getBillingMeterStatus(data.status),
customerMapping: data.customer_mapping,
eventTimeWindow: data.event_time_window
? getBillingMeterEventTimeWindow(data.event_time_window)
: undefined,
valueSettings: data.value_settings,
};
};
const getBillingMeterStatus = (data: Stripe.Billing.Meter.Status) => {
switch (data) {
case 'active':
return BillingMeterStatus.ACTIVE;
case 'inactive':
return BillingMeterStatus.INACTIVE;
}
};
const getBillingMeterEventTimeWindow = (
data: Stripe.Billing.Meter.EventTimeWindow,
) => {
switch (data) {
case 'day':
return BillingMeterEventTimeWindow.DAY;
case 'hour':
return BillingMeterEventTimeWindow.HOUR;
}
};

View File

@ -0,0 +1,113 @@
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 transformStripePriceEventToPriceRepositoryData = (
data: Stripe.PriceCreatedEvent.Data | Stripe.PriceUpdatedEvent.Data,
) => {
return {
stripePriceId: data.object.id,
active: data.object.active,
stripeProductId: String(data.object.product),
stripeMeterId: data.object.recurring?.meter,
currency: data.object.currency.toUpperCase(),
nickname: data.object.nickname === null ? undefined : data.object.nickname,
taxBehavior: data.object.tax_behavior
? getTaxBehavior(data.object.tax_behavior)
: undefined,
type: getBillingPriceType(data.object.type),
billingScheme: getBillingPriceBillingScheme(data.object.billing_scheme),
unitAmountDecimal:
data.object.unit_amount_decimal === null
? undefined
: data.object.unit_amount_decimal,
unitAmount: data.object.unit_amount
? Number(data.object.unit_amount)
: undefined,
transformQuantity:
data.object.transform_quantity === null
? undefined
: data.object.transform_quantity,
usageType: data.object.recurring?.usage_type
? getBillingPriceUsageType(data.object.recurring.usage_type)
: undefined,
interval: data.object.recurring?.interval
? getBillingPriceInterval(data.object.recurring.interval)
: undefined,
currencyOptions:
data.object.currency_options === null
? undefined
: data.object.currency_options,
tiers: data.object.tiers === null ? undefined : data.object.tiers,
tiersMode: data.object.tiers_mode
? getBillingPriceTiersMode(data.object.tiers_mode)
: undefined,
recurring:
data.object.recurring === null ? undefined : data.object.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

@ -13,8 +13,9 @@ export const transformStripeProductEventToProductRepositoryData = (
defaultStripePriceId: data.object.default_price
? String(data.object.default_price)
: undefined,
unitLabel: data.object.unit_label ?? undefined,
url: data.object.url ?? undefined,
unitLabel:
data.object.unit_label === null ? undefined : data.object.unit_label,
url: data.object.url === null ? undefined : data.object.url,
taxCode: data.object.tax_code ? String(data.object.tax_code) : undefined,
};
};

View File

@ -17,7 +17,10 @@ export const transformStripeSubscriptionEventToSubscriptionItemRepositoryData =
stripeSubscriptionItemId: item.id,
quantity: item.quantity,
metadata: item.metadata,
billingThresholds: item.billing_thresholds ?? undefined,
billingThresholds:
item.billing_thresholds === null
? undefined
: item.billing_thresholds,
};
});
};

View File

@ -25,8 +25,14 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = (
BillingSubscriptionCollectionMethod[
data.object.collection_method.toUpperCase()
],
automaticTax: data.object.automatic_tax ?? undefined,
cancellationDetails: data.object.cancellation_details ?? undefined,
automaticTax:
data.object.automatic_tax === null
? undefined
: data.object.automatic_tax,
cancellationDetails:
data.object.cancellation_details === null
? undefined
: data.object.cancellation_details,
endedAt: data.object.ended_at
? getDateFromTimestamp(data.object.ended_at)
: undefined,