fix: default toggle

This commit is contained in:
John Yeo
2025-08-16 12:51:55 -07:00
parent c7852f6760
commit 1dd0ee9e8a
33 changed files with 2169 additions and 1997 deletions

View File

@@ -19,5 +19,3 @@ $MOCHA_CMD \
'tests/attach/checkout/*.ts' \
'tests/attach/entities/*.ts' \
'tests/attach/free/*.ts'\
# 'tests/attach/basic/basic2.ts' \

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { addCustomerCreatedTask } from "@/internal/analytics/handlers/handleCustomerCreated.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
@@ -7,15 +6,15 @@ import { isFreeProduct } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
Organization,
CreateCustomer,
CreateCustomerSchema,
ErrCode,
BillingInterval,
AttachScenario,
FullCustomer,
CreateCustomer,
CreateCustomerSchema,
ErrCode,
BillingInterval,
AttachScenario,
FullCustomer,
FullProduct,
} from "@autumn/shared";
import { AppEnv, Customer } from "@autumn/shared";
import { Customer } from "@autumn/shared";
import { createFullCusProduct } from "../add-product/createFullCusProduct.js";
import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { CusService } from "../CusService.js";
@@ -23,148 +22,216 @@ import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js";
import { generateId } from "@/utils/genUtils.js";
import {
newCusToAttachParams,
newCusToInsertParams,
newCusToAttachParams,
newCusToInsertParams,
} from "../attach/attachUtils/attachParams/convertToParams.js";
import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js";
export const createNewCustomer = async ({
req,
customer,
nextResetAt,
createDefaultProducts = true,
export const getGroupToDefaultProd = async ({
defaultProds,
}: {
req: ExtendedRequest;
customer: CreateCustomer;
nextResetAt?: number;
createDefaultProducts?: boolean;
defaultProds: FullProduct[];
}) => {
const { db, org, env, logger } = req;
const groups = new Set(defaultProds.map((p) => p.group));
const groupToDefaultProd: Record<string, FullProduct> = {};
logger.info(
`Creating customer: ${customer.email || customer.id}, org: ${org.slug}`
);
for (const group of groups) {
const defaultProdsInGroup = defaultProds.filter((p) => p.group === group);
const defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
if (defaultProdsInGroup.length === 0) continue;
const nonFreeProds = defaultProds.filter((p) => !isFreeProduct(p.prices));
const freeProds = defaultProds.filter((p) => isFreeProduct(p.prices));
const defaultPaidTrialProd = nonFreeProds.find((p) =>
isDefaultTrialFullProduct({ product: p })
);
defaultProdsInGroup.sort((a, b) => {
// 1. If a is default trial, go first
if (isDefaultTrialFullProduct({ product: a })) return -1;
const parsedCustomer = CreateCustomerSchema.parse(customer);
if (!isFreeProduct(a.prices)) return -1;
const customerData: Customer = {
...parsedCustomer,
return 0;
});
name: parsedCustomer.name || "",
email:
nonFreeProds.length > 0 && !parsedCustomer.email
? `${parsedCustomer.id}-${org.id}@invoices.useautumn.com`
: parsedCustomer.email || "",
groupToDefaultProd[group] = defaultProdsInGroup[0];
}
metadata: parsedCustomer.metadata || {},
internal_id: generateId("cus"),
org_id: org.id,
created_at: Date.now(),
env,
processor: parsedCustomer.stripe_id
? {
id: parsedCustomer.stripe_id,
type: "stripe",
}
: undefined,
};
// Check if stripeCli exists
if (nonFreeProds.length > 0) {
createStripeCli({
org,
env,
});
if (!customerData?.email) {
throw new RecaseError({
code: ErrCode.InvalidRequest,
message:
"Customer email is required to attach default product with prices",
});
}
}
const newCustomer = await CusService.insert({
db,
data: customerData,
});
if (!newCustomer) {
throw new RecaseError({
code: ErrCode.InternalError,
message: "CusService.insert returned null",
});
}
if (!createDefaultProducts) {
return newCustomer;
}
await addCustomerCreatedTask({
req,
internalCustomerId: newCustomer.internal_id,
org,
env,
});
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;
return groupToDefaultProd;
};
export const createNewCustomer = async ({
req,
customer,
nextResetAt,
createDefaultProducts = true,
}: {
req: ExtendedRequest;
customer: CreateCustomer;
nextResetAt?: number;
createDefaultProducts?: boolean;
}) => {
const { db, org, env, logger } = req;
logger.info(
`Creating customer: ${customer.email || customer.id}, org: ${org.slug}`
);
const defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
const nonFreeProds = defaultProds.filter((p) => !isFreeProduct(p.prices));
// const freeProds = defaultProds.filter((p) => isFreeProduct(p.prices));
// const defaultPaidTrialProd = nonFreeProds.find((p) =>
// isDefaultTrialFullProduct({ product: p })
// );
const parsedCustomer = CreateCustomerSchema.parse(customer);
const customerData: Customer = {
...parsedCustomer,
name: parsedCustomer.name || "",
email:
nonFreeProds.length > 0 && !parsedCustomer.email
? `${parsedCustomer.id}-${org.id}@invoices.useautumn.com`
: parsedCustomer.email || "",
metadata: parsedCustomer.metadata || {},
internal_id: generateId("cus"),
org_id: org.id,
created_at: Date.now(),
env,
processor: parsedCustomer.stripe_id
? {
id: parsedCustomer.stripe_id,
type: "stripe",
}
: undefined,
};
// Check if stripeCli exists
if (nonFreeProds.length > 0) {
createStripeCli({ org, env });
}
const newCustomer = await CusService.insert({
db,
data: customerData,
});
if (!newCustomer) {
throw new RecaseError({
code: ErrCode.InternalError,
message: "CusService.insert returned null",
});
}
if (!createDefaultProducts) {
return newCustomer;
}
await addCustomerCreatedTask({
req,
internalCustomerId: newCustomer.internal_id,
org,
env,
});
const groupToDefaultProd = await getGroupToDefaultProd({
defaultProds,
});
for (const group in groupToDefaultProd) {
const defaultProd = groupToDefaultProd[group];
if (!isFreeProduct(defaultProd.prices)) {
let stripeCli = null;
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: [defaultProd],
stripeCli,
freeTrial: defaultProd.free_trial || null,
}),
});
} else {
await createFullCusProduct({
db,
attachParams: newCusToInsertParams({
req,
newCus: newCustomer,
product: defaultProd,
}),
nextResetAt,
anchorToUnix: org.config.anchor_start_of_month
? getNextStartOfMonthUnix({
interval: BillingInterval.Month,
intervalCount: 1,
})
: undefined,
scenario: AttachScenario.New,
logger,
});
}
}
// 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

