Files
cfw-autumn/server/src/check.ts
2025-10-17 13:38:35 +01:00

486 lines
12 KiB
TypeScript

import { config } from "dotenv";
config();
import assert from "node:assert";
import {
AppEnv,
CusProductStatus,
cusProductToPrices,
type Entity,
type FullCusProduct,
type FullCustomer,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
getAllEntities,
getAllFullCustomers,
} from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
import {
getAllStripeSchedules,
getAllStripeSubscriptions,
} from "@/utils/scriptUtils/getAll/getAllStripeSubs.js";
import { EntityService } from "./internal/api/entities/EntityService.js";
import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
const { db } = initDrizzle({ maxConnections: 5 });
const orgSlugs = process.env.ORG_SLUGS!.split(",");
const skipEmails = process.env.SKIP_EMAILS!.split(",");
const skipIds = [
"cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
"DxG668K7uDd0Vahk54YWjvCGVgf2",
];
// orgSlugs = ["lumenary"];
const customerId = null;
// customerId = "EBbxiRv9QJKXFy5WiAKeHi";
const getSingleCustomer = async ({
stripeCli,
customerId,
orgId,
env,
}: {
stripeCli: Stripe;
customerId: string;
orgId: string;
env: AppEnv;
}) => {
const customers = [
await CusService.getFull({
db,
idOrInternalId: customerId,
orgId,
env,
}),
];
const stripeCusId = customers[0].processor?.id;
const stripeSubs = stripeCusId
? (
await stripeCli.subscriptions.list({
customer: stripeCusId,
expand: ["data.discounts.coupon"],
})
).data
: [];
// const stripeSubs = await getStripeSubs({
// stripeCli,
// subIds: customers[0].customer_products.flatMap(
// (cp) => cp.subscription_ids || []
// ),
// });
let scheduleIds = customers[0].customer_products.flatMap(
(cp) => cp.scheduled_ids || [],
);
scheduleIds = Array.from(new Set(scheduleIds));
const stripeSchedules = await getStripeSchedules({
stripeCli,
scheduleIds,
});
const entities = await EntityService.list({
db,
internalCustomerId: customers[0].internal_id,
});
return { customers, stripeSubs, stripeSchedules, entities };
};
const checkCustomerCorrect = async ({
fullCus,
subs,
schedules,
org,
entities,
}: {
fullCus: FullCustomer;
subs: Stripe.Subscription[];
schedules: Stripe.SubscriptionSchedule[];
org: Organization;
entities: Entity[];
}) => {
if (skipIds.includes(fullCus.internal_id!)) return;
if (skipEmails.some((skipEmail) => skipEmail === fullCus.email)) {
return;
}
fullCus.entities = entities.filter(
(entity) => entity.internal_customer_id === fullCus.internal_id,
);
// console.log(`Checking ${fullCus.email} (${fullCus.id})`);
const cusProducts = fullCus.customer_products;
await checkCusSubCorrect({
db,
fullCus,
subs,
schedules,
org,
env: AppEnv.Live,
});
for (const cusProduct of cusProducts) {
if (!cusProduct.subscription_ids) continue;
if (cusProduct.status === CusProductStatus.Scheduled) {
// Check if there's a main product elsewhere
const mainCusProd = cusProducts.find(
(cp: FullCusProduct) =>
cp.product.group === cusProduct.product.group &&
cp.id !== cusProduct.id &&
cp.status !== CusProductStatus.Scheduled &&
(cusProduct.internal_entity_id
? cusProduct.internal_entity_id === cp.internal_entity_id
: true),
);
assert(
mainCusProd,
`Found scheduled cus product with no main product (${cusProduct.product.name})`,
);
}
if (
!cusProduct.product.is_add_on &&
cusProduct.status !== CusProductStatus.Scheduled
) {
const group = cusProduct.product.group;
const otherCusProd = cusProducts.find(
(cp: FullCusProduct) =>
cp.product.group === group &&
cp.id !== cusProduct.id &&
!cp.product.is_add_on &&
cp.status !== CusProductStatus.Scheduled &&
cp.internal_entity_id === cusProduct.internal_entity_id,
);
assert(
!otherCusProd,
`found two cus products from the same group: ${otherCusProd?.product.name} and ${cusProduct.product.name}`,
);
}
const stripeSubs = subs.filter((sub: any) =>
cusProduct.subscription_ids!.some((id: string) => id === sub.id),
);
assert(
stripeSubs.length === cusProduct.subscription_ids!.length,
"number of stripe subs should be the same as number of subscription ids",
);
// let subItems = stripeSubs.flatMap((sub: any) => sub.items.data);
const prices = cusProductToPrices({ cusProduct });
if (
isOneOff(prices) ||
isFreeProduct(prices) ||
cusProduct.status === CusProductStatus.Scheduled
) {
continue;
}
for (const cusEnt of cusProduct.customer_entitlements) {
const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
if (cusEnt.usage_allowed && !cusPrice) {
assert.fail(
`Feature ${cusEnt.feature_id} has usage allowed but no related cus price`,
);
}
}
}
// Other checks to perform
};
const checkCustomerHandleError = async ({
fullCus,
subs,
org,
schedules,
entities,
}: {
fullCus: FullCustomer;
subs: Stripe.Subscription[];
org: Organization;
schedules: Stripe.SubscriptionSchedule[];
entities: Entity[];
}) => {
try {
await checkCustomerCorrect({
fullCus,
subs,
org,
schedules,
entities,
});
return undefined;
} catch (error: any) {
return {
id: fullCus.id,
name: fullCus.name,
email: fullCus.email,
error: error.message,
};
}
};
export const check = async () => {
const env = AppEnv.Live;
const sb = createSupabaseClient();
const today = new Date().toISOString().slice(0, 16);
for (const slug of orgSlugs) {
const org = await OrgService.getBySlug({
db,
slug,
});
if (!org) {
console.log(`Org ${slug} not found`);
continue;
}
const fileName = `errors/${today}-${org.slug}.json`;
const stripeCli = createStripeCli({
org,
env,
});
console.log("--------------------------------");
console.log(`Running error check for ${org.name}`);
let customers: FullCustomer[] = [];
let stripeSubs: Stripe.Subscription[] = [];
let stripeSchedules: Stripe.SubscriptionSchedule[] = [];
let entities: Entity[] = [];
if (customerId) {
const res = await getSingleCustomer({
stripeCli,
customerId,
orgId: org.id,
env,
});
customers = res.customers;
stripeSubs = res.stripeSubs;
entities = res.entities;
} else {
const [customersRes, stripeSubsRes, stripeSchedulesRes, entitiesRes] =
await Promise.all([
getAllFullCustomers({
db,
orgId: org.id,
env,
}),
getAllStripeSubscriptions({
stripeCli,
waitForSeconds: 1,
}),
getAllStripeSchedules({
stripeCli,
waitForSeconds: 1,
}),
getAllEntities({
db,
orgId: org.id,
env,
}),
]);
customers = customersRes;
stripeSubs = stripeSubsRes.subscriptions;
stripeSchedules = stripeSchedulesRes.schedules;
entities = entitiesRes;
}
const batchSize = 1;
const allErrors = [];
for (let i = 0; i < customers.length; i += batchSize) {
const batch = customers.slice(i, i + batchSize);
const batchCheck: any = [];
for (const customer of batch) {
batchCheck.push(
checkCustomerHandleError({
fullCus: customer,
subs: stripeSubs,
schedules: stripeSchedules,
org,
entities,
}),
);
}
let results = await Promise.all(batchCheck);
results = results.filter(notNullish);
allErrors.push(...results);
}
console.log(`Found ${allErrors.length} errors`);
if (allErrors.length > 0 && customers.length > 1) {
await sb.storage
.from("autumn")
.upload(fileName, JSON.stringify(allErrors, null, 2));
if (allErrors.length > 0) {
const slackBody = {
text: `Error check for ${org.name}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Error check for ${org.name}*: found ${allErrors.length} errors\nSee results at ${process.env.SUPABASE_URL}/storage/v1/object/public/autumn/${fileName}`,
},
},
],
};
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
body: JSON.stringify(slackBody),
});
}
} else {
console.log(allErrors);
}
}
console.log(
`COMPLETED ERROR CHECK FOR ${new Date().toISOString().slice(0, 16)}`,
);
if (process.env.NODE_ENV === "production") {
const slackBody = {
text: `Completed error check for ${new Date().toISOString().slice(0, 16)}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `Error check completed for ${new Date().toISOString().slice(0, 16)}`,
},
},
],
};
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
body: JSON.stringify(slackBody),
});
}
};
check()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => {
process.exit(0);
});
// let missingUsageCount = 0;
// for (const price of prices) {
// const subItem = findStripeItemForPrice({
// stripeItems: subItems,
// price,
// stripeProdId: cusProduct.product.processor?.id,
// });
// let billingType = getBillingType(price.config);
// if (
// billingType == BillingType.UsageInAdvance &&
// price.config.interval == BillingInterval.OneOff
// ) {
// missingUsageCount++;
// continue;
// }
// if (
// billingType == BillingType.UsageInAdvance &&
// price.config.interval != BillingInterval.OneOff
// ) {
// const featureId = (price.config as any).feature_id;
// const options = cusProduct.options.find(
// (o) => o.feature_id == featureId
// );
// assert(
// notNullish(options),
// `options should exist for prepaid price (featureId: ${featureId})`
// );
// let expectedQuantity = options?.upcoming_quantity || options?.quantity;
// // console.log("Sub item: ", subItem);
// assert(
// subItem?.quantity == expectedQuantity,
// `sub item quantity for prepaid price (featureId: ${featureId}) should be ${expectedQuantity}`
// );
// continue;
// }
// if (isV4Usage({ price, cusProduct })) {
// if (nullish(subItem)) {
// missingUsageCount++;
// } else {
// const priceName =
// (price.config as any).feature_id || price.config.interval;
// assert(
// nullish(subItem) ||
// (subItem?.quantity === 0 &&
// isLicenseItem({
// stripeItem: subItem as Stripe.SubscriptionItem,
// })),
// `(${cusProduct.product.name}) sub item for price: ${priceName} should exist`
// );
// }
// continue;
// } else {
// let priceName =
// (price.config as any).feature_id || price.config.interval;
// // console.log("Stripe price ID:", price.config.stripe_price_id);
// // console.log("Sub items:", subItems);
// assert(
// subItem,
// `(${cusProduct.product.name}) sub item for price: ${priceName} should exist`
// );
// }
// }
// assert(
// prices.length - missingUsageCount === subItems.length,
// `(${cusProduct.product.name}) number of sub items equivalent to number of prices`
// );