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 { 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 { 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 { 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 { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service';
|
||||||
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
|
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
|
||||||
@Controller('billing')
|
@Controller('billing')
|
||||||
@ -31,6 +32,7 @@ export class BillingController {
|
|||||||
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
||||||
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
|
private readonly billingWebhookProductService: BillingWebhookProductService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/webhooks')
|
@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();
|
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 { 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 { 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 { 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 { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service';
|
||||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
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,
|
BillingResolver,
|
||||||
BillingWorkspaceMemberListener,
|
BillingWorkspaceMemberListener,
|
||||||
BillingService,
|
BillingService,
|
||||||
|
BillingWebhookProductService,
|
||||||
BillingRestApiExceptionFilter,
|
BillingRestApiExceptionFilter,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|||||||
@ -4,7 +4,6 @@ export enum WebhookEvent {
|
|||||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||||
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||||
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
||||||
CUSTOMER_CREATED = 'customer.created',
|
PRODUCT_CREATED = 'product.created',
|
||||||
CUSTOMER_DELETED = 'customer.deleted',
|
PRODUCT_UPDATED = 'product.updated',
|
||||||
CUSTOMER_UPDATED = 'customer.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 { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
|
|
||||||
export type BillingProductMetadata = {
|
export type BillingProductMetadata =
|
||||||
planKey: BillingPlanKey;
|
| {
|
||||||
priceUsageBased: BillingUsageType;
|
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,
|
interval: data.object.items.data[0].plan.interval,
|
||||||
cancelAtPeriodEnd: data.object.cancel_at_period_end,
|
cancelAtPeriodEnd: data.object.cancel_at_period_end,
|
||||||
currency: data.object.currency.toUpperCase(),
|
currency: data.object.currency.toUpperCase(),
|
||||||
currentPeriodEnd: new Date(data.object.current_period_end * 1000),
|
currentPeriodEnd: getDateFromTimestamp(data.object.current_period_end),
|
||||||
currentPeriodStart: new Date(data.object.current_period_start * 1000),
|
currentPeriodStart: getDateFromTimestamp(data.object.current_period_start),
|
||||||
metadata: data.object.metadata,
|
metadata: data.object.metadata,
|
||||||
collectionMethod:
|
collectionMethod:
|
||||||
BillingSubscriptionCollectionMethod[
|
BillingSubscriptionCollectionMethod[
|
||||||
@ -28,19 +28,19 @@ export const transformStripeSubscriptionEventToSubscriptionRepositoryData = (
|
|||||||
automaticTax: data.object.automatic_tax ?? undefined,
|
automaticTax: data.object.automatic_tax ?? undefined,
|
||||||
cancellationDetails: data.object.cancellation_details ?? undefined,
|
cancellationDetails: data.object.cancellation_details ?? undefined,
|
||||||
endedAt: data.object.ended_at
|
endedAt: data.object.ended_at
|
||||||
? new Date(data.object.ended_at * 1000)
|
? getDateFromTimestamp(data.object.ended_at)
|
||||||
: undefined,
|
: undefined,
|
||||||
trialStart: data.object.trial_start
|
trialStart: data.object.trial_start
|
||||||
? new Date(data.object.trial_start * 1000)
|
? getDateFromTimestamp(data.object.trial_start)
|
||||||
: undefined,
|
: undefined,
|
||||||
trialEnd: data.object.trial_end
|
trialEnd: data.object.trial_end
|
||||||
? new Date(data.object.trial_end * 1000)
|
? getDateFromTimestamp(data.object.trial_end)
|
||||||
: undefined,
|
: undefined,
|
||||||
cancelAt: data.object.cancel_at
|
cancelAt: data.object.cancel_at
|
||||||
? new Date(data.object.cancel_at * 1000)
|
? getDateFromTimestamp(data.object.cancel_at)
|
||||||
: undefined,
|
: undefined,
|
||||||
canceledAt: data.object.canceled_at
|
canceledAt: data.object.canceled_at
|
||||||
? new Date(data.object.canceled_at * 1000)
|
? getDateFromTimestamp(data.object.canceled_at)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -65,3 +65,7 @@ const getSubscriptionStatus = (status: Stripe.Subscription.Status) => {
|
|||||||
return SubscriptionStatus.Unpaid;
|
return SubscriptionStatus.Unpaid;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDateFromTimestamp = (timestamp: number) => {
|
||||||
|
return new Date(timestamp * 1000);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user