fix: validation for default flag

This commit is contained in:
John Yeo
2025-11-21 09:57:19 +00:00
parent 755e541a2d
commit ebbf833841
27 changed files with 321 additions and 171 deletions

View File

@@ -44,10 +44,13 @@ export const getSentryTags = ({
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env || "unknown",
auth_type: ctx.authType,
request_id: ctx.id || "",
customer_id: customerId,
message_id: messageId,
path: path,
method: method,
email: ctx.user?.email,
};
};

View File

@@ -56,7 +56,9 @@ export const handleConnectWebhook = async (c: Context<HonoEnv>) => {
webhookSecret,
);
} catch (err: any) {
logger.error(`Webhook verification error: ${err.message}`);
if (process.env.NODE_ENV !== "development") {
logger.warn(`Webhook verification error: ${err.message}`);
}
return c.json({ error: err.message }, 400);
}

View File

@@ -81,6 +81,9 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ctx.env = env;
ctx.userId = userId;
ctx.authType = AuthType.SecretKey;
if (data?.user) {
ctx.user = data.user;
}
await next();
};

View File

@@ -4,6 +4,7 @@ import type {
AuthType,
Feature,
Organization,
User,
} from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import type { DrizzleCli } from "@/db/initDrizzle.js";
@@ -15,6 +16,7 @@ export type RequestContext = {
org: Organization;
env: AppEnv;
features: Feature[];
user?: User;
userId?: string;
// Objects

View File

@@ -276,6 +276,11 @@ export const deductFromCusEnts = async ({
});
}
} catch (error) {
if (error instanceof Error && !error?.message?.includes("declined")) {
ctx.logger.error(
`[deductFromCusEnts] Attempting rollback due to error: ${error}`,
);
}
await rollbackDeduction({
ctx,
oldFullCus,

View File

@@ -2,11 +2,9 @@ import {
ApiVersion,
type AttachConfig,
AttachScenario,
ErrCode,
InternalError,
SuccessCode,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
@@ -20,7 +18,6 @@ import {
attachToInsertParams,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
attachParamsToCurCusProduct,
getCustomerSchedule,
@@ -62,18 +59,14 @@ export const handleScheduleFunction2 = async ({
});
if (!curSub) {
throw new RecaseError({
throw new InternalError({
message: `SCHEDULE FLOW, curSub is undefined`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (!curCusProduct) {
throw new RecaseError({
throw new InternalError({
message: `SCHEDULE FLOW, curCusProduct is undefined`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}

View File

@@ -4,6 +4,7 @@ import {
type AttachConfig,
BillingType,
ErrCode,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
@@ -13,7 +14,6 @@ import {
getPriceEntitlement,
priceIsOneOffAndTiered,
} from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import type { AttachFlags } from "../models/AttachFlags.js";
@@ -129,8 +129,6 @@ const handlePrepaidErrors = async ({
) {
throw new RecaseError({
message: `Quantity + included usage exceeds usage limit of ${usageLimit} for feature ${priceEnt.feature_id}`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
}
@@ -151,15 +149,11 @@ export const handleCustomPaymentMethodErrors = ({
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
} else if (attachParams.customer.processors?.vercel?.installation_id) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
};
@@ -228,8 +222,6 @@ export const handleAttachErrors = async ({
throw new RecaseError({
message:
"Not allowed to update current product when using publishable key",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}

View File

@@ -4,16 +4,14 @@ import {
cusProductToPrices,
cusProductToProduct,
type EntitlementWithFeature,
ErrCode,
type FullCusProduct,
type FullCustomer,
type Price,
ProrationBehavior,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct.js";
import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js";
@@ -95,13 +93,13 @@ export const handleCancelProduct = async ({
// 2. If there's a scheduled product, throw error?
const isMain = !cusProduct.product.is_add_on;
const product = cusProductToProduct({ cusProduct });
const isFree = isFreeProduct(product.prices || []);
if (isMain) {
if (cusProduct.canceled && !expireImmediately) {
throw new RecaseError({
message: `Product ${cusProduct.product.name} is already about to cancel at the end of cycle.`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
@@ -111,15 +109,12 @@ export const handleCancelProduct = async ({
) {
throw new RecaseError({
message: `Please delete scheduled product ${curScheduledProduct.product.name} first`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
// 2. If expire at cycle end, just cancel subscriptions
if (!expireImmediately) {
const product = cusProductToProduct({ cusProduct });
if (!expireImmediately && !isFree) {
const defaultProduct = await getDefaultProduct({
req,
productGroup: product.group,
@@ -170,7 +165,6 @@ export const handleCancelProduct = async ({
}
// Cancel product immediately
const product = cusProductToProduct({ cusProduct });
await handleUpgradeFlow({
req,
res: null,

View File

@@ -31,6 +31,7 @@ export class ApiKeyService {
master: true,
},
},
user: true,
},
});
@@ -51,6 +52,7 @@ export class ApiKeyService {
features: (data.org.features || []) as Feature[],
env,
userId: data.user_id,
user: data.user || null,
};
return result;

View File

@@ -25,7 +25,7 @@ export const handleGetEntity = createRoute({
withAutumnId: with_autumn_id,
});
const duration = Date.now() - start;
console.debug(`[get-entity] duration: ${duration}ms`);
ctx.logger.debug(`[get-entity] duration: ${duration}ms`);
return c.json(apiEntity);
},

View File

@@ -80,6 +80,7 @@ export const updateFeature = async ({
objectsUsingFeature;
// Handle ID change
if (isChangingId && updates.id) {
await handleFeatureIdChanged({
ctx,

View File

@@ -2,6 +2,7 @@ import {
type Entitlement,
ErrCode,
type Feature,
FeatureAlreadyExistsError,
type Price,
RecaseError,
type UsagePriceConfig,
@@ -30,6 +31,10 @@ export const handleFeatureIdChanged = async ({
newId: string;
}) => {
const { db, org, env } = ctx;
const curFeature = ctx.features.find((f) => f.id === feature.id);
if (curFeature) {
throw new FeatureAlreadyExistsError({ featureId: feature.id });
}
// 1. Check if any customer entitlement linked to this feature
const cusEnts = await CusEntService.getByFeature({

View File

@@ -11,12 +11,10 @@ import {
type FullProduct,
type Price,
ProductAlreadyExistsError,
type ProductV2,
planToProductV2,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
@@ -26,68 +24,9 @@ import {
} from "../free-trials/freeTrialUtils.js";
import { ProductService } from "../ProductService.js";
import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js";
import { isDefaultTrial } from "../productUtils/classifyProduct.js";
import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse.js";
import {
constructProduct,
getGroupToDefaults,
initProductInStripe,
} from "../productUtils.js";
export const disableCurrentDefault = async ({
req,
newProduct,
// items,
// freeTrial,
}: {
req: AutumnContext;
newProduct: CreateProductV2Params | ProductV2;
// items: ProductItem[];
// freeTrial: FreeTrial;
}) => {
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 });
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 },
});
}
}
};
import { constructProduct, initProductInStripe } from "../productUtils.js";
import { validateDefaultFlag } from "./productActions/validateDefaultFlag.js";
/**
* Route: POST /products - Create a product
@@ -121,9 +60,9 @@ export const handleCreatePlan = createRoute({
// 1. If existing product, throw error
if (existing) throw new ProductAlreadyExistsError({ productId: body.id });
await disableCurrentDefault({
req: ctx,
newProduct: v1_2Body as CreateProductV2Params,
await validateDefaultFlag({
ctx,
body: v1_2Body,
});
const backendProduct = constructProduct({

View File

@@ -32,8 +32,8 @@ import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js";
import { initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreatePlan.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { validateDefaultFlag } from "../productActions/validateDefaultFlag.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
export const handleUpdatePlan = createRoute({
@@ -106,9 +106,16 @@ export const handleUpdatePlan = createRoute({
free_trial: newFreeTrial || curProductV2.free_trial || undefined,
};
await disableCurrentDefault({
req: ctx,
newProduct: newProductV2,
await validateDefaultFlag({
ctx,
body: v1_2Body,
curProduct: fullProduct,
});
validateDefaultFlag({
ctx,
body: v1_2Body,
curProduct: fullProduct,
});
await handleUpdateProductDetails({

View File

@@ -18,7 +18,7 @@ import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
import { constructProduct, initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreatePlan.js";
import { validateDefaultFlag } from "./validateDefaultFlag.js";
export const createProduct = async ({
ctx,
@@ -39,9 +39,9 @@ export const createProduct = async ({
// 1. If existing product, throw error
if (existing) throw new ProductAlreadyExistsError({ productId: data.id });
await disableCurrentDefault({
req: ctx,
newProduct: data,
await validateDefaultFlag({
ctx,
body: data,
});
const product = await ProductService.insert({

View File

@@ -22,9 +22,9 @@ import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
import { initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreatePlan.js";
import { handleUpdateProductDetails } from "../handleUpdateProduct/updateProductDetails.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { validateDefaultFlag } from "./validateDefaultFlag.js";
export interface UpdateProductParams {
ctx: AutumnContext;
@@ -92,9 +92,10 @@ export const updateProduct = async ({
free_trial: newFreeTrial,
};
await disableCurrentDefault({
req: ctx,
newProduct: newProductV2,
await validateDefaultFlag({
ctx,
body: updates,
curProduct: fullProduct,
});
await handleUpdateProductDetails({

View File

@@ -0,0 +1,141 @@
import {
type CreateProductV2Params,
type FullProduct,
isDefaultTrial,
isDefaultTrialV2,
isFreeProductV2,
isOneOffProductV2,
RecaseError,
type UpdateProductV2Params,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { ProductService } from "../../ProductService";
import {
getGroupToDefaults,
isFreeProduct,
isOneOff,
} from "../../productUtils";
export const disableCurrentDefault = async ({
ctx,
body,
curProduct,
type,
}: {
ctx: AutumnContext;
body: CreateProductV2Params | UpdateProductV2Params;
curProduct?: FullProduct;
type: "free" | "default_trial";
}) => {
const { db, org, env, logger } = ctx;
let defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
defaultProds = defaultProds.filter((prod) => prod.id !== curProduct?.id);
if (defaultProds.length === 0) return;
const defaults = getGroupToDefaults({
defaultProds,
})?.[body.group || ""];
if (type === "default_trial") {
const curDefault = defaults?.defaultTrial;
if (curDefault) {
throw new RecaseError({
message: `You have another default trial product in this group (${curDefault.id}). Please remove default from that product first.`,
});
}
// 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 (type === "free") {
const curDefault = defaults?.free;
throw new RecaseError({
message: `You have another default product (${curDefault.id}). Please remove default from that product first.`,
});
// 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 validateDefaultFlag = async ({
ctx,
body,
curProduct,
}: {
ctx: AutumnContext;
body: CreateProductV2Params | UpdateProductV2Params;
curProduct?: FullProduct;
}) => {
const validate = (): { type: "free" | "default_trial" | undefined } => {
const isDefault = body.is_default || curProduct?.is_default || false;
if (!isDefault) return { type: undefined };
// If default, check if there are any prices...?
const isFree = body.items
? isFreeProductV2({ items: body.items })
: isFreeProduct(curProduct?.prices || []);
if (isFree) return { type: "free" };
// 1. Check if it's a one off product
const isOneOffProduct = body.items
? isOneOffProductV2({ items: body.items })
: isOneOff(curProduct?.prices || []);
if (isOneOffProduct) {
throw new RecaseError({
message: `Cannot make a plan default if it has prices on it.`,
});
}
// 2. Check if it is default trial
const freeTrial = body.free_trial || curProduct?.free_trial;
const defaultTrial = body.items
? isDefaultTrialV2({
freeTrial: freeTrial ?? undefined,
items: body.items,
isDefault,
})
: isDefaultTrial({
freeTrial: freeTrial ?? undefined,
isDefault,
prices: curProduct?.prices || [],
});
if (defaultTrial) return { type: "default_trial" };
throw new RecaseError({
message: `Cannot make a plan default if it has prices on it.`,
});
};
const { type } = validate();
if (type) {
await disableCurrentDefault({
ctx,
body,
curProduct,
type,
});
}
};

View File

@@ -20,6 +20,7 @@ import {
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import type { Logger } from "../../external/logtail/logtailUtils";
export const getUpgradeProrationInvoiceItem = ({
prevPrice,
@@ -33,6 +34,7 @@ export const getUpgradeProrationInvoiceItem = ({
product,
stripeSub,
subItem,
logger,
}: {
prevPrice: number;
newPrice: number;
@@ -45,6 +47,7 @@ export const getUpgradeProrationInvoiceItem = ({
product: Product;
stripeSub: Stripe.Subscription;
subItem: Stripe.SubscriptionItem;
logger: Logger;
}) => {
const billingUnits = (price.config as UsagePriceConfig).billing_units;
let invoiceAmount = new Decimal(newPrice).minus(prevPrice).toNumber();
@@ -55,8 +58,8 @@ export const getUpgradeProrationInvoiceItem = ({
prodName: product.name,
});
console.log("Invoice amount: ", invoiceAmount);
console.log("Invoice description:", invoiceDescription);
logger.info(`Invoice amount before proration: ${invoiceAmount}`);
logger.info(`Invoice description: ${invoiceDescription}`);
if (shouldProrate(onIncrease)) {
invoiceAmount = calculateProrationAmount({
@@ -68,6 +71,7 @@ export const getUpgradeProrationInvoiceItem = ({
const start = formatUnixToDate(now);
const end = formatUnixToDate(subItem.current_period_end * 1000);
invoiceDescription = `${invoiceDescription} (from ${start} to ${end})`;
}
@@ -83,6 +87,10 @@ export const getUpgradeProrationInvoiceItem = ({
periodEnd: Math.floor(subItem.current_period_end * 1000),
});
logger.info(`Final invoice item (amount: ${invoiceItem?.amount})`, {
data: invoiceItem,
});
return invoiceItem;
};
@@ -113,7 +121,7 @@ export const createUpgradeProrationInvoice = async ({
product: Product;
config: UsagePriceConfig;
onIncrease: OnIncrease;
logger: any;
logger: Logger;
}) => {
const now = await getStripeNow({ stripeCli, stripeSub: sub });
@@ -134,6 +142,7 @@ export const createUpgradeProrationInvoice = async ({
product,
stripeSub: sub,
subItem,
logger,
});
const invoiceAmount =

View File

@@ -20,7 +20,6 @@ import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"
import { getReplaceables } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
shouldBillNow,
shouldProrate,
shouldProrateDowngradeNow,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";

View File

@@ -170,14 +170,18 @@ export const handleProratedUpgrade = async ({
ids: reps.map((r) => r.id),
});
const newQuantity = roundUsage({
usage: newUsage,
price: cusPrice.price,
});
logger.info(`New sub item quantity: ${newQuantity}`);
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: roundUsage({
usage: newUsage,
price: cusPrice.price,
}),
quantity: newQuantity,
proration_behavior: "none",
});
logger.info(`Updated sub item ${subItem.id} to quantity: ${newRoundedUsage}`);
logger.info(`Updated sub item ${subItem.id} successfully!`);
return { deletedReplaceables: deleted, invoice, newReplaceables: [] };
};

View File

@@ -1,18 +1,13 @@
import { beforeAll, describe, test } from "bun:test";
import { BillingInterval, LegacyVersion } from "@autumn/shared";
import { LegacyVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachFailedPaymentMethod } from "../../src/external/stripe/stripeCusUtils.js";
import { CusService } from "../../src/internal/customers/CusService.js";
import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js";
import { advanceClockForInvoice } from "../utils/stripeUtils.js";
import { replaceItems } from "../utils/testProductUtils/testProductUtils.js";
// UNCOMMENT FROM HERE
const pro = constructProduct({
@@ -80,6 +75,24 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
let testClockId: string;
beforeAll(async () => {
const cusId1 = "temp1";
const cusId2 = "temp2";
try {
await autumn.customers.delete(cusId1);
} catch (_error) {}
await Promise.all([
autumn.check({
customer_id: cusId1,
feature_id: TestFeature.Messages,
}),
autumn.check({
customer_id: cusId1,
feature_id: TestFeature.Messages,
}),
]);
return;
const result = await initCustomerV3({
ctx,
customerId,
@@ -106,49 +119,8 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
value: 100,
feature_id: TestFeature.Messages,
});
return;
const cus = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
await attachFailedPaymentMethod({
stripeCli: ctx.stripeCli,
customer: cus!,
});
await advanceClockForInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId,
});
});
return;
test("should attach pro product", async () => {
// newPro = structuredClone(pro);
let newItems = replaceItems({
items: pro.items,
interval: BillingInterval.Month,
newItem: constructPriceItem({
price: 100,
interval: BillingInterval.Month,
}),
});
newItems = replaceItems({
items: newItems,
featureId: TestFeature.Words,
newItem: constructArrearItem({
featureId: TestFeature.Words,
price: 0.5,
}),
});
await autumn.products.update(pro.id, {
items: newItems,
});
});
test("should attach pro product", async () => {});
});

View File

@@ -6,13 +6,13 @@ import {
ProductItemInterval,
} from "@autumn/shared";
import type { ApiCustomerV1 } from "@shared/api/customers/previousVersions/apiCustomerV1.js";
import chalk from "chalk";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js";
import { timeout } from "@tests/utils/genUtils.js";
import { completeCheckoutForm } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructFeatureItem,

View File

@@ -1,12 +1,12 @@
import { beforeAll, describe, test } from "bun:test";
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
import chalk from "chalk";
import { addWeeks } from "date-fns";
import type Stripe from "stripe";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addWeeks } from "date-fns";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";

View File

@@ -26,7 +26,7 @@ export const completeCheckoutForm = async (
_isLocal?: boolean,
) => {
const browser = await puppeteer.launch({
headless: false,
headless: true,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

View File

@@ -1,4 +1,5 @@
import { relations } from "drizzle-orm";
import { user } from "../../db/auth-schema.js";
import { organizations } from "../orgModels/orgTable.js";
import { apiKeys } from "./apiKeyTable.js";
@@ -7,4 +8,8 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
fields: [apiKeys.org_id],
references: [organizations.id],
}),
user: one(user, {
fields: [apiKeys.user_id],
references: [user.id],
}),
}));

View File

@@ -1,11 +1,47 @@
import { Decimal } from "decimal.js";
import type { ProductItem, ProductV2 } from "../index.js";
import type {
CreateFreeTrial,
FreeTrial,
Price,
ProductItem,
ProductV2,
} from "../index.js";
import {
isFeaturePriceItem,
isPriceItem,
} from "./productV2Utils/productItemUtils/getItemType.js";
import { nullish } from "./utils.js";
export const isDefaultTrialV2 = ({
freeTrial,
items,
isDefault,
}: {
freeTrial?: CreateFreeTrial;
items: ProductItem[];
isDefault: boolean;
}) => {
if (!freeTrial) return false;
return (
freeTrial.card_required === false &&
isDefault &&
!isFreeProductV2({ items })
);
};
export const isOneOffProductV2 = ({ items }: { items: ProductItem[] }) => {
return (
items.some((i) => isPriceItem(i) || isFeaturePriceItem(i)) &&
items.every((i) => {
if (isPriceItem(i) || isFeaturePriceItem(i)) {
return i.interval === null;
}
return true;
})
);
};
export const isFreeProductV2 = ({ items }: { items: ProductItem[] }) => {
return items.every((item) => nullish(item.price) && nullish(item.tiers));
};
@@ -76,3 +112,37 @@ export const sortProductsV2 = ({ products }: { products: ProductV2[] }) => {
return isUpgrade ? -1 : 1;
});
};
export const isFreeProduct = ({ prices }: { prices: Price[] }) => {
if (prices.length === 0) {
return true;
}
let totalPrice = 0;
for (const price of prices) {
if ("usage_tiers" in price.config) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice += tiers.reduce((acc, tier) => acc + tier.amount, 0);
} else {
totalPrice += price.config.amount;
}
}
return totalPrice === 0;
};
export const isDefaultTrial = ({
freeTrial,
isDefault,
prices,
}: {
freeTrial?: CreateFreeTrial | FreeTrial;
isDefault: boolean;
prices: Price[];
}) => {
if (!freeTrial) return false;
return (
freeTrial.card_required === false && isDefault && !isFreeProduct({ prices })
);
};

View File

@@ -28,6 +28,7 @@ export const updateProduct = async ({
if (!validated) {
return false;
}
try {
const updateData = UpdateProductV2ParamsSchema.parse({
...product,