fix: free + trial, is now not activated after higher tier cancels

This commit is contained in:
John Yeo
2025-09-29 12:39:39 +01:00
parent 1103fd8ca2
commit 922242f78f
12 changed files with 241 additions and 222 deletions

View File

@@ -1,7 +1,7 @@
services:
valkey:
image: docker.io/bitnami/valkey:8.0
image: docker.io/valkey/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL

View File

@@ -1,7 +1,7 @@
services:
valkey:
image: docker.io/bitnami/valkey:8.0
image: docker.io/valkey/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL

View File

@@ -1,6 +1,6 @@
services:
valkey:
image: docker.io/bitnami/valkey:8.0
image: docker.io/valkey/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL

View File

@@ -1,15 +1,12 @@
import dotenv from "dotenv";
import {
CustomerEntitlement,
FullCusEntWithProduct,
ResetCusEnt,
} from "@autumn/shared";
import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { format } from "date-fns";
import { CronJob } from "cron";
import type { CustomerEntitlement, ResetCusEnt } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { initDrizzle } from "./db/initDrizzle.js";
import { CronJob } from "cron";
import { format } from "date-fns";
import dotenv from "dotenv";
import { resetCustomerEntitlement } from "./cron/cronUtils.js";
import { runProductCron } from "./cron/productCron/runProductCron.js";
import { initDrizzle } from "./db/initDrizzle.js";
import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { OrgService } from "./internal/orgs/OrgService.js";
import { notNullish } from "./utils/genUtils.js";
@@ -45,9 +42,9 @@ export const cronTask = async () => {
);
}
let results = await Promise.all(batchResets);
const results = await Promise.all(batchResets);
let toUpsert = results.filter(notNullish);
const toUpsert = results.filter(notNullish);
await CusEntService.upsert({
db,
data: toUpsert as CustomerEntitlement[],
@@ -68,17 +65,22 @@ export const cronTask = async () => {
// await client.end();
};
const job = new CronJob(
const main = async () => {
await Promise.all([
// cronTask(),
runProductCron(),
]);
};
new CronJob(
"* * * * *", // Run every minute
function () {
cronTask();
},
main,
null, // onComplete
true, // start immediately
"UTC", // timezone (adjust as needed)
);
cronTask();
main();
process.on("SIGTERM", async () => {
console.log("Received SIGTERM signal, closing database connection...");

View File

@@ -0,0 +1,67 @@
import { customerPrices, customerProducts, customers } from "@autumn/shared";
import { and, eq, gt, isNotNull, notExists, sql } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
export const runProductCron = async () => {
console.log("Running product cron");
// Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now
const results = await db
.select()
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
// No customer_prices exist for this customer_product
notExists(
db
.select()
.from(customerPrices)
.where(eq(customerPrices.customer_product_id, customerProducts.id)),
),
// trial_ends_at is not null
isNotNull(customerProducts.trial_ends_at),
// trial_ends_at > now (comparing epoch timestamps in milliseconds)
gt(
customerProducts.trial_ends_at,
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
),
),
);
console.log(
`Found ${results.length} customer products with no prices and active trials`,
);
const uniqueOrgIds = [...new Set(results.map((r) => r.customers.org_id))];
console.log("Unique org IDs:", uniqueOrgIds);
// for (const result of results) {
// console.log(
// `Customer ${result.customers.id}, Org: ${result.customers.org_id}, product: ${result.customer_products.product_id}`,
// );
// }
// const expireCusProduct = async (customerProduct: CustomerProduct) => {
// await CusProductService.update({
// db,
// cusProductId: customerProduct.id,
// updates: {
// status: CusProductStatus.Expired,
// },
// });
// };
// const batchSize = 50;
// for (let i = 0; i < results.length; i += batchSize) {
// const batch = results.slice(i, i + batchSize);
// const batchExpires = batch.map((cusProduct) =>
// expireCusProduct(cusProduct),
// );
// await Promise.all(batchExpires);
// console.log(`Expired batch of ${batch.length} customer products`);
// }
return results;
};

View File

@@ -1,26 +1,25 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
activateFutureProduct,
activateDefaultProduct,
cancelCusProductSubscriptions,
} from "@/internal/customers/cusProducts/cusProductUtils.js";
import { cusProductToPrices } from "@autumn/shared";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
FullCusProduct,
APIVersion,
AttachScenario,
BillingType,
CusProductStatus,
AttachScenario,
cusProductToPrices,
type FullCusProduct,
} from "@autumn/shared";
import Stripe from "stripe";
import { getCusPaymentMethod } from "../../stripeCusUtils.js";
import { webhookToAttachParams } from "../../webhookUtils/webhookUtils.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { createUsageInvoice } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
activateDefaultProduct,
activateFutureProduct,
} from "@/internal/customers/cusProducts/cusProductUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getCusPaymentMethod } from "../../stripeCusUtils.js";
import { webhookToAttachParams } from "../../webhookUtils/webhookUtils.js";
export const handleCusProductDeleted = async ({
req,
@@ -137,13 +136,13 @@ export const handleCusProductDeleted = async ({
return;
}
let cusProducts = await CusProductService.list({
const cusProducts = await CusProductService.list({
db,
internalCustomerId: cusProduct.customer!.internal_id,
internalCustomerId: cusProduct.internal_customer_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
let { curMainProduct } = getExistingCusProducts({
const { curMainProduct } = getExistingCusProducts({
product: cusProduct.product,
cusProducts,
});
@@ -154,12 +153,4 @@ export const handleCusProductDeleted = async ({
fullCus,
curCusProduct: curMainProduct || undefined,
});
// await cancelCusProductSubscriptions({
// cusProduct,
// org,
// env,
// excludeIds: [subscription.id],
// logger,
// });
};

View File

@@ -1,15 +1,17 @@
import Stripe from "stripe";
import { AttachScenario, CusProductStatus } from "@autumn/shared";
import { createStripeCli } from "../../utils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { activateFutureProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { cusProductToProduct } from "@autumn/shared";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import {
AttachScenario,
CusProductStatus,
cusProductToProduct,
} from "@autumn/shared";
import type Stripe from "stripe";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { activateFutureProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { createStripeCli } from "../../utils.js";
export const handleSchedulePhaseCompleted = async ({
req,
@@ -127,9 +129,4 @@ export const handleSchedulePhaseCompleted = async ({
);
}
}
// const currentPhase = schedule.phases.find(
// (phase) =>
// phase.start_date <= Math.floor(now / 1000) &&
// phase.end_date > Math.floor(now / 1000)
// );
};

View File

@@ -1,4 +1,5 @@
import {
ACTIVE_STATUSES,
APIVersion,
type AppEnv,
AttachScenario,
@@ -9,6 +10,7 @@ import {
type FixedPriceConfig,
type FullCusProduct,
type FullCustomer,
type FullProduct,
type Organization,
PriceType,
type Subscription,
@@ -31,7 +33,6 @@ import { notNullish, nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { newCusToAttachParams } from "../attach/attachUtils/attachParams/convertToParams.js";
import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js";
import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js";
import { CusProductService, RELEVANT_STATUSES } from "./CusProductService.js";
import { getRelatedCusEnt } from "./cusPrices/cusPriceUtils.js";
@@ -133,6 +134,7 @@ export const getDefaultProduct = async ({
return defaultProd;
};
// This function is only used in cancellation flows
export const activateDefaultProduct = async ({
req,
productGroup,
@@ -153,44 +155,38 @@ export const activateDefaultProduct = async ({
});
// Look for a paid default trial first, then fall back to free default
let defaultProd = defaultProducts.find(
const defaultProd: FullProduct | undefined = defaultProducts.find(
(p) =>
p.group === productGroup && isDefaultTrialFullProduct({ product: p }),
p.group === productGroup && !isDefaultTrialFullProduct({ product: p }),
);
const defaultableProducts = {
free: defaultProducts.filter(
(p) => p.group === productGroup && isFreeProduct(p.prices),
),
paid: defaultProducts.filter(
(p) =>
p.group === productGroup && isDefaultTrialFullProduct({ product: p }),
),
};
// const defaultableProducts = {
// free: defaultProducts.filter(
// (p) => p.group === productGroup && isFreeProduct(p.prices),
// ),
// paid: defaultProducts.filter(
// (p) =>
// p.group === productGroup && isDefaultTrialFullProduct({ product: p }),
// ),
// };
// console.log("Found defaultable products:", {
// free: defaultableProducts.free.map((p) => p.name),
// paid: defaultableProducts.paid.map((p) => p.name),
// });
// if (defaultableProducts.paid.length > 0) {
// defaultProd = defaultableProducts.paid[0];
// } else if (defaultableProducts.free.length > 0) {
// defaultProd = defaultableProducts.free[0];
// }
if (defaultableProducts.paid.length > 0) {
defaultProd = defaultableProducts.paid[0];
} else if (defaultableProducts.free.length > 0) {
defaultProd = defaultableProducts.free[0];
} else {
return false;
}
if (!defaultProd) return false;
if (curCusProduct?.internal_product_id == defaultProd.internal_id) {
if (curCusProduct?.internal_product_id === defaultProd.internal_id) {
return false;
}
const stripeCli = createStripeCli({ org, env });
const defaultIsFree = isFreeProduct(defaultProd.prices);
const isDefaultTrial = isDefaultTrialFullProduct({ product: defaultProd });
// Initialize Stripe customer and products if needed (for paid non-trial products)
if (!defaultIsFree && !isDefaultTrial) {
if (!defaultIsFree) {
await initStripeCusAndProducts({
db,
org,
@@ -201,83 +197,87 @@ export const activateDefaultProduct = async ({
});
}
if (!isDefaultTrial) {
const existingDefaultProduct = fullCus.customer_products.find(
(cp) =>
cp.product.internal_id === defaultProd!.internal_id &&
(cp.status === CusProductStatus.Active ||
cp.status === CusProductStatus.PastDue ||
cp.status === CusProductStatus.Trialing),
// If default is already active, skip
const existingDefaultProduct = fullCus.customer_products.find(
(cp) =>
cp.product.id === defaultProd?.id && ACTIVE_STATUSES.includes(cp.status),
);
if (existingDefaultProduct) {
logger.info(
`Default product ${defaultProd?.name} already exists for customer`,
);
if (existingDefaultProduct) {
logger.info(
`Default product ${defaultProd!.name} already exists for customer`,
);
return false;
}
await handleAddProduct({
req,
attachParams: newCusToAttachParams({
req,
newCus: fullCus,
products: [defaultProd],
stripeCli,
}),
});
// await createFullCusProduct({
// db,
// attachParams: {
// org,
// customer,
// product: defaultProd,
// prices: defaultProd.prices,
// entitlements: defaultProd.entitlements,
// freeTrial: defaultProd.free_trial || null,
// optionsList: [],
// entities: [],
// features: [],
// replaceables: [],
// },
// scenario: AttachScenario.New,
// logger,
// });
// console.log(` ✅ activated default product: ${defaultProd.group}`);
return true;
} else if (isDefaultTrial && defaultableProducts.free.length > 0) {
defaultProd = defaultableProducts.free[0];
// Check if the free default product already exists to prevent duplicates
const existingFreeProduct = fullCus.customer_products.find(
(cp) =>
cp.product.internal_id === defaultProd!.internal_id &&
(cp.status === CusProductStatus.Active ||
cp.status === CusProductStatus.PastDue),
);
if (existingFreeProduct) {
logger.info(
`Free default product ${defaultProd!.name} already exists for customer`,
);
return false;
}
await handleAddProduct({
req,
attachParams: newCusToAttachParams({
req,
newCus: fullCus,
products: [defaultProd],
stripeCli,
}),
config: getDefaultAttachConfig(),
});
return true;
return false;
}
await handleAddProduct({
req,
attachParams: newCusToAttachParams({
req,
newCus: fullCus,
products: [defaultProd],
stripeCli,
}),
});
return true;
// if (!isDefaultTrial) {
// const existingDefaultProduct = fullCus.customer_products.find(
// (cp) =>
// cp.product.internal_id === defaultProd?.internal_id &&
// ACTIVE_STATUSES.includes(cp.status),
// );
// if (existingDefaultProduct) {
// logger.info(
// `Default product ${defaultProd?.name} already exists for customer`,
// );
// return false;
// }
// await handleAddProduct({
// req,
// attachParams: newCusToAttachParams({
// req,
// newCus: fullCus,
// products: [defaultProd],
// stripeCli,
// }),
// });
// return true;
// } else if (isDefaultTrial && defaultableProducts.free.length > 0) {
// defaultProd = defaultableProducts.free[0];
// // Check if the free default product already exists to prevent duplicates
// const existingFreeProduct = fullCus.customer_products.find(
// (cp) =>
// cp.product.internal_id === defaultProd!.internal_id &&
// (cp.status === CusProductStatus.Active ||
// cp.status === CusProductStatus.PastDue),
// );
// if (existingFreeProduct) {
// logger.info(
// `Free default product ${defaultProd?.name} already exists for customer`,
// );
// return false;
// }
// await handleAddProduct({
// req,
// attachParams: newCusToAttachParams({
// req,
// newCus: fullCus,
// products: [defaultProd],
// stripeCli,
// }),
// config: getDefaultAttachConfig(),
// });
// return true;
// }
};
export const expireAndActivate = async ({

View File

@@ -196,51 +196,5 @@ export const createNewCustomer = async ({
}
}
// if (nonFreeProds.length > 0) {
// const stripeCli = createStripeCli({ org, env });
// await initStripeCusAndProducts({
// db,
// org,
// env,
// customer: newCustomer,
// products: nonFreeProds,
// logger,
// });
// await handleAddProduct({
// req,
// attachParams: newCusToAttachParams({
// req,
// newCus: newCustomer as FullCustomer,
// products: nonFreeProds,
// stripeCli,
// freeTrial: defaultPaidTrialProd?.free_trial || null,
// }),
// });
// }
// if (!defaultPaidTrialProd) {
// for (const product of freeProds) {
// await createFullCusProduct({
// db,
// attachParams: newCusToInsertParams({
// req,
// newCus: newCustomer,
// product,
// }),
// nextResetAt,
// anchorToUnix: org.config.anchor_start_of_month
// ? getNextStartOfMonthUnix({
// interval: BillingInterval.Month,
// intervalCount: 1,
// })
// : undefined,
// scenario: AttachScenario.New,
// logger,
// });
// }
// }
return newCustomer;
};

View File

@@ -1,4 +1,4 @@
import { FullProduct, Price, ProductV2 } from "@autumn/shared";
import type { FullProduct, Price, ProductV2 } from "@autumn/shared";
import { pricesOnlyOneOff } from "../prices/priceUtils.js";
import { isFeatureItem } from "../product-items/productItemUtils/getItemType.js";
import { isFreeProduct } from "../productUtils.js";
@@ -57,6 +57,9 @@ export const isDefaultTrialFullProduct = ({
product: FullProduct;
skipDefault?: boolean;
}) => {
// If it's free + trial, also consider it default trial
if (isFreeProduct(product.prices) && product.free_trial) return true;
return (
product.free_trial &&
!product.free_trial?.card_required &&

View File

@@ -7,6 +7,7 @@ import {
import type { Autumn } from "autumn-js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import {
@@ -14,7 +15,6 @@ import {
createStripeCustomer,
} from "../../external/stripe/stripeCusUtils.js";
import { createStripeCli } from "../../external/stripe/utils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
export const createCusInStripe = async ({
customer,
@@ -188,7 +188,7 @@ export const initCustomerV2 = async ({
attachPm,
withTestClock = true,
}: {
autumn: Autumn;
autumn: Autumn | AutumnInt;
customerId: string;
org: Organization;
env: AppEnv;
@@ -201,7 +201,7 @@ export const initCustomerV2 = async ({
const fingerprint_ = "";
const stripeCli = createStripeCli({ org, env });
let testClockId;
let testClockId: string | undefined;
if (withTestClock) {
const testClock = await stripeCli.testHelpers.testClocks.create({
@@ -220,7 +220,8 @@ export const initCustomerV2 = async ({
// 2. Create customer
try {
await autumn.customers.delete(customerId);
} catch (error) {}
} catch (_error) {}
await autumn.customers.create({
id: customerId,
name,

View File

@@ -1,8 +1,10 @@
import "dotenv/config";
import { timeout } from "./genUtils.js";
import { Stripe } from "stripe";
import { BillingInterval, Customer, FullProduct } from "@autumn/shared";
import {
BillingInterval,
type Customer,
type FullProduct,
} from "@autumn/shared";
import {
addDays,
addHours,
@@ -12,9 +14,11 @@ import {
format,
} from "date-fns";
import puppeteer from "puppeteer-core";
import Browserbase from "@browserbasehq/sdk";
import type { Stripe } from "stripe";
import { timeout } from "./genUtils.js";
const STRIPE_TEST_CLOCK_TIMING = 20000; // 30s
import { Hyperbrowser } from "@hyperbrowser/sdk";
const client = new Hyperbrowser({
@@ -186,7 +190,7 @@ export const deleteStripeProduct = async ({
if (product.processor) {
// console.log("Stripe product", stripeProd.active, stripeProd.id);
let stripeProdId = product.processor.id;
const stripeProdId = product.processor.id;
try {
await stripeCli.products.del(stripeProdId);
} catch (error) {
@@ -260,7 +264,7 @@ export const advanceTestClock = async ({
}
console.log(" - Advancing to: ", format(advanceTo, "dd MMM yyyy HH:mm:ss"));
await stripeCli.testHelpers.testClocks.advance(testClockId, {
const res = await stripeCli.testHelpers.testClocks.advance(testClockId, {
frozen_time: Math.floor(advanceTo / 1000),
});
@@ -384,7 +388,7 @@ export const checkBillingMeterEventSummary = async ({
stripeMeterId: string;
stripeCustomerId: string;
}) => {
let endTime = addMonths(startTime, 1);
const endTime = addMonths(startTime, 1);
const event = await stripeCli.billing.meters.listEventSummaries(
stripeMeterId,
{