Skip to content

Commit

Permalink
add price and meter dynamically add foreign keys in billing (#9100)
Browse files Browse the repository at this point in the history
**TLDR**
Solves: twentyhq/private-issues#199
Partially solves: twentyhq/private-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.
  • Loading branch information
anamarn authored Dec 17, 2024
1 parent e492efb commit 55dc598
Show file tree
Hide file tree
Showing 18 changed files with 348 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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")`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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')
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +57,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
BillingWorkspaceMemberListener,
BillingService,
BillingWebhookProductService,
BillingWebhookPriceService,
BillingRestApiExceptionFilter,
],
exports: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
OneToMany,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';

Expand All @@ -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')
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export class BillingEntitlement {
(billingCustomer) => billingCustomer.billingEntitlements,
{
onDelete: 'CASCADE',
createForeignKeyConstraints: false, // TODO: remove this once the customer table is populated
},
)
@JoinColumn({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -61,7 +61,7 @@ export class BillingWebhookProductService {
}
}

isValidPriceUsageBased(priceUsageBased: string | undefined) {
isValidPriceUsageBased(priceUsageBased?: string) {
switch (priceUsageBased) {
case BillingUsageType.METERED:
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class BillingWebhookSubscriptionService {
data,
),
{
conflictPaths: ['workspaceId', 'stripeCustomerId'],
conflictPaths: ['workspaceId'],
skipUpdateIfNoValuesChanged: true,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -146,7 +143,10 @@ export class StripeService {
stripeSubscriptionItem.stripeSubscriptionItemId,
{
price: stripePriceId,
quantity: stripeSubscriptionItem.quantity,
quantity:
stripeSubscriptionItem.quantity === null
? undefined
: stripeSubscriptionItem.quantity,
},
);
}
Expand All @@ -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
Expand Down
Loading

0 comments on commit 55dc598

Please sign in to comment.