@@ -185,7 +185,6 @@ export const handleCreateCustomer = async ({
createDefaultProducts?: boolean;
}) => {
const newCus = CreateCustomerSchema.parse(cusData);
console.log("newCus", newCus);
// 1. If no ID and email is not NULL
let createdCustomer;

View File

@@ -115,16 +115,19 @@ export class ProductService {
db,
orgId,
env,
group,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
group?: string;
}) {
let prods = (await db.query.products.findMany({
where: and(
eq(products.org_id, orgId),
eq(products.env, env),
eq(products.is_default, true)
eq(products.is_default, true),
group ? eq(products.group, group) : undefined
),
with: {
entitlements: {

View File

@@ -13,7 +13,10 @@ import {
FreeTrial,
FullProduct,
Price,
Product,
ProductItem,
ProductResponseSchema,
ProductV2,
} from "@autumn/shared";
import {
keyToTitle,
@@ -25,13 +28,15 @@ import {
import { ProductService } from "@/internal/products/ProductService.js";
import {
constructProduct,
getGroupToDefaults,
initProductInStripe,
} from "@/internal/products/productUtils.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { detectBaseVariant } from "../productUtils/detectProductVariant.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { isDefaultTrial } from "../productUtils/classifyProduct.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => {
let { free_trial, items } = req.body;
@@ -95,6 +100,67 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => {
};
};
export const disableCurrentDefault = async ({
req,
newProduct,
items,
freeTrial,
}: {
req: ExtendedRequest;
newProduct: Product;
items: ProductItem[];
freeTrial: FreeTrial | null;
}) => {
const { db, org, env, logger } = req;
let defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
defaultProds = defaultProds.filter((prod) => prod.id !== newProduct.id);
if (defaultProds.length === 0) return;
const defaults = getGroupToDefaults({
defaultProds,
})?.[newProduct.group];
const willBeDefaultTrial = isDefaultTrial({
product: {
...newProduct,
free_trial: freeTrial,
items: items || [],
},
});
if (willBeDefaultTrial) {
// Disable current default trial
const curDefault = defaults?.defaultTrial;
if (curDefault) {
logger.info(
`Disabling trial on cur default trial product: ${curDefault.id}`
);
await ProductService.updateByInternalId({
db,
internalId: curDefault.internal_id,
update: { is_default: false },
});
}
} else if (newProduct.is_default) {
const curDefault = defaults?.free;
if (curDefault) {
logger.info(`Disabling trial on cur default product: ${curDefault.id}`);
await ProductService.updateByInternalId({
db,
internalId: curDefault.internal_id,
update: { is_default: false },
});
}
}
};
export const handleCreateProduct = async (req: Request, res: any) =>
routeHandler({
req,
@@ -114,6 +180,13 @@ export const handleCreateProduct = async (req: Request, res: any) =>
env,
});
await disableCurrentDefault({
req,
newProduct,
items,
freeTrial: freeTrial || null,
});
let product = await ProductService.insert({ db, product: newProduct });
let prices: Price[] = [];

View File

@@ -17,7 +17,11 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { initProductInStripe } from "../../productUtils.js";
import { handleCreateProduct } from "../handleCreateProduct.js";
import {
disableCurrentDefault,
handleCreateProduct,
} from "../handleCreateProduct.js";
import { mapToProductItems } from "../../productV2Utils.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
@@ -29,27 +33,32 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms] = await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert == "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
]);
const [features, org, fullProduct, rewardPrograms, defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert == "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
ProductService.listDefault({
db,
orgId,
env,
}),
]);
if (!fullProduct) {
console.log("Upserting:", upsert);
if (upsert == "true") {
await handleCreateProduct(req, res);
return;
@@ -70,10 +79,31 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
let cusProductExists = cusProductsCurVersion.length > 0;
// console.log("Updating product", {
// id: fullProduct.id,
// body: req.body,
// });
await disableCurrentDefault({
req,
newProduct: {
...fullProduct,
...req.body,
},
items:
req.body.items ||
mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features,
}),
freeTrial: req.body.free_trial || fullProduct.free_trial || null,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(req.body),
newFreeTrial: req.body.free_trial,
items: req.body.items,
org,
rewardPrograms,
@@ -81,7 +111,6 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
});
let itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version == "true") {
throw new RecaseError({

View File

@@ -3,235 +3,281 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
AppEnv,
ErrCode,
FullProduct,
Organization,
Product,
ProductItem,
RewardProgram,
UpdateProduct,
AppEnv,
CreateFreeTrial,
ErrCode,
FreeTrial,
FullProduct,
isFreeProductV2,
Organization,
Product,
ProductItem,
RewardProgram,
UpdateProduct,
} from "@autumn/shared";
import { ProductService } from "../../ProductService.js";
import { FreeTrialService } from "../../free-trials/FreeTrialService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js";
import {
isFeaturePriceItem,
isPriceItem,
isFeaturePriceItem,
isPriceItem,
} from "../../product-items/productItemUtils/getItemType.js";
import { isFreeProduct } from "../../productUtils.js";
import { isStripeConnected } from "@/internal/orgs/orgUtils.js";
import { isDefaultTrialFullProduct } from "../../productUtils/classifyProduct.js";
const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => {
if (notNullish(prod2.id) && prod1.id != prod2.id) {
return false;
}
if (notNullish(prod2.id) && prod1.id != prod2.id) {
return false;
}
if (notNullish(prod2.name) && prod1.name != prod2.name) {
return false;
}
if (notNullish(prod2.name) && prod1.name != prod2.name) {
return false;
}
if (notNullish(prod2.group) && prod1.group != prod2.group) {
return false;
}
if (notNullish(prod2.group) && prod1.group != prod2.group) {
return false;
}
if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) {
return false;
}
if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) {
return false;
}
if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) {
return false;
}
if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) {
return false;
}
if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) {
return false;
}
if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) {
return false;
}
return true;
return true;
};
const updateStripeProductNames = async ({
db,
org,
curProduct,
newName,
logger,
db,
org,
curProduct,
newName,
logger,
}: {
db: DrizzleCli;
org: Organization;
curProduct: FullProduct;
newName: string;
logger: any;
db: DrizzleCli;
org: Organization;
curProduct: FullProduct;
newName: string;
logger: any;
}) => {
if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return;
if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return;
const stripeCli = createStripeCli({
org,
env: curProduct.env as AppEnv,
});
let stripeProdId = curProduct.processor?.id;
const stripeCli = createStripeCli({
org,
env: curProduct.env as AppEnv,
});
let stripeProdId = curProduct.processor?.id;
if (!stripeProdId || !newName) {
return;
}
if (!stripeProdId || !newName) {
return;
}
try {
await stripeCli.products.update(stripeProdId, {
name: newName,
});
} catch (error: any) {
logger.error(
`Error updating product ${curProduct.id} name in Stripe: ${error.message}`,
{
error,
stripeProdId,
newName,
}
);
}
try {
await stripeCli.products.update(stripeProdId, {
name: newName,
});
} catch (error: any) {
logger.error(
`Error updating product ${curProduct.id} name in Stripe: ${error.message}`,
{
error,
stripeProdId,
newName,
}
);
}
for (const price of curProduct.prices) {
let stripeProdId = price.config?.stripe_product_id;
for (const price of curProduct.prices) {
let stripeProdId = price.config?.stripe_product_id;
if (stripeProdId) {
let name = usagePriceToProductName({
price,
fullProduct: {
...curProduct,
name: newName,
},
});
if (stripeProdId) {
let name = usagePriceToProductName({
price,
fullProduct: {
...curProduct,
name: newName,
},
});
try {
await stripeCli.products.update(stripeProdId, {
name,
});
} catch (error: any) {
logger.error(
`Error updating price ${price.id} name in Stripe: ${error.message}`
);
}
}
}
try {
await stripeCli.products.update(stripeProdId, {
name,
});
} catch (error: any) {
logger.error(
`Error updating price ${price.id} name in Stripe: ${error.message}`
);
}
}
}
};
const willBeDefaultTrial = ({
newProduct,
curProduct,
newFreeTrial,
newItems,
}: {
newProduct: UpdateProduct;
curProduct: FullProduct;
newFreeTrial: FreeTrial;
newItems: ProductItem[];
}) => {
// 1. Get final default
const finalDefault = notNullish(newProduct.is_default)
? newProduct.is_default
: curProduct.is_default;
const finalFreeTrial = notNullish(newFreeTrial)
? newFreeTrial
: curProduct.free_trial;
const finalIsFree = notNullish(newItems)
? isFreeProductV2({ items: newItems })
: isFreeProduct(curProduct.prices);
return finalDefault && !finalIsFree && finalFreeTrial;
};
export const handleUpdateProductDetails = async ({
db,
newProduct,
curProduct,
items,
org,
rewardPrograms,
logger,
db,
newProduct,
curProduct,
newFreeTrial,
items,
org,
rewardPrograms,
logger,
}: {
db: DrizzleCli;
curProduct: FullProduct;
newProduct: UpdateProduct;
items: ProductItem[];
org: Organization;
rewardPrograms: RewardProgram[];
logger: any;
db: DrizzleCli;
curProduct: FullProduct;
newProduct: UpdateProduct;
newFreeTrial: FreeTrial;
items: ProductItem[];
org: Organization;
rewardPrograms: RewardProgram[];
logger: any;
}) => {
const customersOnAllVersions = await CusProductService.getByProductId({
db,
productId: curProduct.id,
orgId: org.id,
env: curProduct.env as AppEnv,
});
const customersOnAllVersions = await CusProductService.getByProductId({
db,
productId: curProduct.id,
orgId: org.id,
env: curProduct.env as AppEnv,
});
const trialConfig = await FreeTrialService.getByProductId({
db,
productId: curProduct.internal_id,
});
const trialConfig = await FreeTrialService.getByProductId({
db,
productId: curProduct.internal_id,
});
// Should error if:
// - New product is a default product
// - Org is not allowed to have paid default products
// - Current product is not a default trial
if (newProduct.is_default && !org.config.allow_paid_default && !isDefaultTrialFullProduct({ product: curProduct, skipDefault: true })) {
// 1. Check if there are items
if (items) {
if (
items.some(
(item) => isFeaturePriceItem(item) || isPriceItem(item)
)
) {
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
code: ErrCode.InvalidProduct,
statusCode: 400,
});
}
} else {
if (!isFreeProduct(curProduct.prices)) {
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
code: ErrCode.InvalidProduct,
statusCode: 400,
});
}
}
}
// Should error if:
// - New product is a default product
// - Org is not allowed to have paid default products
// - Current product is not a default trial
if (productDetailsSame(curProduct, newProduct)) {
return;
}
// Final prices are curProduct.prices or newProduct.prices
if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) {
if (customersOnAllVersions.length > 0) {
throw new RecaseError({
message:
"Cannot change product ID because it has existing customers",
code: ErrCode.ProductHasCustomers,
statusCode: 400,
});
}
if (
newProduct.is_default &&
!org.config.allow_paid_default &&
!willBeDefaultTrial({
newProduct,
curProduct,
newFreeTrial,
newItems: items,
})
// && !isDefaultTrialFullProduct({
// product: {
// ...newProduct,
// free_trial: newFreeTrial || curProduct.free_trial || null,
// },
// skipDefault: true,
// })
) {
// 1. Check if there are items
if (items) {
if (items.some((item) => isFeaturePriceItem(item) || isPriceItem(item))) {
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
code: ErrCode.InvalidProduct,
statusCode: 400,
});
}
} else {
if (!isFreeProduct(curProduct.prices)) {
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
code: ErrCode.InvalidProduct,
statusCode: 400,
});
}
}
}
if (rewardPrograms.length > 0) {
throw new RecaseError({
message:
"Cannot change product ID because existing reward programs are linked to it",
code: ErrCode.ProductHasRewardPrograms,
statusCode: 400,
});
}
}
if (productDetailsSame(curProduct, newProduct)) {
return;
}
// 2. Update product
await ProductService.updateByInternalId({
db,
internalId: curProduct.internal_id,
update: {
id: newProduct.id,
name: newProduct.name,
group: newProduct.group,
is_add_on: newProduct.is_add_on,
is_default: newProduct.is_default,
archived: newProduct.archived,
},
});
if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) {
if (customersOnAllVersions.length > 0) {
throw new RecaseError({
message: "Cannot change product ID because it has existing customers",
code: ErrCode.ProductHasCustomers,
statusCode: 400,
});
}
// Update product name in Stripe
if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) {
logger.info(
`Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`
);
await updateStripeProductNames({
db,
org,
curProduct,
newName: newProduct.name!,
logger,
});
}
if (rewardPrograms.length > 0) {
throw new RecaseError({
message:
"Cannot change product ID because existing reward programs are linked to it",
code: ErrCode.ProductHasRewardPrograms,
statusCode: 400,
});
}
}
curProduct.name = newProduct.name || curProduct.name;
curProduct.group = newProduct.group || curProduct.group;
curProduct.is_add_on = newProduct.is_add_on ?? curProduct.is_add_on;
curProduct.is_default = newProduct.is_default ?? curProduct.is_default;
curProduct.archived = newProduct.archived ?? curProduct.archived;
// 2. Update product
await ProductService.updateByInternalId({
db,
internalId: curProduct.internal_id,
update: {
id: newProduct.id,
name: newProduct.name,
group: newProduct.group,
is_add_on: newProduct.is_add_on,
is_default: newProduct.is_default,
archived: newProduct.archived,
},
});
// Update product name in Stripe
if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) {
logger.info(
`Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`
);
await updateStripeProductNames({
db,
org,
curProduct,
newName: newProduct.name!,
logger,
});
}
curProduct.name = newProduct.name || curProduct.name;
curProduct.group = newProduct.group || curProduct.group;
curProduct.is_add_on = newProduct.is_add_on ?? curProduct.is_add_on;
curProduct.is_default = newProduct.is_default ?? curProduct.is_default;
curProduct.archived = newProduct.archived ?? curProduct.archived;
};

