fix: validation for default flag
This commit is contained in:
3
server/src/external/sentry/sentryUtils.ts
vendored
3
server/src/external/sentry/sentryUtils.ts
vendored
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -80,6 +80,7 @@ export const updateFeature = async ({
|
||||
objectsUsingFeature;
|
||||
|
||||
// Handle ID change
|
||||
|
||||
if (isChangingId && updates.id) {
|
||||
await handleFeatureIdChanged({
|
||||
ctx,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: [] };
|
||||
};
|
||||
|
||||
@@ -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 () => {});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const updateProduct = async ({
|
||||
if (!validated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = UpdateProductV2ParamsSchema.parse({
|
||||
...product,
|
||||
|
||||
Reference in New Issue
Block a user