fix: default toggle
This commit is contained in:
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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")}`, () => {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const CreateFreeTrial = ({
|
||||
length: 7,
|
||||
unique_fingerprint: false,
|
||||
duration: FreeTrialDuration.Day,
|
||||
card_required: false,
|
||||
card_required: true,
|
||||
});
|
||||
|
||||
const handleCreateFreeTrial = async () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user