diff --git a/server/src/check.ts b/server/src/check.ts index f2588162c..74caa4c22 100644 --- a/server/src/check.ts +++ b/server/src/check.ts @@ -1,485 +1,414 @@ -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 }); - -let orgSlugs = process.env.ORG_SLUGS!.split(","); -const skipEmails = process.env.SKIP_EMAILS!.split(","); -const skipIds = [ - "cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx", - "DxG668K7uDd0Vahk54YWjvCGVgf2", -]; - -orgSlugs = ["nithiiyan_skhanthan_52620936"]; -// let customerId = null; -const customerId = null; - -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` -// ); +// 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 }); + +// let orgSlugs = process.env.ORG_SLUGS!.split(","); +// const skipEmails = process.env.SKIP_EMAILS!.split(","); +// const skipIds = [ +// "cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx", +// "DxG668K7uDd0Vahk54YWjvCGVgf2", +// ]; + +// orgSlugs = ["athenahq"]; +// // let customerId = null; +// const customerId = null; + +// 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); +// }); + +import { initInfisical } from "./external/infisical/initInfisical.js"; + +await initInfisical(); + +await import("./scan/runScan.js"); diff --git a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts index 1ec7f5f6c..1e11dfc33 100644 --- a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts +++ b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts @@ -10,6 +10,7 @@ import { UTCDate } from "@date-fns/utc"; import { applyTrialToEntitlement } from "@/internal/products/entitlements/entitlementUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getNextEntitlementReset } from "@/utils/timeUtils.js"; +import { formatUnixToDateTime } from "../../../../../utils/genUtils.js"; import { getAlignedUnix } from "../../../../products/prices/billingIntervalUtils2.js"; export const initNextResetAt = ({ @@ -53,8 +54,9 @@ export const initNextResetAt = ({ // "Trial end timestamp: ", // formatUnixToDateTime(trialEndTimestamp! * 1000), // ); - // console.log("Should apply trial: ", shouldApplyTrial); - // console.log("Anchor to unix: ", formatUnixToDateTime(anchorToUnix!)); + console.log("Should apply trial: ", shouldApplyTrial); + console.log("Anchor to unix: ", anchorToUnix); + console.log("Anchor to unix formatted: ", formatUnixToDateTime(anchorToUnix)); if (freeTrial && shouldApplyTrial && trialEndTimestamp) { nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000); diff --git a/server/src/internal/products/entitlements/copyEntUtils.ts b/server/src/internal/products/entitlements/copyEntUtils.ts new file mode 100644 index 000000000..95040e2b4 --- /dev/null +++ b/server/src/internal/products/entitlements/copyEntUtils.ts @@ -0,0 +1,33 @@ +import type { Entitlement } from "@autumn/shared"; +import { entitlements } from "@models/productModels/entModels/entTable"; +import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { generateId } from "../../../utils/genUtils"; + +export const copyEnt = async ({ + db, + entId, + isCustom = true, + internalProductId, +}: { + db: DrizzleCli; + entId: string; + isCustom?: boolean; + internalProductId?: string; +}) => { + const ent = await db.query.entitlements.findFirst({ + where: eq(entitlements.id, entId), + }); + + let newEnt = structuredClone(ent!) as Entitlement; + + newEnt = { + ...newEnt, + id: generateId("ent"), + created_at: Date.now(), + is_custom: isCustom ?? newEnt.is_custom, + internal_product_id: internalProductId || newEnt.internal_product_id, + }; + + return newEnt; +}; diff --git a/server/src/scan/runScan.ts b/server/src/scan/runScan.ts new file mode 100644 index 000000000..adb6507c4 --- /dev/null +++ b/server/src/scan/runScan.ts @@ -0,0 +1,405 @@ +import "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 { EntityService } from "@/internal/api/entities/EntityService.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; +import { checkCusSubCorrect } from "@/utils/checkUtils/checkCustomerCorrect.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"; + +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 = ["athenahq"]; +const customerId = null; + +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); + });