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:
co-authored by
Félix Malfait
Righteousness Akinbola
Charles Bochet
Charles Bochet
Weiko
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
github-actions
martmull
Raphaël Bosi
Abdullah.
Thomas Trompette
Paul Rastoin
parent
da3c3d2b9d
commit
7692fb2b6c
+16
-18
@@ -79,25 +79,23 @@ export const SettingsBillingCreditsSection = ({
|
||||
withBorderRadius={true}
|
||||
/>
|
||||
|
||||
<StyledLineSeparator />
|
||||
{!isTrialing && (
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Extra Credits Used`}
|
||||
value={`${formatToShortNumber(extraCreditsUsed)}`}
|
||||
/>
|
||||
)}
|
||||
{!isTrialing && (
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost per 1k Extra Credits`}
|
||||
value={`$${formatNumber(costPer1kExtraCredits, { abbreviate: true, decimals: 6 })}`}
|
||||
/>
|
||||
)}
|
||||
{!isTrialing && (
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost`}
|
||||
isValueInPrimaryColor={true}
|
||||
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
|
||||
/>
|
||||
<>
|
||||
<StyledLineSeparator />
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Extra Credits Used`}
|
||||
value={`${formatToShortNumber(extraCreditsUsed)}`}
|
||||
/>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost per 1k Extra Credits`}
|
||||
value={`$${formatNumber(costPer1kExtraCredits, { abbreviate: true, decimals: 6 })}`}
|
||||
/>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost`}
|
||||
isValueInPrimaryColor={true}
|
||||
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_API_KEY as string, {
|
||||
apiVersion: '2024-10-28.acacia',
|
||||
});
|
||||
|
||||
const PRODUCT_ID = process.env.PRODUCT_ID!;
|
||||
const DRY_RUN = !!process.env.DRY_RUN;
|
||||
const SLEEP_MS = parseInt(process.env.SLEEP_MS ?? '50', 10);
|
||||
|
||||
const tiers = [
|
||||
// Monthly
|
||||
[10_000, 5_000_000],
|
||||
[50_000, 10_000_000],
|
||||
[100_000, 50_000_000],
|
||||
[120_000, 110_000_000],
|
||||
|
||||
// Yearly
|
||||
[120_000, 50_000_000],
|
||||
[600_000, 130_000_000],
|
||||
[1_200_000, 540_000_000],
|
||||
] as Array<[number, number]>;
|
||||
|
||||
if (!process.env.STRIPE_API_KEY || !process.env.PRODUCT_ID) {
|
||||
console.error('STRIPE_API_KEY manquant ou PRODUCT_ID manquant');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
type PriceMap = Map<string, { sourcePriceId: string; targetPriceId: string }>;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function loadPriceMapping([TIER_SRC, TIER_DST]: [
|
||||
number,
|
||||
number,
|
||||
]): Promise<PriceMap> {
|
||||
const { data: prices } = await stripe.prices.list({
|
||||
type: 'recurring',
|
||||
product: PRODUCT_ID,
|
||||
limit: 100,
|
||||
expand: ['data.tiers', 'data.recurring'],
|
||||
});
|
||||
|
||||
const byProduct = new Map<string, Stripe.Price[]>();
|
||||
|
||||
for (const p of prices) {
|
||||
if (p.recurring?.usage_type !== 'metered') continue;
|
||||
if (!Array.isArray(p.tiers)) continue;
|
||||
const arr = byProduct.get(p.product as string) ?? [];
|
||||
|
||||
arr.push(p);
|
||||
byProduct.set(p.product as string, arr);
|
||||
}
|
||||
|
||||
const mapByProduct: PriceMap = new Map();
|
||||
|
||||
for (const [productId, plist] of byProduct.entries()) {
|
||||
const src = plist.find((pr) => pr.tiers?.some((t) => t.up_to === TIER_SRC));
|
||||
const dst = plist.find((pr) => pr.tiers?.some((t) => t.up_to === TIER_DST));
|
||||
|
||||
if (src && dst) {
|
||||
mapByProduct.set(productId, {
|
||||
sourcePriceId: src.id,
|
||||
targetPriceId: dst.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mapByProduct;
|
||||
}
|
||||
|
||||
async function getCurrentPeriodTotalUsageMeters(params: {
|
||||
meterId: string;
|
||||
customerId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}): Promise<number> {
|
||||
const { meterId, customerId, start, end } = params;
|
||||
const res = await stripe.billing.meters.listEventSummaries(meterId, {
|
||||
customer: customerId,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
});
|
||||
|
||||
// Additionne aggregated_value si le meter regroupe par fenêtres
|
||||
return (res.data as Array<{ aggregated_value?: number }>).reduce(
|
||||
(s, x) => s + (x.aggregated_value ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function toCustomerId(
|
||||
cust: string | Stripe.Customer | Stripe.DeletedCustomer,
|
||||
): string {
|
||||
if (typeof cust === 'string') return cust;
|
||||
|
||||
return cust.id as string;
|
||||
}
|
||||
|
||||
async function main(tier: [number, number]) {
|
||||
const priceMap = await loadPriceMapping(tier);
|
||||
|
||||
if (priceMap.size === 0) {
|
||||
console.log(`Aucun mapping price ${tier[0]} -> ${tier[1]} trouvé`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let examined = 0;
|
||||
let modified = 0;
|
||||
let copiedTotal = 0;
|
||||
|
||||
for (const status of [
|
||||
'active',
|
||||
'trialing',
|
||||
] as Array<Stripe.SubscriptionListParams.Status>) {
|
||||
let starting_after: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const subs = await stripe.subscriptions.list({
|
||||
status,
|
||||
limit: 100,
|
||||
...(starting_after ? { starting_after } : {}),
|
||||
price: priceMap.get(PRODUCT_ID)?.sourcePriceId,
|
||||
expand: ['data.items.data.price'],
|
||||
});
|
||||
|
||||
for (const sub of subs.data) {
|
||||
examined++;
|
||||
const periodStart = sub.current_period_start as number;
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
const customerId = toCustomerId(sub.customer);
|
||||
|
||||
const targets = sub.items.data.filter((it) => {
|
||||
const m = priceMap.get(it.price.product as string);
|
||||
|
||||
return m && it.price.id === m.sourcePriceId;
|
||||
});
|
||||
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const item = targets[0];
|
||||
|
||||
const meterId = item.price.recurring?.meter;
|
||||
|
||||
if (!meterId) continue; // pas un meter moderne
|
||||
|
||||
const mapping = priceMap.get(item.price.product as string)!;
|
||||
|
||||
const total = await getCurrentPeriodTotalUsageMeters({
|
||||
meterId,
|
||||
customerId,
|
||||
start: periodStart,
|
||||
end: nowTs,
|
||||
});
|
||||
|
||||
let newItemId: string;
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(
|
||||
`[DRY_RUN] CREATE subscription_item price=${mapping.targetPriceId} subscription=${sub.id}`,
|
||||
);
|
||||
newItemId = `dry_${Date.now()}`;
|
||||
} else {
|
||||
const created = await stripe.subscriptionItems.create({
|
||||
subscription: sub.id,
|
||||
price: mapping.targetPriceId,
|
||||
...(item.quantity ? { quantity: item.quantity } : {}),
|
||||
});
|
||||
|
||||
newItemId = created.id;
|
||||
}
|
||||
await sleep(SLEEP_MS);
|
||||
|
||||
if (total > 0) {
|
||||
if (DRY_RUN) {
|
||||
console.log(
|
||||
`[DRY_RUN] USAGE new_si=${newItemId} usage=${total * 1000}}`,
|
||||
);
|
||||
copiedTotal += total;
|
||||
} else {
|
||||
await stripe.subscriptionItems.createUsageRecord(newItemId, {
|
||||
quantity: total * 1000,
|
||||
timestamp: nowTs,
|
||||
action: 'increment',
|
||||
});
|
||||
}
|
||||
copiedTotal += total;
|
||||
await sleep(SLEEP_MS);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(
|
||||
`[DRY_RUN] DELETE ${item.id}?clear_usage=true (total_usage=${total})`,
|
||||
);
|
||||
} else {
|
||||
await stripe.subscriptionItems.del(item.id, { clear_usage: true });
|
||||
}
|
||||
await sleep(SLEEP_MS);
|
||||
|
||||
modified++;
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
if (!subs.has_more) break;
|
||||
starting_after = subs.data[subs.data.length - 1].id;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`Terminé [${tier[0]} -> ${tier[1]}]. Subscriptions examinées: ${examined}. Modifiées: ${modified}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const tier of tiers) {
|
||||
main(tier).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// Usage: STRIPE_API_KEY=sk_live_xxx PRODUCT_ID=prod_xxx node update-stripe-prices.js
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import Stripe from 'stripe';
|
||||
|
||||
if (!process.env.STRIPE_API_KEY || !process.env.PRODUCT_ID) {
|
||||
console.error('Missing STRIPE_API_KEY or PRODUCT_ID');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||
apiVersion: '2024-10-28.acacia',
|
||||
});
|
||||
|
||||
const productId = process.env.PRODUCT_ID;
|
||||
const currency = 'usd';
|
||||
|
||||
const prices: Array<{
|
||||
interval: 'month' | 'year';
|
||||
usd: number;
|
||||
credits: number;
|
||||
pricePer1MAbove: number;
|
||||
}> = [
|
||||
// Monthly
|
||||
{ interval: 'month', usd: 0, credits: 5_000_000, pricePer1MAbove: 8.0 },
|
||||
{
|
||||
interval: 'month',
|
||||
usd: 29,
|
||||
credits: 10_000_000,
|
||||
pricePer1MAbove: 4.35,
|
||||
},
|
||||
{
|
||||
interval: 'month',
|
||||
usd: 99,
|
||||
credits: 50_000_000,
|
||||
pricePer1MAbove: 2.97,
|
||||
},
|
||||
{
|
||||
interval: 'month',
|
||||
usd: 199,
|
||||
credits: 110_000_000,
|
||||
pricePer1MAbove: 2.71,
|
||||
},
|
||||
{
|
||||
interval: 'month',
|
||||
usd: 399,
|
||||
credits: 240_000_000,
|
||||
pricePer1MAbove: 2.49,
|
||||
},
|
||||
{
|
||||
interval: 'month',
|
||||
usd: 999,
|
||||
credits: 700_000_000,
|
||||
pricePer1MAbove: 1.43,
|
||||
},
|
||||
// Yearly
|
||||
{ interval: 'year', usd: 0, credits: 50_000_000, pricePer1MAbove: 7.6 },
|
||||
{
|
||||
interval: 'year',
|
||||
usd: 290,
|
||||
credits: 130_000_000,
|
||||
pricePer1MAbove: 3.35,
|
||||
},
|
||||
{
|
||||
interval: 'year',
|
||||
usd: 990,
|
||||
credits: 540_000_000,
|
||||
pricePer1MAbove: 2.75,
|
||||
},
|
||||
{
|
||||
interval: 'year',
|
||||
usd: 1990,
|
||||
credits: 1_200_000_000,
|
||||
pricePer1MAbove: 2.49,
|
||||
},
|
||||
{
|
||||
interval: 'year',
|
||||
usd: 3990,
|
||||
credits: 2_600_000_000,
|
||||
pricePer1MAbove: 2.3,
|
||||
},
|
||||
{
|
||||
interval: 'year',
|
||||
usd: 9990,
|
||||
credits: 7_500_000_000,
|
||||
pricePer1MAbove: 1.33,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const toCents = (usd: number) => Math.round(usd * 100);
|
||||
const perCreditCentsDecimal = (pricePer1kUSD: number) =>
|
||||
((pricePer1kUSD * 100) / 1000000).toFixed(5);
|
||||
|
||||
const formatCredits = (credits: number) => credits.toLocaleString('de-DE'); // example: 7.500.000
|
||||
|
||||
const makeNickname = (price: (typeof prices)[0]) =>
|
||||
`${formatCredits(price.credits)} Credits`;
|
||||
|
||||
const makeIdemKey = (price: (typeof prices)[0], meter: Stripe.Billing.Meter) =>
|
||||
'price_' +
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
[
|
||||
price.interval,
|
||||
price.usd,
|
||||
price.credits,
|
||||
currency,
|
||||
price.pricePer1MAbove,
|
||||
productId,
|
||||
meter.id,
|
||||
].join('='),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
|
||||
const main = async () => {
|
||||
const createdNicknames = [];
|
||||
|
||||
const meter = (
|
||||
await stripe.billing.meters.list({ status: 'active' })
|
||||
).data.find((m) => m.event_name === 'WORKFLOW_NODE_RUN');
|
||||
|
||||
if (!meter) {
|
||||
throw new Error('Meter not found');
|
||||
}
|
||||
|
||||
for (const price of prices) {
|
||||
const flatAmountCents = toCents(price.usd);
|
||||
const idemKey = makeIdemKey(price, meter);
|
||||
const nickname = makeNickname(price);
|
||||
|
||||
createdNicknames.push(nickname);
|
||||
|
||||
const unitAmountPerCreditCentsDecimal = perCreditCentsDecimal(
|
||||
price.pricePer1MAbove,
|
||||
);
|
||||
|
||||
try {
|
||||
const priceCreated = await stripe.prices.create(
|
||||
{
|
||||
product: productId,
|
||||
currency,
|
||||
nickname,
|
||||
billing_scheme: 'tiered',
|
||||
tiers_mode: 'graduated',
|
||||
recurring: {
|
||||
interval: price.interval,
|
||||
usage_type: 'metered',
|
||||
meter: meter.id,
|
||||
},
|
||||
tiers: [
|
||||
{
|
||||
up_to: price.credits,
|
||||
flat_amount: flatAmountCents,
|
||||
},
|
||||
{
|
||||
up_to: 'inf',
|
||||
unit_amount_decimal: unitAmountPerCreditCentsDecimal,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ idempotencyKey: idemKey },
|
||||
);
|
||||
|
||||
console.log(`Created/exists: ${priceCreated.id} -> ${nickname}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed: ${nickname}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Archiving old prices ---');
|
||||
let hasMore = true;
|
||||
let startingAfter = undefined;
|
||||
|
||||
while (hasMore) {
|
||||
const { data, has_more }: { data: Array<Stripe.Price>; has_more: boolean } =
|
||||
await stripe.prices.list({
|
||||
product: productId,
|
||||
limit: 100,
|
||||
starting_after: startingAfter,
|
||||
});
|
||||
|
||||
for (const price of data) {
|
||||
if (price.nickname && !createdNicknames.includes(price.nickname)) {
|
||||
if (price.active) {
|
||||
await stripe.prices.update(price.id, { active: false });
|
||||
console.log(`Archived: ${price.id} (${price.nickname})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (has_more) {
|
||||
startingAfter = data[data.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
main();
|
||||
+6
-6
@@ -15,12 +15,12 @@ export class ActivateUnaccentExtension1758117800000
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE OR REPLACE FUNCTION public.unaccent_immutable(input text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT public.unaccent('public.unaccent'::regdictionary, input)
|
||||
$$;`,
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT public.unaccent('public.unaccent'::regdictionary, input)
|
||||
$$;`,
|
||||
);
|
||||
} catch {
|
||||
// Ignore error
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
-11
@@ -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,
|
||||
);
|
||||
|
||||
+2475
-631
File diff suppressed because it is too large
Load Diff
+119
-41
@@ -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(
|
||||
|
||||
+4
-17
@@ -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,
|
||||
|
||||
+11
-2
@@ -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: [
|
||||
{
|
||||
|
||||
+28
-12
@@ -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;
|
||||
|
||||
-26
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,9 @@ export class WorkspaceResolver {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
return this.billingSubscriptionService.getCurrentBillingSubscription({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => Number)
|
||||
|
||||
Reference in New Issue
Block a user