View File

@@ -6,7 +6,7 @@ import { ErrCode, UsageModel } from "@autumn/shared";
import { FeatureOptions } from "@autumn/shared";
import { OrgService } from "../orgs/OrgService.js";
import { RewardService } from "../rewards/RewardService.js";
import { getProductVersionCounts } from "./productUtils.js";
import { getGroupToDefaults, getProductVersionCounts } from "./productUtils.js";
import { getLatestProducts } from "./productUtils.js";
import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js";
import { MigrationService } from "../migrations/MigrationService.js";
@@ -20,6 +20,7 @@ import RecaseError, {
} from "@/utils/errorUtils.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
import { sortFullProducts } from "./productUtils/sortProductUtils.js";
import { getGroupToDefaultProd } from "../customers/cusUtils/createNewCustomer.js";
export const productRouter: Router = Router({ mergeParams: true });
@@ -29,7 +30,7 @@ productRouter.get("/data", async (req: any, res) => {
const allVersions = req.query.all_versions === "true";
const [products, features, org, coupons, rewardPrograms] =
const [products, features, org, coupons, rewardPrograms, defaultProds] =
await Promise.all([
ProductService.listFull({
db,
@@ -46,12 +47,21 @@ productRouter.get("/data", async (req: any, res) => {
orgId: req.orgId,
env: req.env,
}),
ProductService.listDefault({
db,
orgId: req.orgId,
env: req.env,
}),
]);
sortFullProducts({
products: getLatestProducts(products),
});
const groupToDefaultProd = getGroupToDefaults({
defaultProds,
});
res.status(200).json({
products: products.map((product) => {
return mapToProductV2({ product, features });
@@ -61,6 +71,7 @@ productRouter.get("/data", async (req: any, res) => {
org: createOrgResponse(org),
rewards: coupons,
rewardPrograms,
groupToDefaults: groupToDefaultProd,
});
} catch (error) {
console.error("Failed to get products", error);
@@ -73,7 +84,7 @@ productRouter.post("/data", async (req: any, res) => {
let { db } = req;
let { showArchived } = req.body;
const [products, features, org, coupons, rewardPrograms] =
const [products, defaultProds, features, org, coupons, rewardPrograms] =
await Promise.all([
ProductService.listFull({
db,
@@ -82,6 +93,11 @@ productRouter.post("/data", async (req: any, res) => {
// returnAll: true,
archived: showArchived,
}),
ProductService.listDefault({
db,
orgId: req.orgId,
env: req.env,
}),
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
RewardService.list({ db, orgId: req.orgId, env: req.env }),
@@ -92,10 +108,16 @@ productRouter.post("/data", async (req: any, res) => {
}),
]);
// Group to default product
const groupToDefaultProd = getGroupToDefaults({
defaultProds,
});
res.status(200).json({
products: sortFullProducts({ products }).map((product) => {
return mapToProductV2({ product, features });
}),
groupToDefaults: groupToDefaultProd,
versionCounts: getProductVersionCounts(products),
features,
org: createOrgResponse(org),
@@ -191,6 +213,18 @@ productRouter.get("/:productId/data", async (req: any, res) => {
statusCode: StatusCodes.NOT_FOUND,
});
}
const defaultProds = await ProductService.listDefault({
db,
orgId: req.orgId,
env: req.env,
group: product.group,
});
const groupDefaults = getGroupToDefaults({
defaultProds,
})?.[product.group];
let entitlements = product.entitlements;
let prices = product.prices;
@@ -218,6 +252,7 @@ productRouter.get("/:productId/data", async (req: any, res) => {
},
numVersions,
existingMigrations,
groupDefaults: groupDefaults,
});
} catch (error) {
handleFrontendReqError({

View File

@@ -43,6 +43,7 @@ import { FreeTrialService } from "./free-trials/FreeTrialService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js";
import { isStripeConnected } from "../orgs/orgUtils.js";
import { isDefaultTrialFullProduct } from "./productUtils/classifyProduct.js";
export const getLatestProducts = (products: FullProduct[]) => {
const latestProducts = products.reduce((acc: any, product: any) => {
@@ -534,3 +535,27 @@ export const searchProductsByStripeId = async ({
}) => {
return products.find((p) => p.processor?.id === stripeId);
};
export const getGroupToDefaults = ({
defaultProds,
}: {
defaultProds: FullProduct[];
}) => {
const groupToDefaults: Record<string, Record<string, FullProduct>> = {};
for (const product of defaultProds) {
if (!groupToDefaults[product.group]) {
groupToDefaults[product.group] = {};
}
if (isDefaultTrialFullProduct({ product })) {
groupToDefaults[product.group].defaultTrial = product;
}
if (isFreeProduct(product.prices)) {
groupToDefaults[product.group].free = product;
}
}
return groupToDefaults;
};

View File

@@ -35,10 +35,32 @@ export const isFreeProductV2 = ({ product }: { product: ProductV2 }) => {
return product.items.every((item) => isFeatureItem(item));
};
export const isDefaultTrial = ({ product, skipDefault = false }: { product: ProductV2, skipDefault?: boolean }) => {
return product.free_trial && !product.free_trial?.card_required && (product.is_default || skipDefault) && !isFreeProductV2({ product });
export const isDefaultTrial = ({
product,
skipDefault = false,
}: {
product: ProductV2;
skipDefault?: boolean;
}) => {
return (
product.free_trial &&
!product.free_trial?.card_required &&
(product.is_default || skipDefault) &&
!isFreeProductV2({ product })
);
};
export const isDefaultTrialFullProduct = ({ product, skipDefault = false }: { product: FullProduct, skipDefault?: boolean }) => {
return product.free_trial && !product.free_trial?.card_required && (product.is_default || skipDefault) && !isFreeProduct(product.prices);
};
export const isDefaultTrialFullProduct = ({
product,
skipDefault = false,
}: {
product: FullProduct;
skipDefault?: boolean;
}) => {
return (
product.free_trial &&
!product.free_trial?.card_required &&
(product.is_default || skipDefault) &&
!isFreeProduct(product.prices)
);
};

View File

@@ -83,6 +83,7 @@ export const constructProduct = ({
items,
type,
interval,
group,
intervalCount,
isAnnual = false,
trial = false,
@@ -96,6 +97,7 @@ export const constructProduct = ({
items: ProductItem[];
type: "free" | "pro" | "premium" | "growth" | "one_off";
interval?: BillingInterval;
group?: string;
intervalCount?: number;
isAnnual?: boolean;
trial?: boolean;
@@ -152,9 +154,9 @@ export const constructProduct = ({
: keyToTitle(type),
items,
is_add_on: isAddOn,
is_default: (type == "free" && isDefault) || (forcePaidDefault),
is_default: (type == "free" && isDefault) || forcePaidDefault,
version: 1,
group: "",
group: group || "",
free_trial:
freeTrial || trial
? (CreateFreeTrialSchema.parse({

View File

@@ -93,9 +93,7 @@ export const initCustomer = async ({
}
try {
const response = await autumn.customers.create(customerData);
// console.log("Created customer:", response);
const res = await autumn.customers.create(customerData);
let customer = (await CusService.get({
db,
@@ -104,17 +102,6 @@ export const initCustomer = async ({
env: env,
})) as Customer;
// console.log("Org ID:", org.id);
// console.log("Env:", env);
// console.log("Customer ID:", customerId);
// console.log("Customer:", customer);
// console.log("customer id", customerId);
// console.log("org id", org.id);
// console.log("env", env);
// console.log("customer", customer);
const stripeCli = createStripeCli({ org: org, env: env });
let testClockId = "";
if (withTestClock) {
@@ -152,3 +139,101 @@ export const initCustomer = async ({
throw error;
}
};
export const attachPaymentMethod = async ({
stripeCli,
stripeCusId,
type,
}: {
stripeCli: Stripe;
stripeCusId: string;
type: "success" | "fail";
}) => {
try {
let token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa";
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
token,
},
});
await stripeCli.paymentMethods.attach(pm.id, {
customer: stripeCusId,
});
await stripeCli.customers.update(stripeCusId, {
invoice_settings: {
default_payment_method: pm.id,
},
});
} catch (error) {
console.log("failed to attach payment method", error);
}
};
// V2 initializes the customer in Stripe, then creates the customer in Autumn
export const initCustomerV2 = async ({
autumn,
customerId,
org,
env,
db,
attachPm,
withTestClock = true,
}: {
autumn: Autumn;
customerId: string;
org: Organization;
env: AppEnv;
db: DrizzleCli;
attachPm?: "success" | "fail";
withTestClock?: boolean;
}) => {
let name = customerId;
let email = `${customerId}@example.com`;
let fingerprint_ = "";
const stripeCli = createStripeCli({ org, env });
let testClockId = undefined;
if (withTestClock) {
const testClock = await stripeCli.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
});
testClockId = testClock.id;
}
// 1. Create stripe customer
const stripeCus = await stripeCli.customers.create({
email,
name,
test_clock: testClockId,
});
// 2. Create customer
try {
await autumn.customers.delete(customerId);
} catch (error) {}
await autumn.customers.create({
id: customerId,
name,
email,
fingerprint: fingerprint_,
// @ts-ignore
stripe_id: stripeCus.id,
});
// 3. Attach payment method
if (attachPm) {
await attachPaymentMethod({
stripeCli,
stripeCusId: stripeCus.id,
type: attachPm,
});
}
return {
testClockId: testClockId || "",
};
};

View File

@@ -1,152 +0,0 @@
import { APIVersion, ProductItemInterval, FreeTrialDuration } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { Organization, AppEnv } from "@autumn/shared";
import { Stripe } from "stripe";
import chalk from "chalk";
import { setupBefore } from "tests/before.js";
import { expect } from "chai";
import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
const testCase = "defaultTrial0";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 1500,
interval: ProductItemInterval.Month,
}),
],
// id: testCase + "_pro",
isDefault: true,
forcePaidDefault: true,
type: "pro",
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: false,
},
});
export let free = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
interval: ProductItemInterval.Month,
}),
],
type: "free",
isDefault: true,
});
const cleanUpCustomers = async (autumn: AutumnInt) => {
[testCase + "_a", testCase + "_b"].forEach(async (customerId) => {
await autumn.customers.delete(customerId).catch(e => {
throw e;
});
});
}
describe(`${chalk.yellowBright(`advanced/${testCase}: ensure manually attaching is the same as creating a customer`)}`, () => {
let customerId_a = testCase + "_a";
let customerId_b = testCase + "_b";
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let autumn_js: any;
let curUnix = Math.floor(new Date().getTime() / 1000);
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
autumn_js = this.autumnJs;
let productsToCreate = addPrefixToProducts({
products: [pro, free],
prefix: testCase,
});
await createProducts({
autumn,
products: productsToCreate,
db,
orgId: org.id,
env,
}).catch(e => {
if(e.message.includes("already exists")) {
return;
}
throw e;
});
await flipDefaultStates({
currentCase: 0,
autumn,
});
await cleanUpCustomers(autumn);
});
it("should match initCustomer", async function () {
before(async function () {
await autumn.customers.delete(customerId_a);
await autumn.customers.delete(customerId_b);
});
await manuallyAttachDefaultTrial({
customerId: customerId_a,
stripeCli,
autumn,
db,
org,
env,
autumnJs: autumn_js,
group: testCase,
});
let customer_a = await autumn.customers.get(customerId_a);
expect(customer_a, "customer should be defined").to.exist;
let customer_a_products = customer_a?.products.map(p => p.id + " " + p.status + " " + p.name);
expect(customer_a_products[0], "customer_a_products should be defined").to.exist;
await initCustomer({
customerId: customerId_b,
autumn: autumn_js,
db,
org,
env,
});
let customer_b = await autumn.customers.get(customerId_b);
let customer_b_products = customer_b?.products.map((p: any) => p.id + " " + p.status + " " + p.name);
expect(customer_b_products[0], "customer_b_products should be defined").to.exist;
expect(customer_a_products[0], "customer_a_products should be the same as customer_b_products").to.equal(customer_b_products[0]);
});
after(async function() {
await cleanUpCustomers(autumn);
});
})

View File

@@ -1,28 +1,23 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
// Manual customer creation - not using initCustomer to control test clock properly
import {
APIVersion,
AppEnv,
CusProductStatus,
FreeTrialDuration,
Organization,
ProductItemInterval,
APIVersion,
AppEnv,
CusProductStatus,
Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import {
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js";
import { expect } from "chai";
import {
defaultTrialFree,
defaultTrialPro,
setupDefaultTrialBefore,
} from "./defaultTrialBefore.test.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
// Case 1: ✅
// Pro product with default trial exists alongside a free default product
@@ -35,124 +30,61 @@ import { expect } from "chai";
const testCase = "defaultTrial1";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 1500,
interval: ProductItemInterval.Month,
}),
],
// id: testCase + "_pro",
isDefault: true,
forcePaidDefault: true,
type: "pro",
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: false,
},
});
export let free = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
interval: ProductItemInterval.Month,
}),
],
type: "free",
isDefault: true,
});
describe(`${chalk.yellowBright(`advanced/${testCase}: ensure default trials are attached when creating a customer`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = Math.floor(new Date().getTime() / 1000);
let curUnix = Math.floor(new Date().getTime() / 1000);
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
before(async function () {
await setupBefore(this);
await setupDefaultTrialBefore({});
const { autumnJs } = this;
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
let testClock = await stripeCli.testHelpers.testClocks.create({
frozen_time: curUnix,
});
testClockID = testClock.id;
let productsToCreate = addPrefixToProducts({
products: [pro, free],
prefix: testCase,
});
await createProducts({
autumn,
products: productsToCreate,
db,
orgId: org.id,
env,
}).catch(e => {
if(e.message.includes("already exists")) {
return;
}
throw e;
});
await flipDefaultStates({
currentCase: 1,
autumn,
});
let customer = await manuallyAttachDefaultTrial({
customerId,
stripeCli,
autumn,
db,
org,
env,
testClockID,
autumnJs,
group: testCase,
});
expect(customer, "customer should be defined").to.exist;
const res = await initCustomerV2({
autumn: autumnJs,
customerId: testCase,
db,
org,
env,
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
testClockID = res.testClockId;
});
expectProductAttached({
customer,
product: pro,
});
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
describe("ensure trials automatically cancel if no payment method is provided", () => {
it("should expire after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
numberOfDays: 8,
waitForSeconds: 10,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: free,
status: CusProductStatus.Active
});
});
expectProductAttached({
customer,
product: defaultTrialPro,
status: CusProductStatus.Trialing,
});
});
describe("ensure trials automatically cancel if no payment method is provided", () => {
it("should expire after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
numberOfDays: 8,
waitForSeconds: 10,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: defaultTrialFree,
status: CusProductStatus.Active,
});
});
});
});

View File

@@ -1,153 +1,88 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
// Manual customer creation - not using initCustomer to control test clock properly
import {
APIVersion,
AppEnv,
CusProductStatus,
FreeTrialDuration,
Organization,
ProductItemInterval,
APIVersion,
AppEnv,
CusProductStatus,
Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import {
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js";
import { expect } from "chai";
import { CusService } from "@/internal/customers/CusService.js";
// 2.2:
import {
defaultTrialPro,
setupDefaultTrialBefore,
} from "./defaultTrialBefore.test.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { addDays, addHours } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
// 2.2:
// -> Creating a new customer with a payment method should attach the pro product with default trial
// --> Advancing the test clock should cancel the trial and attach the pro product
const testCase = "defaultTrial2";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 1500,
interval: ProductItemInterval.Month,
}),
],
// id: testCase + "_pro",
isDefault: true,
forcePaidDefault: true,
type: "pro",
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: false,
},
});
export let free = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
interval: ProductItemInterval.Month,
}),
],
type: "free",
isDefault: true,
});
describe(`${chalk.yellowBright(`advanced/${testCase}: ensure trial transitions into full product if payment method is valid`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = Math.floor(new Date().getTime() / 1000);
let curUnix = Math.floor(new Date().getTime() / 1000);
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
before(async function () {
await setupBefore(this);
await setupDefaultTrialBefore({});
const { autumnJs } = this;
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
let testClock = await stripeCli.testHelpers.testClocks.create({
frozen_time: curUnix,
});
testClockID = testClock.id;
let productsToCreate = addPrefixToProducts({
products: [pro, free],
prefix: testCase,
});
await createProducts({
autumn,
products: productsToCreate,
db,
orgId: org.id,
env,
}).catch(e => {
if(e.message.includes("already exists")) {
return;
}
throw e;
});
await flipDefaultStates({
currentCase: 2,
autumn,
});
let customer = await manuallyAttachDefaultTrial({
customerId,
stripeCli,
autumn,
db,
org,
env,
testClockID,
autumnJs,
group: testCase,
attachPm: "success"
});
expect(customer, "customer should be defined").to.exist;
const res = await initCustomerV2({
autumn: autumnJs,
customerId: testCase,
db,
org,
env,
attachPm: "success",
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
testClockID = res.testClockId;
});
expectProductAttached({
customer,
product: pro,
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: defaultTrialPro,
});
});
it("should be active after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
advanceTo: addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 10,
});
it("should be active after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
numberOfDays: 9,
waitForSeconds: 10,
});
let customer = await autumn.customers.get(customerId);
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
status: CusProductStatus.Active
});
expectProductAttached({
customer,
product: defaultTrialPro,
status: CusProductStatus.Active,
});
});
});

