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
@@ -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();