add product table data in real time (#9055)
Solves (https://github.com/twentyhq/private-issues/issues/198) **TLDR** Updates the billingProduct table data using stripe webhooks event. It saves all the updates/creates of the products, but ensuring that it has the lastest version of the correct metadata attributes (typeof BillingProductMetadata) **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 Go to Stripe In test mode and update or create a product using a metadata of type of BillingProductMetadata, you can also update it using a different values for metadata. Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
5961d26f91
commit
abaf2651ec
@ -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 { 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';
|
||||
@Controller('billing')
|
||||
@ -31,6 +32,7 @@ export class BillingController {
|
||||
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
||||
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingWebhookProductService: BillingWebhookProductService,
|
||||
) {}
|
||||
|
||||
@Post('/webhooks')
|
||||
@ -88,6 +90,13 @@ export class BillingController {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === WebhookEvent.PRODUCT_CREATED ||
|
||||
event.type === WebhookEvent.PRODUCT_UPDATED
|
||||
) {
|
||||
await this.billingWebhookProductService.processStripeEvent(event.data);
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 { 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';
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
@ -54,6 +55,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
BillingResolver,
|
||||
BillingWorkspaceMemberListener,
|
||||
BillingService,
|
||||
BillingWebhookProductService,
|
||||
BillingRestApiExceptionFilter,
|
||||
],
|
||||
exports: [
|
||||
|
||||
@ -4,7 +4,6 @@ export enum WebhookEvent {
|
||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
||||
CUSTOMER_CREATED = 'customer.created',
|
||||
CUSTOMER_DELETED = 'customer.deleted',
|
||||
CUSTOMER_UPDATED = 'customer.updated',
|
||||
PRODUCT_CREATED = 'product.created',
|
||||
PRODUCT_UPDATED = 'product.updated',
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
||||
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 { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util';
|
||||
@Injectable()
|
||||
export class BillingWebhookProductService {
|
||||
protected readonly logger = new Logger(BillingWebhookProductService.name);
|
||||
constructor(
|
||||
@InjectRepository(BillingProduct, 'core')
|
||||
private readonly billingProductRepository: Repository<BillingProduct>,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(
|
||||
data: Stripe.ProductCreatedEvent.Data | Stripe.ProductUpdatedEvent.Data,
|
||||
) {
|
||||
const metadata = data.object.metadata;
|
||||
const isStripeValidProductMetadata =
|
||||
this.isStripeValidProductMetadata(metadata);
|
||||
const productRepositoryData = isStripeValidProductMetadata
|
||||
? {
|
||||
...transformStripeProductEventToProductRepositoryData(data),
|
||||
metadata,
|
||||
}
|
||||
: transformStripeProductEventToProductRepositoryData(data);
|
||||
|
||||
await this.billingProductRepository.upsert(productRepositoryData, {
|
||||
conflictPaths: ['stripeProductId'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
});
|
||||
}
|
||||
|
||||
isStripeValidProductMetadata(
|
||||
metadata: Stripe.Metadata,
|
||||
): metadata is BillingProductMetadata {
|
||||
if (Object.keys(metadata).length === 0) {
|
||||
return true;
|
||||
}
|
||||
const hasBillingPlanKey = this.isValidBillingPlanKey(metadata?.planKey);
|
||||
const hasPriceUsageBased = this.isValidPriceUsageBased(
|
||||
metadata?.priceUsageBased,
|
||||
);
|
||||
|
||||
return hasBillingPlanKey && hasPriceUsageBased;
|
||||
}
|
||||
|
||||
isValidBillingPlanKey(planKey: string | undefined) {
|
||||
switch (planKey) {
|
||||
case BillingPlanKey.BASE_PLAN:
|
||||
return true;
|
||||
case BillingPlanKey.PRO_PLAN:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isValidPriceUsageBased(priceUsageBased: string | undefined) {
|
||||
switch (priceUsageBased) {
|
||||
case BillingUsageType.METERED:
|
||||
return true;
|
||||
case BillingUsageType.LICENSED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
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';
|
||||
|
||||
export type BillingProductMetadata = {
|
||||
planKey: BillingPlanKey;
|
||||
priceUsageBased: BillingUsageType;
|
||||
};
|
||||
export type BillingProductMetadata =
|
||||
| {
|
||||
planKey: BillingPlanKey;
|
||||
priceUsageBased: BillingUsageType;
|
||||
[key: string]: string;
|
||||
}
|
||||
| Record<string, never>;
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export const transformStripeProductEventToProductRepositoryData = (
|
||||
data: Stripe.ProductUpdatedEvent.Data | Stripe.ProductCreatedEvent.Data,
|
||||
) => {
|
||||
return {
|
||||
stripeProductId: data.object.id,
|
||||
name: data.object.name,
|
||||
active: data.object.active,
|
||||
description: data.object.description,
|
||||
images: data.object.images,
|
||||
marketingFeatures: data.object.marketing_features,
|
||||
defaultStripePriceId: data.object.default_price
|
||||
? String(data.object.default_price)
|
||||
: undefined,
|
||||
unitLabel: data.object.unit_label ?? undefined,
|
||||
url: data.object.url ?? undefined,
|
||||
taxCode: data.object.tax_code ? String(data.object.tax_code) : undefined,
|
||||
};
|
||||
};
|
||||
@ -18,8 +18,8 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = (
|
||||
interval: data.object.items.data[0].plan.interval,
|
||||
cancelAtPeriodEnd: data.object.cancel_at_period_end,
|
||||
currency: data.object.currency.toUpperCase(),
|
||||
currentPeriodEnd: new Date(data.object.current_period_end * 1000),
|
||||
currentPeriodStart: new Date(data.object.current_period_start * 1000),
|
||||
currentPeriodEnd: getDateFromTimestamp(data.object.current_period_end),
|
||||
currentPeriodStart: getDateFromTimestamp(data.object.current_period_start),
|
||||
metadata: data.object.metadata,
|
||||
collectionMethod:
|
||||
BillingSubscriptionCollectionMethod[
|
||||
@ -28,19 +28,19 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = (
|
||||
automaticTax: data.object.automatic_tax ?? undefined,
|
||||
cancellationDetails: data.object.cancellation_details ?? undefined,
|
||||
endedAt: data.object.ended_at
|
||||
? new Date(data.object.ended_at * 1000)
|
||||
? getDateFromTimestamp(data.object.ended_at)
|
||||
: undefined,
|
||||
trialStart: data.object.trial_start
|
||||
? new Date(data.object.trial_start * 1000)
|
||||
? getDateFromTimestamp(data.object.trial_start)
|
||||
: undefined,
|
||||
trialEnd: data.object.trial_end
|
||||
? new Date(data.object.trial_end * 1000)
|
||||
? getDateFromTimestamp(data.object.trial_end)
|
||||
: undefined,
|
||||
cancelAt: data.object.cancel_at
|
||||
? new Date(data.object.cancel_at * 1000)
|
||||
? getDateFromTimestamp(data.object.cancel_at)
|
||||
: undefined,
|
||||
canceledAt: data.object.canceled_at
|
||||
? new Date(data.object.canceled_at * 1000)
|
||||
? getDateFromTimestamp(data.object.canceled_at)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
@ -65,3 +65,7 @@ const getSubscriptionStatus = (status: Stripe.Subscription.Status) => {
|
||||
return SubscriptionStatus.Unpaid;
|
||||
}
|
||||
};
|
||||
|
||||
const getDateFromTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user