View File

@@ -1,28 +1,26 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
// Manual customer creation - not using initCustomer to control test clock properly
import {
APIVersion,
AppEnv,
CusProductStatus,
FreeTrialDuration,
Organization,
ProductItemInterval,
APIVersion,
AppEnv,
CusProductStatus,
Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import {
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { flipDefaultState, flipDefaultStates, manuallyAttachDefaultTrial } from "tests/utils/testAttachUtils/trialAttachUtils.js";
import { expect } from "chai";
import {
defaultTrialFree,
defaultTrialPro,
setupDefaultTrialBefore,
} from "./defaultTrialBefore.test.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { addDays, addHours } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
// 2.3:
// -> Creating a new customer with a fake payment method should attach the pro product with default trial
@@ -30,138 +28,78 @@ import { expect } from "chai";
const testCase = "defaultTrial3";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 1500,
interval: ProductItemInterval.Month,
}),
],
isDefault: true,
forcePaidDefault: true,
type: "pro",
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: false,
},
});
export let free = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
interval: ProductItemInterval.Month,
}),
],
type: "free",
isDefault: true,
});
describe(`${chalk.yellowBright(`advanced/${testCase}: ensure trials cancel with bad payment method`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockID: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = Math.floor(new Date().getTime() / 1000);
let curUnix = Math.floor(new Date().getTime() / 1000);
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
before(async function () {
await setupBefore(this);
await setupDefaultTrialBefore({});
const { autumnJs } = this;
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
let testClock = await stripeCli.testHelpers.testClocks.create({
frozen_time: curUnix,
});
testClockID = testClock.id;
let productsToCreate = addPrefixToProducts({
products: [pro, free],
prefix: testCase,
});
await createProducts({
autumn,
products: productsToCreate,
db,
orgId: org.id,
env,
}).catch(e => {
if(e.message.includes("already exists")) {
return;
}
throw e;
});
await flipDefaultStates({
currentCase: 3,
autumn,
});
let customer = await manuallyAttachDefaultTrial({
customerId,
stripeCli,
autumn,
db,
org,
env,
testClockID,
autumnJs,
group: testCase,
attachPm: "fail"
});
expect(customer, "customer should be defined").to.exist;
const res = await initCustomerV2({
autumn: autumnJs,
customerId: testCase,
db,
org,
env,
attachPm: "fail",
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
testClockID = res.testClockId;
});
expectProductAttached({
customer,
product: pro,
});
it("should create a customer with the paid default trial", async function () {
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: defaultTrialPro,
});
});
it("should cancel after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
advanceTo: addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 30,
});
it("should cancel after 7 days", async function () {
await advanceTestClock({
stripeCli,
testClockId: testClockID,
numberOfDays: 8,
waitForSeconds: 30,
});
let customer = await autumn.customers.get(customerId);
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
status: CusProductStatus.PastDue
});
await advanceTestClock({
stripeCli,
testClockId: testClockID,
// should be massive so the stripe smart retry works in all settings
numberOfDays: 31,
waitForSeconds: 30,
});
customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: free,
status: CusProductStatus.Active
});
expectProductAttached({
customer,
product: defaultTrialPro,
status: CusProductStatus.PastDue,
});
// await advanceTestClock({
// stripeCli,
// testClockId: testClockID,
// // should be massive so the stripe smart retry works in all settings
// numberOfDays: 31,
// waitForSeconds: 30,
// });
// customer = await autumn.customers.get(customerId);
// expectProductAttached({
// customer,
// product: defaultTrialFree,
// status: CusProductStatus.Active,
// });
});
});

View File

@@ -0,0 +1,57 @@
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import {
APIVersion,
FreeTrialDuration,
ProductItemInterval,
} from "@autumn/shared";
import { TestFeature } from "tests/setup/v2Features.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
export let defaultTrialPro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 1500,
interval: ProductItemInterval.Month,
}),
],
isDefault: true,
forcePaidDefault: true,
id: "defaultTrial_pro",
group: "defaultTrial",
type: "pro",
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: false,
},
});
export let defaultTrialFree = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
interval: ProductItemInterval.Month,
}),
],
id: "defaultTrial_free",
group: "defaultTrial",
type: "free",
isDefault: true,
});
export const setupDefaultTrialBefore = async ({}: {}) => {
const autumn = new AutumnInt({ version: APIVersion.v1_2 });
for (const product of [defaultTrialPro, defaultTrialFree]) {
let res = await autumn.products.get(product.id);
if (res.code === "product_not_found") {
try {
await autumn.products.create(product);
} catch (error) {}
}
}
};

View File

@@ -4,16 +4,11 @@ import { assert, expect } from "chai";
import chalk from "chalk";
import { setupBefore } from "tests/before.js";
import { AutumnCli } from "tests/cli/AutumnCli.js";
import { features, products } from "tests/global.js";
import { products } from "tests/global.js";
import { compareMainProduct } from "tests/utils/compare.js";
import { completeCheckoutForm } from "tests/utils/stripeUtils.js";
import { timeout } from "tests/utils/genUtils.js";
const oneTimeQuantity = 2;
const oneTimePurchaseCount = 2;
const oneTimeOverrideQuantity = 4;
const monthlyQuantity = 2;
// UNCOMMENT FROM HERE
const testCase = "basic2";
describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => {

View File

@@ -28,12 +28,12 @@ export const expectProductAttached = ({
if (status) {
expect(productAttached?.status).to.equal(
status,
`product ${product.id} should have status ${status}`,
`product ${product.id} should have status ${status}`
);
} else {
expect(
productAttached?.status,
`product ${product.id} is not expired`,
`product ${product.id} is not expired`
).to.not.equal(CusProductStatus.Expired);
}
@@ -68,12 +68,12 @@ export const expectInvoicesCorrect = ({
expect(invoices![0].total).to.approximately(
first.total,
0.01,
`invoice total is correct: ${first.total}`,
`invoice total is correct: ${first.total}`
);
expect(invoices![0].product_ids).to.include(
first.productId,
`invoice includes product ${first.productId}`,
`invoice includes product ${first.productId}`
);
} catch (error) {
console.log(`invoice for ${first.productId}, ${first.total} not found`);
@@ -96,15 +96,15 @@ export const expectInvoicesCorrect = ({
expect(totalAmount).to.approximately(
second.total,
0.01,
`first & second invoice total should sum to ${second.total}`,
`first & second invoice total should sum to ${second.total}`
);
expect(
invoices![0].product_ids.includes(second.productId),
`invoice 1 includes product ${second.productId}`,
`invoice 1 includes product ${second.productId}`
).to.be.true;
expect(
invoices![1].product_ids.includes(second.productId),
`invoice 2 includes product ${second.productId}`,
`invoice 2 includes product ${second.productId}`
).to.be.true;
} catch (error) {
console.log(`invoice for ${second.productId}, ${second.total} not found`);

View File

@@ -11,224 +11,228 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { attachPmToCus } from "@/external/stripe/stripeCusUtils.js";
export async function manuallyAttachDefaultTrial({
customerId,
stripeCli,
autumn,
db,
org,
env,
testClockID,
autumnJs,
attachPm = "",
group,
customerId,
stripeCli,
autumn,
db,
org,
env,
testClockID,
autumnJs,
attachPm = "",
group,
}: {
customerId: string;
stripeCli: Stripe;
autumn: AutumnInt;
db: DrizzleCli;
org: any;
env: AppEnv;
testClockID?: string;
autumnJs: any;
attachPm?: "success" | "fail" | "";
group?: string;
customerId: string;
stripeCli: Stripe;
autumn: AutumnInt;
db: DrizzleCli;
org: any;
env: AppEnv;
testClockID?: string;
autumnJs: any;
attachPm?: "success" | "fail" | "";
group?: string;
}) {
try {
const existingCustomer = await CusService.get({ db, idOrInternalId: customerId, orgId: org.id, env });
if (existingCustomer) {
// Delete via API to clean up properly
await autumnJs.customers.delete(customerId);
}
} catch (error) {
// Ignore if customer doesn't exist
console.log("Customer doesn't exist, skipping delete", error);
try {
const existingCustomer = await CusService.get({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
});
if (existingCustomer) {
// Delete via API to clean up properly
await autumnJs.customers.delete(customerId);
}
} catch (error) {
// Ignore if customer doesn't exist
console.log("Customer doesn't exist, skipping delete", error);
}
// Step 2: Manually create customer in DB (following createNewCustomer.ts logic)
const customerData = {
id: customerId,
name: customerId,
email: `${customerId}@example.com`,
metadata: {},
internal_id: generateId("cus"),
org_id: org.id,
created_at: Date.now(),
env,
};
// Step 2: Manually create customer in DB (following createNewCustomer.ts logic)
const customerData = {
id: customerId,
name: customerId,
email: `${customerId}@example.com`,
metadata: {},
internal_id: generateId("cus"),
org_id: org.id,
created_at: Date.now(),
env,
};
const newCustomer = await CusService.insert({
db,
data: customerData,
const newCustomer = await CusService.insert({
db,
data: customerData,
});
if (!newCustomer) {
throw new Error("Failed to create customer");
}
// Step 3: Get default products (following createNewCustomer.ts logic)
const allDefaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
// Filter by group if specified
const defaultProds = group
? allDefaultProds.filter((p) => p.group === group)
: allDefaultProds;
const defaultPaidTrialProd = defaultProds.find((p) =>
isDefaultTrialFullProduct({ product: p })
);
let customer = newCustomer;
if (defaultPaidTrialProd) {
// Step 4: Create Stripe customer with test clock
const stripeCustomer = await stripeCli.customers.create({
email: `${customerId}@example.com`,
test_clock: testClockID ? testClockID : undefined,
});
if (!newCustomer) {
throw new Error("Failed to create customer");
}
// Step 3: Get default products (following createNewCustomer.ts logic)
const allDefaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
// Step 5: Update customer with Stripe processor info (BEFORE attachPmToCus)
await CusService.update({
db,
internalCusId: newCustomer.internal_id,
update: {
processor: {
type: ProcessorType.Stripe,
id: stripeCustomer.id,
},
},
});
// Filter by group if specified
const defaultProds = group
? allDefaultProds.filter(p => p.group === group)
: allDefaultProds;
// Update local customer object
customer = {
...newCustomer,
processor: {
id: stripeCustomer.id,
type: "stripe",
},
} as any;
const defaultPaidTrialProd = defaultProds.find((p) =>
isDefaultTrialFullProduct({ product: p })
);
let customer = newCustomer;
if (defaultPaidTrialProd) {
// Step 4: Create Stripe customer with test clock
const stripeCustomer = await stripeCli.customers.create({
email: `${customerId}@example.com`,
test_clock: testClockID ? testClockID : undefined,
});
// Step 5: Update customer with Stripe processor info (BEFORE attachPmToCus)
await CusService.update({
db,
internalCusId: newCustomer.internal_id,
update: {
processor: {
type: ProcessorType.Stripe,
id: stripeCustomer.id,
},
},
});
// Update local customer object
customer = {
...newCustomer,
processor: {
id: stripeCustomer.id,
type: "stripe",
},
} as any;
if (attachPm && testClockID) {
await attachPmToCus({
customer: customer,
org: org,
env: env,
db: db,
testClockId: testClockID,
willFail: attachPm === "fail",
});
}
// Step 6: Manually attach the default trial product (following createNewCustomer.ts logic)
const req = {
db,
org,
env,
orgId: org.id,
logtail: console,
logger: console,
} as any;
await handleAddProduct({
req,
attachParams: newCusToAttachParams({
req,
newCus: customer as any,
products: [defaultPaidTrialProd],
stripeCli,
freeTrial: defaultPaidTrialProd.free_trial || null,
}),
});
return customer;
if (attachPm && testClockID) {
await attachPmToCus({
customer: customer,
org: org,
env: env,
db: db,
testClockId: testClockID,
willFail: attachPm === "fail",
});
}
// Step 6: Manually attach the default trial product (following createNewCustomer.ts logic)
const req = {
db,
org,
env,
orgId: org.id,
logtail: console,
logger: console,
} as any;
await handleAddProduct({
req,
attachParams: newCusToAttachParams({
req,
newCus: customer as any,
products: [defaultPaidTrialProd],
stripeCli,
freeTrial: defaultPaidTrialProd.free_trial || null,
}),
});
return customer;
}
}
export async function cleanupQueueAndCache() {
try {
const { QueueManager } = await import("@/queue/QueueManager.js");
const queueInstance = await QueueManager.getInstance();
// Access private properties to close connections
if ((queueInstance as any).queue) {
await (queueInstance as any).queue.close();
}
if ((queueInstance as any).backupQueue) {
await (queueInstance as any).backupQueue.close();
}
if ((queueInstance as any).mainConnection) {
await (queueInstance as any).mainConnection.quit();
}
if ((queueInstance as any).backupConnection) {
await (queueInstance as any).backupConnection.quit();
}
} catch (error) {
// Ignore cleanup errors
}
try {
const { QueueManager } = await import("@/queue/QueueManager.js");
const queueInstance = await QueueManager.getInstance();
try {
const { CacheManager } = await import("@/external/caching/CacheManager.js");
const cacheInstance = await CacheManager.getInstance();
if ((cacheInstance as any).connection) {
await (cacheInstance as any).connection.quit();
}
} catch (error) {
// Ignore cleanup errors
// Access private properties to close connections
if ((queueInstance as any).queue) {
await (queueInstance as any).queue.close();
}
if ((queueInstance as any).backupQueue) {
await (queueInstance as any).backupQueue.close();
}
if ((queueInstance as any).mainConnection) {
await (queueInstance as any).mainConnection.quit();
}
if ((queueInstance as any).backupConnection) {
await (queueInstance as any).backupConnection.quit();
}
} catch (error) {
// Ignore cleanup errors
}
try {
const { CacheManager } = await import("@/external/caching/CacheManager.js");
const cacheInstance = await CacheManager.getInstance();
if ((cacheInstance as any).connection) {
await (cacheInstance as any).connection.quit();
}
} catch (error) {
// Ignore cleanup errors
}
}
export async function flipDefaultState({
id,
autumn,
state
id,
autumn,
state,
}: {
id: string;
autumn: AutumnInt;
state: boolean;
id: string;
autumn: AutumnInt;
state: boolean;
}) {
try {
let productExists = await autumn.products.get(id);
if (productExists) {
await autumn.products.update(id, {
is_default: state,
});
}
} catch (error) {
// Ignore if product doesn't exist
console.log("Product doesn't exist, skipping update", error);
try {
let productExists = await autumn.products.get(id);
if (productExists) {
await autumn.products.update(id, {
is_default: state,
});
}
} catch (error) {
console.log(`Product ${id} doesn't exist, skipping update`);
}
}
export async function flipDefaultStates({
currentCase,
autumn,
currentCase,
autumn,
}: {
currentCase: number;
autumn: AutumnInt;
currentCase: number;
autumn: AutumnInt;
}) {
let total = 4;
// Now flip all products from 0 to total-1, only current case should be true
for (let i = 0; i < total; i++) {
const id = `defaultTrial${i}_pro`;
const state = i === currentCase; // Only the current case is true
await flipDefaultState({
id,
autumn,
state,
});
}
let total = 4;
for (let i = 0; i < total; i++) {
const id = `defaultTrial${i}_free`;
const state = i === currentCase; // Only the current case is true
await flipDefaultState({
id,
autumn,
state,
});
}
}
// Now flip all products from 0 to total-1, only current case should be true
for (let i = 0; i < total; i++) {
const id = `defaultTrial${i}_pro`;
const state = i === currentCase; // Only the current case is true
await flipDefaultState({
id,
autumn,
state,
});
}
for (let i = 0; i < total; i++) {
const id = `defaultTrial${i}_free`;
const state = i === currentCase; // Only the current case is true
await flipDefaultState({
id,
autumn,
state,
});
}
}

