feat(billing): refacto billing (#14243)

… prices for metered billing

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-09-19 11:25:53 +02:00
committed by GitHub
parent 163890f6c8
commit 43e0cd5d05
351 changed files with 16091 additions and 6101 deletions
@@ -22,6 +22,7 @@ import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-we
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service';
import { BillingWebhookSubscriptionScheduleService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service';
import {
BillingException,
BillingExceptionCode,
@@ -47,6 +48,7 @@ export class BillingWebhookController {
private readonly billingWebhookAlertService: BillingWebhookAlertService,
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
) {}
@Post(['webhooks/stripe'])
@@ -100,6 +102,11 @@ export class BillingWebhookController {
event.data,
);
case BillingWebhookEvent.SUBSCRIPTION_SCHEDULE_UPDATED:
return await this.billingWebhookSubscriptionScheduleService.processStripeEvent(
event.data,
);
case BillingWebhookEvent.PRODUCT_UPDATED:
case BillingWebhookEvent.PRODUCT_CREATED:
return await this.billingWebhookProductService.processStripeEvent(
@@ -9,6 +9,7 @@ import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-we
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service';
import { BillingWebhookSubscriptionScheduleService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
@@ -57,6 +58,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingWebhookInvoiceService,
BillingWebhookCustomerService,
BillingWebhookSubscriptionService,
BillingWebhookSubscriptionScheduleService,
BillingWebhookEntitlementService,
],
})
@@ -3,8 +3,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
@@ -14,17 +14,13 @@ import {
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
const TRIAL_PERIOD_ALERT_TITLE = 'TRIAL_PERIOD_ALERT'; // to set in Stripe config
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
@Injectable()
export class BillingWebhookAlertService {
protected readonly logger = new Logger(BillingWebhookAlertService.name);
constructor(
@InjectRepository(BillingSubscription)
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingProduct)
private readonly billingProductRepository: Repository<BillingProduct>,
@InjectRepository(BillingSubscriptionItem)
@@ -34,57 +30,39 @@ export class BillingWebhookAlertService {
async processStripeEvent(data: Stripe.BillingAlertTriggeredEvent.Data) {
const { customer: stripeCustomerId, alert } = data.object;
const stripeMeterId = alert.usage_threshold?.meter as string | undefined;
const stripeMeterId = alert.usage_threshold?.meter;
if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
const subscription = await this.billingSubscriptionRepository.findOne({
where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
assertIsDefinedOrThrow(stripeMeterId);
if (!subscription) return;
const product = await this.billingProductRepository.findOne({
where: {
billingPrices: { stripeMeterId },
},
});
if (!product) {
throw new BillingException(
`Product associated to meter ${stripeMeterId} not found`,
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
const subscriptionItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.stripeProductId === product.stripeProductId,
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ stripeCustomerId },
);
const trialPeriodFreeWorkflowCredits = isDefined(
subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits,
)
? Number(subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits)
: 0;
if (
!isDefined(alert.usage_threshold?.gte) ||
trialPeriodFreeWorkflowCredits !== alert.usage_threshold.gte
) {
return;
}
await this.billingSubscriptionItemRepository.update(
{
billingSubscriptionId: subscription.id,
stripeProductId: product.stripeProductId,
const product = await this.billingProductRepository.findOne({
where: {
billingPrices: {
stripeMeterId:
typeof stripeMeterId === 'string'
? stripeMeterId
: stripeMeterId.id,
},
{ hasReachedCurrentPeriodCap: true },
},
});
if (!product) {
throw new BillingException(
`Product associated to meter ${stripeMeterId} not found`,
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
await this.billingSubscriptionItemRepository.update(
{
billingSubscriptionId: subscription.id,
stripeProductId: product.stripeProductId,
},
{ hasReachedCurrentPeriodCap: true },
);
}
}
@@ -17,12 +17,14 @@ import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-p
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { transformStripeMeterToDatabaseMeter } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-to-database-meter.util';
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
@Injectable()
export class BillingWebhookPriceService {
protected readonly logger = new Logger(BillingWebhookPriceService.name);
constructor(
private readonly stripeBillingMeterService: StripeBillingMeterService,
private readonly stripePriceService: StripePriceService,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
@InjectRepository(BillingMeter)
@@ -61,7 +63,9 @@ export class BillingWebhookPriceService {
}
await this.billingPriceRepository.upsert(
transformStripePriceEventToDatabasePrice(data),
transformStripePriceEventToDatabasePrice(
await this.stripePriceService.getPriceByPriceId(data.object.id),
),
{
conflictPaths: ['stripePriceId'],
skipUpdateIfNoValuesChanged: true,
@@ -0,0 +1,63 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { transformStripeSubscriptionScheduleEventToDatabaseSubscriptionPhase } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-schedule-event-to-database-subscription-phase.util';
@Injectable()
export class BillingWebhookSubscriptionScheduleService {
protected readonly logger = new Logger(
BillingWebhookSubscriptionScheduleService.name,
);
constructor(
@InjectRepository(BillingSubscription)
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
) {}
async processStripeEvent(
data:
| Stripe.SubscriptionScheduleUpdatedEvent.Data
| Stripe.SubscriptionScheduleCanceledEvent.Data,
) {
const schedule = data.object as Stripe.SubscriptionSchedule;
if (!isDefined(schedule.subscription)) {
throw new Error('Subscription is not defined');
}
const subscriptionId =
typeof schedule.subscription === 'string'
? schedule.subscription
: schedule.subscription.id;
const subscriptionWithSchedule =
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
subscriptionId,
);
await this.billingSubscriptionRepository.update(
{ stripeSubscriptionId: subscriptionWithSchedule.id },
{
phases:
transformStripeSubscriptionScheduleEventToDatabaseSubscriptionPhase(
schedule,
),
},
);
return {
stripeSubscriptionId: subscriptionWithSchedule.id,
phasesCount: subscriptionWithSchedule.schedule?.phases?.length ?? 0,
scheduleId: subscriptionWithSchedule.schedule?.id,
};
}
}
@@ -29,6 +29,8 @@ import {
CleanWorkspaceDeletionWarningUserVarsJob,
type CleanWorkspaceDeletionWarningUserVarsJobData,
} from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -50,6 +52,8 @@ export class BillingWebhookSubscriptionService {
private readonly billingCustomerRepository: Repository<BillingCustomer>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly workspaceService: WorkspaceService,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
) {}
async processStripeEvent(
@@ -83,7 +87,12 @@ export class BillingWebhookSubscriptionService {
);
await this.billingSubscriptionRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscription(workspaceId, data),
transformStripeSubscriptionEventToDatabaseSubscription(
workspaceId,
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
data.object.id,
),
),
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
@@ -146,6 +155,15 @@ export class BillingWebhookSubscriptionService {
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
updatedBillingSubscription.id,
);
const gte =
this.billingSubscriptionService.getTrialPeriodFreeWorkflowCredits(
updatedBillingSubscription,
);
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
updatedBillingSubscription.stripeCustomerId,
gte,
);
}
return {
@@ -3,35 +3,32 @@
import { transformStripePriceEventToDatabasePrice } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-price-event-to-database-price.util';
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';
describe('transformStripePriceEventToDatabasePrice', () => {
const createMockPriceData = (overrides = {}) => ({
object: {
id: 'price_123',
active: true,
product: 'prod_123',
meter: null,
currency: 'usd',
nickname: null,
tax_behavior: null,
type: 'recurring',
billing_scheme: 'per_unit',
unit_amount_decimal: '1000',
unit_amount: 1000,
transform_quantity: null,
recurring: {
usage_type: 'licensed',
interval: 'month',
},
currency_options: null,
tiers: null,
tiers_mode: null,
...overrides,
id: 'price_123',
active: true,
product: 'prod_123',
meter: null,
currency: 'usd',
nickname: null,
tax_behavior: null,
type: 'recurring',
billing_scheme: 'per_unit',
unit_amount_decimal: '1000',
unit_amount: 1000,
transform_quantity: null,
recurring: {
usage_type: 'licensed',
interval: 'month',
},
currency_options: null,
tiers: null,
tiers_mode: null,
...overrides,
});
it('should transform basic price data correctly', () => {
@@ -55,7 +52,6 @@ describe('transformStripePriceEventToDatabasePrice', () => {
interval: SubscriptionInterval.Month,
currencyOptions: undefined,
tiers: undefined,
tiersMode: undefined,
recurring: {
usage_type: 'licensed',
interval: 'month',
@@ -124,25 +120,9 @@ describe('transformStripePriceEventToDatabasePrice', () => {
});
});
it('should handle all tiers modes correctly', () => {
const tiersModes = [
['graduated', BillingPriceTiersMode.GRADUATED],
['volume', BillingPriceTiersMode.VOLUME],
];
tiersModes.forEach(([stripeTiersMode, expectedTiersMode]) => {
const mockData = createMockPriceData({ tiers_mode: stripeTiersMode });
const result = transformStripePriceEventToDatabasePrice(mockData as any);
expect(result.tiersMode).toBe(expectedTiersMode);
});
});
it('should handle all intervals correctly', () => {
const intervals = [
['month', SubscriptionInterval.Month],
['day', SubscriptionInterval.Day],
['week', SubscriptionInterval.Week],
['year', SubscriptionInterval.Year],
];
@@ -172,7 +152,6 @@ describe('transformStripePriceEventToDatabasePrice', () => {
expect(result.billingScheme).toBe(BillingPriceBillingScheme.TIERED);
expect(result.tiers).toEqual(mockTiers);
expect(result.tiersMode).toBe(BillingPriceTiersMode.GRADUATED);
});
it('should handle metered pricing with transform quantity', () => {
@@ -9,34 +9,32 @@ describe('transformStripeSubscriptionEventToDatabaseSubscription', () => {
const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC
const createMockSubscriptionData = (overrides = {}) => ({
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'active',
items: {
data: [
{
plan: {
interval: 'month',
},
id: 'sub_123',
customer: 'cus_123',
status: 'active',
items: {
data: [
{
plan: {
interval: 'month',
},
],
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: mockTimestamp,
current_period_start: mockTimestamp - 2592000, // 30 days before end
metadata: {},
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
ended_at: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
...overrides,
},
],
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: mockTimestamp,
current_period_start: mockTimestamp - 2592000, // 30 days before end
metadata: {},
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
ended_at: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
...overrides,
});
it('should transform basic subscription data correctly', () => {
@@ -66,6 +64,7 @@ describe('transformStripeSubscriptionEventToDatabaseSubscription', () => {
trialEnd: undefined,
cancelAt: undefined,
canceledAt: undefined,
phases: [],
});
});
@@ -4,53 +4,40 @@ import type 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 transformStripePriceEventToDatabasePrice = (
data: Stripe.PriceCreatedEvent.Data | Stripe.PriceUpdatedEvent.Data,
data: Stripe.Price,
) => {
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)
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.object.type),
billingScheme: getBillingPriceBillingScheme(data.object.billing_scheme),
type: getBillingPriceType(data.type),
billingScheme: getBillingPriceBillingScheme(data.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,
data.unit_amount_decimal === null ? undefined : data.unit_amount_decimal,
unitAmount: data.unit_amount ? Number(data.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)
data.transform_quantity === null ? undefined : data.transform_quantity,
usageType: data.recurring?.usage_type
? getBillingPriceUsageType(data.recurring.usage_type)
: undefined,
interval: data.object.recurring?.interval
? getBillingPriceInterval(data.object.recurring.interval)
interval: data.recurring?.interval
? getBillingPriceInterval(data.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,
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
recurring: data.recurring === null ? undefined : data.recurring,
};
};
@@ -92,23 +79,10 @@ const getBillingPriceUsageType = (data: Stripe.Price.Recurring.UsageType) => {
}
};
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;
}
@@ -4,52 +4,57 @@ import type Stripe from 'stripe';
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { transformStripeSubscriptionScheduleEventToDatabaseSubscriptionPhase } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-schedule-event-to-database-subscription-phase.util';
export const transformStripeSubscriptionEventToDatabaseSubscription = (
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
subscription: SubscriptionWithSchedule,
) => {
return {
workspaceId,
stripeCustomerId: String(data.object.customer),
stripeSubscriptionId: data.object.id,
status: getSubscriptionStatus(data.object.status),
interval: data.object.items.data[0].plan.interval,
cancelAtPeriodEnd: data.object.cancel_at_period_end,
currency: data.object.currency.toUpperCase(),
currentPeriodEnd: getDateFromTimestamp(data.object.current_period_end),
currentPeriodStart: getDateFromTimestamp(data.object.current_period_start),
metadata: data.object.metadata,
stripeCustomerId: String(subscription.customer),
stripeSubscriptionId: subscription.id,
status: getSubscriptionStatus(subscription.status),
interval: subscription.items.data[0].plan.interval as SubscriptionInterval,
phases: subscription.schedule
? transformStripeSubscriptionScheduleEventToDatabaseSubscriptionPhase(
subscription.schedule,
)
: [],
cancelAtPeriodEnd: subscription.cancel_at_period_end,
currency: subscription.currency.toUpperCase(),
currentPeriodEnd: getDateFromTimestamp(subscription.current_period_end),
currentPeriodStart: getDateFromTimestamp(subscription.current_period_start),
metadata: subscription.metadata,
collectionMethod:
// @ts-expect-error legacy noImplicitAny
BillingSubscriptionCollectionMethod[
data.object.collection_method.toUpperCase()
subscription.collection_method.toUpperCase()
],
automaticTax:
data.object.automatic_tax === null
subscription.automatic_tax === null
? undefined
: data.object.automatic_tax,
: subscription.automatic_tax,
cancellationDetails:
data.object.cancellation_details === null
subscription.cancellation_details === null
? undefined
: data.object.cancellation_details,
endedAt: data.object.ended_at
? getDateFromTimestamp(data.object.ended_at)
: subscription.cancellation_details,
endedAt: subscription.ended_at
? getDateFromTimestamp(subscription.ended_at)
: undefined,
trialStart: data.object.trial_start
? getDateFromTimestamp(data.object.trial_start)
trialStart: subscription.trial_start
? getDateFromTimestamp(subscription.trial_start)
: undefined,
trialEnd: data.object.trial_end
? getDateFromTimestamp(data.object.trial_end)
trialEnd: subscription.trial_end
? getDateFromTimestamp(subscription.trial_end)
: undefined,
cancelAt: data.object.cancel_at
? getDateFromTimestamp(data.object.cancel_at)
cancelAt: subscription.cancel_at
? getDateFromTimestamp(subscription.cancel_at)
: undefined,
canceledAt: data.object.canceled_at
? getDateFromTimestamp(data.object.canceled_at)
canceledAt: subscription.canceled_at
? getDateFromTimestamp(subscription.canceled_at)
: undefined,
};
};
@@ -0,0 +1,20 @@
/* @license Enterprise */
import { isDefined } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { type BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
export function transformStripeSubscriptionScheduleEventToDatabaseSubscriptionPhase(
schedule: Stripe.SubscriptionSchedule,
): Array<BillingSubscriptionSchedulePhase> {
return schedule.phases.slice(-2).map((phase) => ({
start_date: phase.start_date,
end_date: phase.end_date,
items: phase.items.map((item) => ({
price: typeof item.price === 'string' ? item.price : item.price.id,
...(isDefined(item.quantity) ? { quantity: item.quantity } : {}),
})),
}));
}