chore(billing): add tests + fix meter name for trial (#14701)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Righteousness Akinbola <righteousnessakinbola@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Abdullah. <125115953+mabdullahabaid@users.noreply.github.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Antoine Moreaux
2025-09-30 10:01:51 +02:00
committed by GitHub
parent da3c3d2b9d
commit 7692fb2b6c
13 changed files with 2663 additions and 1192 deletions
@@ -28,4 +28,5 @@ export enum BillingExceptionCode {
BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS',
BILLING_PRICE_INVALID = 'BILLING_PRICE_INVALID',
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND',
}
@@ -10,10 +10,6 @@ import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
@@ -81,13 +77,6 @@ export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWork
{ workspaceId },
);
if (!isDefined(subscription)) {
throw new BillingException(
`No subscription found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
const subscriptionItemToUpdate = subscription.billingSubscriptionItems.find(
(item) => item.stripePriceId === this.stripePriceIdToUpdate,
);
@@ -3,10 +3,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { Not, Repository } from 'typeorm';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { differenceInDays } from 'date-fns';
import type Stripe from 'stripe';
@@ -80,10 +82,10 @@ export class BillingSubscriptionService {
});
}
async getCurrentBillingSubscriptionOrThrow(criteria: {
async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
}): Promise<BillingSubscription | undefined> {
const notCanceledSubscriptions =
await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not(SubscriptionStatus.Canceled) },
@@ -93,12 +95,32 @@ export class BillingSubscriptionService {
],
});
assert(
notCanceledSubscriptions.length <= 1,
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
if (notCanceledSubscriptions.length > 1) {
throw new BillingException(
`More than one not canceled subscription for workspace ${criteria.workspaceId}`,
BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND,
);
}
return notCanceledSubscriptions[0];
}
async getCurrentBillingSubscriptionOrThrow(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}): Promise<BillingSubscription> {
const notCanceledSubscription =
await this.getCurrentBillingSubscription(criteria);
assertIsDefinedOrThrow(
notCanceledSubscription,
new BillingException(
`No active subscription found for workspace ${criteria.workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
),
);
return notCanceledSubscriptions?.[0];
return notCanceledSubscription;
}
async getBaseProductCurrentBillingSubscriptionItemOrThrow(
@@ -138,10 +160,9 @@ export class BillingSubscriptionService {
}
async deleteSubscriptions(workspaceId: string) {
const subscriptionToCancel =
await this.getCurrentBillingSubscriptionOrThrow({
workspaceId,
});
const subscriptionToCancel = await this.getCurrentBillingSubscription({
workspaceId,
});
if (subscriptionToCancel) {
await this.stripeSubscriptionService.cancelSubscription(
@@ -379,6 +400,8 @@ export class BillingSubscriptionService {
}
async getMeteredBillingPriceByPriceId(stripePriceId: string) {
assertIsDefinedOrThrow(stripePriceId);
const currentMeteredBillingPrice =
await this.billingPriceRepository.findOneOrFail({
where: {
@@ -519,6 +542,7 @@ export class BillingSubscriptionService {
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
stripeSubscriptionId,
);
const schedule =
await this.stripeSubscriptionScheduleService.findOrCreateSubscriptionSchedule(
subscription,
@@ -963,7 +987,7 @@ export class BillingSubscriptionService {
updateType: 'interval',
});
return this.upgradeIntervalNowWithReanchor(
await this.upgradeIntervalNowWithReanchor(
billingSubscription.stripeSubscriptionId,
{
licensedPriceId: targetLicensedPrice.stripePriceId,
@@ -971,6 +995,55 @@ export class BillingSubscriptionService {
seats,
},
);
const { currentEditable, nextEditable, subscription, schedule } =
await this.loadScheduleEditable(
billingSubscription.stripeSubscriptionId,
);
if (nextEditable && currentEditable) {
const reloadedNextDetails =
await this.billingSubscriptionPhaseService.getDetailsFromPhase(
nextEditable as BillingSubscriptionSchedulePhase,
);
const mappedNext = await this.resolvePrices({
interval: SubscriptionInterval.Year,
planKey: reloadedNextDetails.plan.planKey,
meteredPriceId: reloadedNextDetails.meteredPrice.stripePriceId,
updateType: 'interval',
});
const currentSnap =
this.billingSubscriptionPhaseService.toSnapshot(currentEditable);
const nextPhaseForYear =
this.billingSubscriptionPhaseService.buildSnapshot(
{
start_date: ensureFutureStartDate(
(currentSnap?.end_date as number | undefined) ??
subscription.current_period_end,
),
items: currentSnap.items,
proration_behavior: 'none',
} as Stripe.SubscriptionScheduleUpdateParams.Phase,
mappedNext.targetLicensedPrice.stripePriceId,
reloadedNextDetails.quantity,
mappedNext.targetMeteredPrice.stripePriceId,
await this.getBillingThresholdsByPriceId(
mappedNext.targetLicensedPrice.stripePriceId,
),
);
return await this.scheduleReplaceNext({
subscription,
scheduleId: schedule.id,
currentSnapshot: currentSnap,
nextPhase: nextPhaseForYear,
});
}
return;
}
// Case C: Year -> Month
@@ -1097,7 +1170,7 @@ export class BillingSubscriptionService {
const { targetLicensedPrice, targetMeteredPrice } =
await this.resolvePrices({
interval,
planKey: BillingPlanKey.ENTERPRISE,
planKey: targetPlanKey,
meteredPriceId: currentMeteredPriceId,
updateType: 'plan',
});
@@ -1106,7 +1179,7 @@ export class BillingSubscriptionService {
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
seats,
planMeta: BillingPlanKey.ENTERPRISE,
planMeta: targetPlanKey,
});
return;
@@ -1138,12 +1211,12 @@ export class BillingSubscriptionService {
const nextPrices = await this.resolvePrices({
interval: preservedNextInterval,
planKey: BillingPlanKey.PRO,
planKey: targetPlanKey,
meteredPriceId: preservedNextMeteredId,
updateType: 'plan',
});
await this.downgradeDeferred(stripeSubscriptionId, {
return await this.downgradeDeferred(stripeSubscriptionId, {
current: {
licensedPriceId: currentPrices.targetLicensedPrice.stripePriceId,
meteredPriceId: currentMeteredPriceId,
@@ -1156,8 +1229,6 @@ export class BillingSubscriptionService {
planKey: BillingPlanKey.PRO,
},
});
return;
}
throw new BillingException(
@@ -1217,7 +1288,7 @@ export class BillingSubscriptionService {
currentSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
}): Promise<void> {
const { scheduleId, currentSnapshot } = params;
const { scheduleId, currentSnapshot, subscription } = params;
let { nextPhase } = params;
if (
@@ -1239,8 +1310,7 @@ export class BillingSubscriptionService {
);
const refreshed =
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
(params.subscription as Stripe.Subscription).id ??
(params.subscription as SubscriptionWithSchedule).id,
subscription.id,
);
const workspaceId = (
await this.billingSubscriptionRepository.findOneByOrFail({
@@ -1253,38 +1323,46 @@ export class BillingSubscriptionService {
private async upgradePlanNow(
stripeSubscriptionId: string,
prices: {
newPrices: {
licensedPriceId: string;
meteredPriceId: string;
seats: number;
planMeta?: BillingPlanKey;
},
): Promise<void> {
const sub = await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
const currentSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { stripeSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
const licensed = this.getCurrentLicensedBillingSubscriptionItemOrThrow(sub);
const metered = this.getCurrentMeteredBillingSubscriptionItemOrThrow(sub);
const currentLicenseSubsciptionItem =
this.getCurrentLicensedBillingSubscriptionItemOrThrow(
currentSubscription,
);
const currentMeteredSubsciptionItem =
this.getCurrentMeteredBillingSubscriptionItemOrThrow(currentSubscription);
const updatedSubscription = await this.updateSubscription({
stripeSubscriptionId,
licensedItemId: licensed.stripeSubscriptionItemId,
meteredItemId: metered.stripeSubscriptionItemId,
licensedPriceId: prices.licensedPriceId,
meteredPriceId: prices.meteredPriceId,
seats: prices.seats,
licensedItemId: currentLicenseSubsciptionItem.stripeSubscriptionItemId,
meteredItemId: currentMeteredSubsciptionItem.stripeSubscriptionItemId,
licensedPriceId: newPrices.licensedPriceId,
meteredPriceId: newPrices.meteredPriceId,
seats: newPrices.seats,
proration: 'create_prorations',
metadata: prices.planMeta
? { ...(sub?.metadata || {}), plan: prices.planMeta }
metadata: newPrices.planMeta
? { ...(currentSubscription?.metadata || {}), plan: newPrices.planMeta }
: undefined,
});
await this.syncSubscriptionToDatabase(sub.workspaceId, updatedSubscription);
await this.syncSubscriptionToDatabase(
currentSubscription.workspaceId,
updatedSubscription,
);
}
private async upgradeIntervalNowWithReanchor(
@@ -38,17 +38,11 @@ export class BillingUsageService {
}
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{
workspaceId,
},
);
await this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId,
});
if (!billingSubscription) {
return false;
}
return true;
return !!billingSubscription;
}
async billUsage({
@@ -94,13 +88,6 @@ export class BillingUsageService {
{ workspaceId: workspace.id },
);
if (!isDefined(subscription)) {
throw new BillingException(
'Not-canceled subscription not found',
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
const meteredSubscriptionItemDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscription.id,
@@ -1,10 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
@Injectable()
export class StripeBillingAlertService {
@@ -28,14 +31,20 @@ export class StripeBillingAlertService {
customerId: string,
gte: number,
): Promise<void> {
const meters = await this.stripeBillingMeterService.getAllMeters();
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
(meter) => {
return meter.event_name === BillingMeterEventName.WORKFLOW_NODE_RUN;
},
);
assertIsDefinedOrThrow(meter);
await this.stripe.billing.alerts.create({
alert_type: 'usage_threshold',
title: `Trial usage cap for customer ${customerId}`,
usage_threshold: {
gte,
meter: meters[0].id,
meter: meter.id,
recurrence: 'one_time',
filters: [
{
@@ -2,12 +2,18 @@
import { Injectable, Logger } from '@nestjs/common';
import { findOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
@Injectable()
export class StripeSubscriptionScheduleService {
@@ -60,18 +66,30 @@ export class StripeSubscriptionScheduleService {
getEditablePhases(live: Stripe.SubscriptionSchedule) {
const now = Math.floor(Date.now() / 1000);
const currentEditable = (live.phases || []).find((p) => {
const s = p.start_date ?? 0;
const e = p.end_date ?? Infinity;
const currentEditable = findOrThrow(
live.phases,
(p) => {
const s = p.start_date ?? 0;
const e = p.end_date ?? Infinity;
return s <= now && now < e;
});
return s <= now && now < e;
},
new BillingException(
`Subscription must have at least 1 phase to be editable`,
BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND,
),
);
const nextEditable = (live.phases || [])
.filter((p) => (p.start_date ?? 0) > now)
.sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0];
.sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0] as
| Stripe.SubscriptionSchedule.Phase
| undefined;
return { currentEditable, nextEditable };
return {
currentEditable,
nextEditable,
};
}
async getSubscriptionWithSchedule(stripeSubscriptionId: string) {
@@ -128,12 +146,10 @@ export class StripeSubscriptionScheduleService {
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [];
if (currentEditable) {
const currentSnapshot =
desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable);
const currentSnapshot =
desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable);
phases.push(currentSnapshot);
}
phases.push(currentSnapshot);
const hasNextKey = 'nextPhase' in desired;
const wantsNext = hasNextKey && !!desired.nextPhase;
@@ -4,7 +4,6 @@ import { Injectable, Logger } from '@nestjs/common';
import type Stripe from 'stripe';
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@@ -60,23 +59,6 @@ export class StripeSubscriptionService {
await this.stripe.invoices.pay(latestInvoice.id);
}
async updateSubscriptionItems(
stripeSubscriptionId: string,
billingSubscriptionItems: BillingSubscriptionItem[],
) {
const stripeSubscriptionItemsToUpdate = billingSubscriptionItems.map(
(item) => ({
id: item.stripeSubscriptionItemId,
price: item.stripePriceId,
quantity: item.quantity === null ? undefined : item.quantity,
}),
);
await this.stripe.subscriptions.update(stripeSubscriptionId, {
items: stripeSubscriptionItemsToUpdate,
});
}
async updateSubscription(
stripeSubscriptionId: string,
updateData: Stripe.SubscriptionUpdateParams,
@@ -92,12 +74,4 @@ export class StripeSubscriptionService {
reset_billing_cycle_anchor: false,
};
}
async setYearlyThresholds(stripeSubscriptionId: string) {
return this.stripe.subscriptions.update(stripeSubscriptionId, {
billing_thresholds: this.getBillingThresholdsByInterval(
SubscriptionInterval.Year,
),
});
}
}