View File

@@ -29,7 +29,7 @@ export const FreeTrialResponseSchema = z.object({
length: z.number(),
unique_fingerprint: z.boolean(),
trial_available: z.boolean().nullish().default(true),
card_required: z.boolean(),
card_required: z.boolean().nullish(),
});
export type FreeTrial = z.infer<typeof FreeTrialSchema>;

View File

@@ -16,15 +16,9 @@ import CreateCustomer from "./CreateCustomer";
import { SearchBar } from "./SearchBar";
import LoadingScreen from "../general/LoadingScreen";
import FilterButton from "./FilterButton";
import { SavedViewsDropdown } from "./SavedViewsDropdown";
import SmallSpinner from "@/components/general/SmallSpinner";
import {
useQueryStates,
parseAsString,
parseAsInteger,
parseAsJson,
parseAsArrayOf,
} from "nuqs";
import { useQueryStates, parseAsString, parseAsInteger } from "nuqs";
function CustomersView({ env }: { env: AppEnv }) {
const pageSize = 50;

View File

@@ -22,6 +22,7 @@ import { Product } from "@autumn/shared";
import { updateProduct } from "@/views/products/product/utils/updateProduct";
import { getBackendErr } from "@/utils/genUtils";
import { EditProductDetails } from "./edit-product/EditProductDetails";
import { ToggleDefaultProduct } from "@/views/products/product/product-sidebar/ToggleDefaultProduct";
export const EditProduct = ({ mutate }: { mutate: any }) => {
const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false);
@@ -116,6 +117,7 @@ export const EditProduct = ({ mutate }: { mutate: any }) => {
>
<ProductContext.Provider
value={{
groupDefaults: data.groupToDefaults?.[product?.group || ""],
product,
setProduct,
mutate,
@@ -193,26 +195,34 @@ export const EditProduct = ({ mutate }: { mutate: any }) => {
>
<div className="flex flex-col gap-4" style={{ width: "320px" }}>
<div>
<ToggleButton
{/* <ToggleButton
disabled={product?.is_add_on}
buttonText="Default Product"
value={product?.is_default}
className="text-t2 font-medium h-fit mb-2"
setValue={() => handleToggleSettings("is_default")}
/>
/> */}
<div className="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Default Product</p>
<ToggleDefaultProduct toggleKey="is_default" />
</div>
<div className="text-t3 text-sm" style={{ width: "320px" }}>
A default product is enabled by default for all new users,
typically used for your free plan.
</div>
</div>
<div className="">
<ToggleButton
{/* <ToggleButton
disabled={product?.is_default}
buttonText="Add-on Product"
className="text-t2 font-medium h-fit mb-2"
value={product?.is_add_on}
setValue={() => handleToggleSettings("is_add_on")}
/>
/> */}
<div className="flex items-center text-sm text-t2 gap-2">
<p className="text-t2 font-medium">Add On Product</p>
<ToggleDefaultProduct toggleKey="is_add_on" />
</div>
<div className="text-t3 text-sm" style={{ width: "320px" }}>
A product that can be added on top of a customer's main
plan. Eg. one time purchases or top ups.

View File

@@ -24,6 +24,7 @@ import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { ProductConfig } from "./ProductConfig";
import { ProductV2 } from "@autumn/shared";
import { ToggleButton } from "@/components/general/ToggleButton";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
export const defaultProduct = {
name: "",
@@ -38,7 +39,7 @@ function CreateProduct({
}: {
onSuccess?: (newProduct: ProductV2) => Promise<void>;
}) {
const { env, mutate } = useProductsContext();
const { env, mutate, groupToDefaults } = useProductsContext();
const [loading, setLoading] = useState(false);
const [product, setProduct] = useState(defaultProduct);
const [open, setOpen] = useState(false);
@@ -74,6 +75,8 @@ function CreateProduct({
}
}, [open]);
const groupDefault = groupToDefaults[product.group]?.free;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -89,8 +92,15 @@ function CreateProduct({
isUpdate={false}
/>
{groupDefault && product.is_default && (
<WarningBox className="text-sm">
Creating this product will disable default on {groupDefault.name}{" "}
and enable it on this product.
</WarningBox>
)}
<DialogFooter>
<div className="flex justify-between items-center gap-2 w-full mt-4">
<div className="flex justify-between items-center gap-2 w-full mt-2">
<div className="flex gap-4">
<ToggleButton
disabled={product?.is_add_on}

View File

@@ -1,29 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import {
useAxiosPostSWR,
useAxiosSWR,
usePostSWR,
} from "@/services/useAxiosSwr";
import LoadingScreen from "../general/LoadingScreen";
import CreateProduct from "./CreateProduct";
import CreateReward from "./rewards/CreateReward";
import CreateRewardProgramModal from "./reward-programs/CreateRewardProgram";
import CreateCreditSystem from "../credits/CreateCreditSystem";
import { useEffect, useState } from "react";
import { useAxiosSWR, usePostSWR } from "@/services/useAxiosSwr";
import { Product, Feature } from "@autumn/shared";
import { ProductsContext } from "./ProductsContext";
import { AppEnv } from "@autumn/shared";
import CreateProduct from "./CreateProduct";
import { ProductsTable } from "./ProductsTable";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Package, Gift, Flag } from "lucide-react";
import { RewardsTable } from "./rewards/RewardsTable";
import CreateReward from "./rewards/CreateReward";
import CreateRewardProgramModal from "./reward-programs/CreateRewardProgram";
import { RewardProgramsTable } from "./reward-programs/RewardProgramsTable";
import { FeaturesTable } from "../features/FeaturesTable";
import { CreateFeatureDialog } from "../features/CreateFeature";
import { CreditSystemsTable } from "../credits/CreditSystemsTable";
import CreateCreditSystem from "../credits/CreateCreditSystem";
import { FeaturesContext } from "../features/FeaturesContext";
import { PageSectionHeader } from "@/components/general/PageSectionHeader";
import { HamburgerMenu } from "@/components/general/table-components/HamburgerMenu";
@@ -84,6 +79,7 @@ function ProductsView({ env }: { env: AppEnv }) {
<ProductsContext.Provider
value={{
...data,
groupToDefault: data?.groupToDefault || {},
env,
selectedProduct,
setSelectedProduct,

View File

@@ -1,345 +1,208 @@
import { useProductContext } from "./ProductContext";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import CopyButton from "@/components/general/CopyButton";
import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { ToggleButton } from "@/components/general/ToggleButton";
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
import { ToggleDefaultProduct } from "./product-sidebar/ToggleDefaultProduct";
import { getBackendErr } from "@/utils/genUtils";
export const ProductProps = () => {
const { product, setProduct, counts, mutate } = useProductContext();
const axiosInstance = useAxiosInstance();
const [defaultOpen, setDefaultOpen] = React.useState(false);
const [defaultTrialOpen, setDefaultTrialOpen] = React.useState(false);
const [addOnOpen, setAddOnOpen] = React.useState(false);
const [groupModalOpen, setGroupModalOpen] = React.useState(false);
const [tempGroup, setTempGroup] = React.useState(product.group || "");
const [archivedOpen, setArchivedOpen] = React.useState(false);
const { product, setProduct, counts, mutate } = useProductContext();
const axiosInstance = useAxiosInstance();
const [defaultOpen, setDefaultOpen] = React.useState(false);
const [defaultTrialOpen, setDefaultTrialOpen] = React.useState(false);
const [addOnOpen, setAddOnOpen] = React.useState(false);
const [groupModalOpen, setGroupModalOpen] = React.useState(false);
const [tempGroup, setTempGroup] = React.useState(product.group || "");
const [archivedOpen, setArchivedOpen] = React.useState(false);
return (
<>
<div className="flex justify-between gap-4 w-full whitespace-nowrap">
<div className="flex flex-col w-full gap-4">
<div className="flex items-center w-full justify-between gap-4 h-4">
<p className="text-xs text-t3 font-medium text-center">
Product ID
</p>
<CopyButton text={product.id} className="font-mono">
<span className="truncate block">{product.id}</span>
</CopyButton>
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">
Customers
</p>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-sm text-t2 px-2">
{counts?.active ?? 0} active
</p>
</TooltipTrigger>
<TooltipContent
className="w-22 px-2 flex flex-col gap-2
return (
<>
<div className="flex justify-between gap-4 w-full whitespace-nowrap">
<div className="flex flex-col w-full gap-4">
<div className="flex items-center w-full justify-between gap-4 h-4">
<p className="text-xs text-t3 font-medium text-center">
Product ID
</p>
<CopyButton text={product.id} className="font-mono">
<span className="truncate block">{product.id}</span>
</CopyButton>
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">Customers</p>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-sm text-t2 px-2">
{counts?.active ?? 0} active
</p>
</TooltipTrigger>
<TooltipContent
className="w-22 px-2 flex flex-col gap-2
bg-white/50 backdrop-blur-sm shadow-sm border-1 pr-6 py-2 text-t3 whitespace-nowrap
"
side="bottom"
sideOffset={4}
>
<p className="">
<span>Canceled:</span> {counts?.canceled}
</p>
{counts?.trialing > 0 && (
<p className="">
<span>Trialing:</span>{" "}
{counts?.trialing}
</p>
)}
{counts?.custom > 0 && (
<p className="">
<span>Custom:</span> {counts?.custom}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">
Default
</p>
<Popover
open={defaultOpen}
onOpenChange={setDefaultOpen}
>
<PopoverTrigger
asChild
className="p-0 py-0.5 h-fit"
>
<Button
variant="outline"
className="text-t2 px-2"
disabled={product.is_add_on}
>
{product.is_default ? (
<span className="text-lime-600">
True
</span>
) : (
"False"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-16 p-1" align="end">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={() => {
setProduct({
...product,
is_default: true,
});
setDefaultOpen(false);
}}
>
True
</Button>
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={() => {
setProduct({
...product,
is_default: false,
});
setDefaultOpen(false);
}}
>
False
</Button>
</div>
</PopoverContent>
</Popover>
</div>
side="bottom"
sideOffset={4}
>
<p className="">
<span>Canceled:</span> {counts?.canceled}
</p>
{counts?.trialing > 0 && (
<p className="">
<span>Trialing:</span> {counts?.trialing}
</p>
)}
{counts?.custom > 0 && (
<p className="">
<span>Custom:</span> {counts?.custom}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center w-full justify-between h-4">
<div className="flex items-center gap-2">
<p className="text-xs text-t3 font-medium text-center">Default</p>
<InfoTooltip>
<p>
Default products are the default product for a group. They are
used to determine the default product for a customer when they
don't have an active subscription.
</p>
</InfoTooltip>
</div>
<ToggleDefaultProduct toggleKey="is_default" />
</div>
<div className="flex items-center w-full justify-between h-4">
<div className="flex items-center gap-2">
<p className="text-xs text-t3 font-medium text-center">Add On</p>
<InfoTooltip>
<p>
Add-ons are products that are added to a customer's
subscription. They are used to determine the default product
for a customer when they don't have an active subscription.
</p>
</InfoTooltip>
</div>
<ToggleDefaultProduct toggleKey="is_add_on" />
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">
Add On
</p>
<Popover open={addOnOpen} onOpenChange={setAddOnOpen}>
<PopoverTrigger
asChild
className="p-0 py-0.5 h-fit"
>
<Button
variant="outline"
className="text-t2 px-2"
disabled={product.is_default}
>
{product.is_add_on ? (
<span className="text-lime-600">
True
</span>
) : (
"False"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-16 p-1" align="end">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={() => {
setProduct({
...product,
is_add_on: true,
});
setAddOnOpen(false);
}}
>
True
</Button>
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={() => {
setProduct({
...product,
is_add_on: false,
});
setAddOnOpen(false);
}}
>
False
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">Group</p>
<Button
variant="outline"
className="text-t2 px-2 h-fit py-0.5"
onClick={() => {
setTempGroup(product.group || "");
setGroupModalOpen(true);
}}
>
{product.group || <span className="text-t3">No group</span>}
</Button>
</div>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">
Group
</p>
<Button
variant="outline"
className="text-t2 px-2 h-fit py-0.5"
onClick={() => {
setTempGroup(product.group || "");
setGroupModalOpen(true);
}}
>
{product.group || (
<span className="text-t3">No group</span>
)}
</Button>
</div>
<Dialog open={groupModalOpen} onOpenChange={setGroupModalOpen}>
<DialogContent className="sm:min-w-sm max-w-lg">
<DialogHeader>
<DialogTitle>Edit Product Group</DialogTitle>
</DialogHeader>
<p className="text-t3 text-sm">
Assign this product to a group. Customers will be able to have
active subscriptions from different product groups at the same
time. This can alter your existing upgrade and downgrade logic,
so read the docs{" "}
<a
href="https://docs.useautumn.com/products/create-product#product-groups"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 underline"
>
here
</a>{" "}
to understand how this works.
</p>
<div className="flex gap-4 py-4">
<Input
placeholder="Enter group name"
value={tempGroup}
onChange={(e) => setTempGroup(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setProduct({
...product,
group: tempGroup,
});
setGroupModalOpen(false);
}
}}
/>
<div className="flex justify-end">
<Button
onClick={() => {
setProduct({
...product,
group: tempGroup,
});
setGroupModalOpen(false);
}}
>
Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={groupModalOpen}
onOpenChange={setGroupModalOpen}
>
<DialogContent className="sm:min-w-sm">
<DialogHeader>
<DialogTitle>Edit Product Group</DialogTitle>
</DialogHeader>
<p className="text-t3 text-sm">
Assign this product to a group. Customers will
be able to have active subscriptions from
different product groups at the same time. This
can alter your existing upgrade and downgrade
logic, so read the docs{" "}
<a
href="https://docs.useautumn.com/products/create-product#product-groups"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 underline"
>
here
</a>{" "}
to understand how this works.
</p>
<div className="flex gap-4 py-4">
<Input
placeholder="Enter group name"
value={tempGroup}
onChange={(e) =>
setTempGroup(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setProduct({
...product,
group: tempGroup,
});
setGroupModalOpen(false);
}
}}
/>
<div className="flex justify-end">
<Button
onClick={() => {
setProduct({
...product,
group: tempGroup,
});
setGroupModalOpen(false);
}}
>
Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">
Archived
</p>
<Popover
open={archivedOpen}
onOpenChange={setArchivedOpen}
>
<PopoverTrigger
asChild
className="p-0 py-0.5 h-fit"
>
<Button
variant="outline"
className="text-t2 px-2"
>
{product.archived ? "True" : "False"}
</Button>
</PopoverTrigger>
{/** This will not use the setProduct function because otherwise it will create a new
* version of the product.
*/}
<PopoverContent className="w-16 p-1" align="end">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={async () => {
if (product.archived) return;
await ProductService.updateProduct(
axiosInstance,
product.id,
{
archived: true,
},
product.version
);
mutate();
setArchivedOpen(false);
toast.success("Product archived");
}}
>
True
</Button>
<Button
variant="ghost"
className="text-t2 px-2 py-0"
onClick={async () => {
if (!product.archived) return;
await ProductService.updateProduct(
axiosInstance,
product.id,
{
archived: false,
},
product.version
);
mutate();
setArchivedOpen(false);
toast.success("Product unarchived");
}}
>
False
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</>
);
<div className="flex items-center w-full justify-between h-4">
<p className="text-xs text-t3 font-medium text-center">Archived</p>
<div className="px-2">
<ToggleButton
value={product.archived}
setValue={async (value) => {
try {
await ProductService.updateProduct(
axiosInstance,
product.id,
{ archived: value },
product.version
);
await mutate();
toast.success(
value
? "Product archived successfully"
: "Product unarchived successfully"
);
} catch (error) {
toast.error(
getBackendErr(error, "Failed to archive product")
);
}
}}
/>
</div>
</div>
</div>
</div>
</>
);
};

View File

@@ -12,10 +12,9 @@ import { AttachButton } from "@/views/customers/customer/product/components/Atta
import { CustomerProductBadge } from "@/views/customers/customer/product/components/CustomerProductBadge";
import { EntitiesSidebar } from "./product-item/EntitiesSidebar";
import { UpdateProductButton } from "./components/UpdateProductButton";
import { toast } from "sonner";
export default function ProductSidebar() {
const { product, org, setProduct, customer, features, mutate } = useProductContext();
const { product, setProduct, customer } = useProductContext();
const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false);
const [entitiesOpen, setEntitiesOpen] = useState(false);
const [accordionValues, setAccordionValues] = useState([
@@ -36,7 +35,7 @@ export default function ProductSidebar() {
const handleAccordionToggle = (value: string) => {
setAccordionValues((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};

View File

@@ -41,13 +41,13 @@ export const ProductVersions = () => {
`${customer ? `/customers/${customer.id}` : "/products"}/${
product.id
}?version=${value}`,
env,
),
env
)
);
}}
>
<SelectTrigger
className="h-7 w-fit text-sm pr-1 mr-1 h-6 w-12 "
className="text-sm pr-1 mr-1 h-6 w-14 "
iconClassName="size-4 p-0"
>
<SelectValue placeholder="Select version" />
@@ -68,8 +68,8 @@ export const ProductVersions = () => {
? `/customers/${customer.id}`
: "/products"
}/${product.id}?version=${version}`,
env,
),
env
)
);
}}
>

View File

@@ -29,7 +29,7 @@ export const CreateFreeTrial = ({
length: 7,
unique_fingerprint: false,
duration: FreeTrialDuration.Day,
card_required: false,
card_required: true,
});
const handleCreateFreeTrial = async () => {

View File

@@ -23,7 +23,7 @@ export const FreeTrialConfig = ({
length: freeTrial?.length || 7,
unique_fingerprint: freeTrial?.unique_fingerprint || false,
duration: freeTrial?.duration || FreeTrialDuration.Day,
card_required: freeTrial?.card_required ?? false,
card_required: freeTrial?.card_required ?? true,
});
useEffect(() => {
@@ -78,7 +78,7 @@ export const FreeTrialConfig = ({
<span className=" font-mono">fingerprint</span>
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={fields.card_required}

View File

@@ -63,8 +63,15 @@ export const useProductData = ({
return;
}
const hasChanged =
JSON.stringify(sortedProduct) !== JSON.stringify(originalProduct);
// Remove is_default from the product
const { is_default, is_add_on, ...rest } = sortedProduct;
const {
is_default: originalIsDefault,
is_add_on: originalIsAddOn,
...originalRest
} = originalProduct;
const hasChanged = JSON.stringify(rest) !== JSON.stringify(originalRest);
setHasChanges(hasChanged);
}, [product]);

View File

@@ -0,0 +1,211 @@
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
import { ToggleButton } from "@/components/general/ToggleButton";
import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useProductContext } from "../ProductContext";
import { toast } from "sonner";
import { getBackendErr, notNullish } from "@/utils/genUtils";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { isFreeProductV2 } from "@autumn/shared";
const ToggleProductDialog = ({
open,
setOpen,
description,
toggleKey,
value,
toggleProduct,
}: {
open: boolean;
setOpen: (open: boolean) => void;
description: string;
toggleKey: "is_default" | "is_add_on";
value: boolean;
toggleProduct: (value: boolean, optimisticUpdate?: boolean) => Promise<void>;
}) => {
const { product, customer } = useProductContext();
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await toggleProduct(value, false);
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to update product"));
}
setLoading(false);
};
const getTitle = () => {
if (toggleKey === "is_default") {
return value
? `Make ${product.name} a default product`
: `Remove default from ${product.name}`;
} else {
return value
? `Make ${product.name} an add-on`
: `Remove ${product.name} as an add-on`;
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{getTitle()}</DialogTitle>
</DialogHeader>
<DialogDescription>
<p>{description}</p>
</DialogDescription>
<DialogFooter>
<Button
variant="secondary"
isLoading={loading}
onClick={handleConfirm}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const ToggleDefaultProduct = ({
toggleKey,
}: {
toggleKey: "is_default" | "is_add_on";
}) => {
const axiosInstance = useAxiosInstance();
const { product, setProduct, counts, mutate, customer, groupDefaults } =
useProductContext();
const activeCount = counts?.active;
const [open, setOpen] = useState(false);
const [dialogDescription, setDialogDescription] = useState("");
const [toggling, setToggling] = useState(false);
const toggleProduct = async (value: boolean, optimisticUpdate = true) => {
setToggling(true);
if (toggling) {
return;
}
try {
if (optimisticUpdate) {
setProduct({
...product,
[toggleKey]: value,
});
}
const data = {
[toggleKey]: value,
};
await ProductService.updateProduct(axiosInstance, product.id, data);
mutate();
setOpen(false);
toast.success("Successfully updated product");
} catch (error) {
setProduct({
...product,
[toggleKey]: !value,
});
toast.error(getBackendErr(error, "Failed to update product"));
} finally {
setToggling(false);
}
};
const handleToggle = async (value: boolean) => {
if (toggling) return;
const disableDefaultDescription = getDisableDefaultDescription(value);
if (disableDefaultDescription) {
setDialogDescription(disableDefaultDescription);
setOpen(true);
return;
}
if (activeCount > 0) {
const activeCusStr = activeCount === 1 ? "customer" : "customers";
// 1. If key is default
if (toggleKey === "is_default") {
if (value) {
setDialogDescription(
`You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to make this product default?`
);
} else {
setDialogDescription(
`You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to remove this product as default?`
);
}
} else {
if (value) {
setDialogDescription(
`You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to make this product an add-on?`
);
} else {
setDialogDescription(
`You have ${activeCount} active ${activeCusStr} on this product. Are you sure you want to remove this product as an add-on?`
);
}
}
setOpen(true);
} else {
await toggleProduct(value);
}
};
const getDisableDefaultDescription = (value: boolean) => {
// 1. Is default trial
if (toggleKey !== "is_default") return;
const isDefaultTrial =
value && product.free_trial && !isFreeProductV2(product);
if (isDefaultTrial && notNullish(groupDefaults?.defaultTrial)) {
return `${groupDefaults.defaultTrial.name} is currently a default trial product. Making ${product.name} a default trial will remove ${groupDefaults.defaultTrial.name} as a default trial product.`;
}
if (value && notNullish(groupDefaults?.free)) {
return `${groupDefaults.free.name} is currently a default product. Making ${product.name} a default product will remove ${groupDefaults.free.name} as a default product.`;
}
};
const isDisabled =
(toggleKey === "is_add_on" && product.is_default) ||
(toggleKey === "is_default" && product.is_add_on);
return (
<>
<ToggleProductDialog
open={open}
setOpen={setOpen}
description={dialogDescription}
toggleKey={toggleKey}
value={!product[toggleKey]}
toggleProduct={toggleProduct}
/>
<ToggleButton
value={product[toggleKey]}
setValue={handleToggle}
className="text-t2 px-2"
disabled={isDisabled || notNullish(customer)}
// tooltipContent="Default products are the default product for a group. They are used to determine the default product for a customer when they don't have an active subscription."
// infoContent="Default products are the default product for a group. They are used to determine the default product for a customer when they don't have an active subscription."
/>
</>
);
};