feat: 🎸 wip

This commit is contained in:
amianthus
2025-10-16 18:14:02 +01:00
parent 6a1fa62fdb
commit 8cff234011
16 changed files with 457 additions and 137 deletions

View File

@@ -1,4 +1,8 @@
import {
AffectedResource,
type ApiPlan,
ApiVersion,
CreatePlanParamsSchema,
type CreateProductV2Params,
CreateProductV2ParamsSchema,
type Entitlement,
@@ -20,8 +24,9 @@ import {
} from "../free-trials/freeTrialUtils.js";
import { ProductService } from "../ProductService.js";
import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js";
import { planToProductV2 } from "../productUtils/apiPlanUtils/planToProductV2.js";
import { isDefaultTrial } from "../productUtils/classifyProduct.js";
import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js";
import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse.js";
import {
constructProduct,
getGroupToDefaults,
@@ -87,12 +92,19 @@ export const disableCurrentDefault = async ({
* Route: POST /products - Create a product
*/
export const createProduct = createRoute({
body: CreateProductV2ParamsSchema,
// body: CreateProductV2ParamsSchema,
versionedBody: {
latest: CreatePlanParamsSchema,
[ApiVersion.V1_1]: CreateProductV2ParamsSchema,
},
resource: AffectedResource.Product,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
// const query = c.req.valid("query");
const v1_2Body = planToProductV2({ plan: body as ApiPlan });
const { logger, org, features, env, db } = ctx;
const existing = await ProductService.get({
@@ -107,19 +119,19 @@ export const createProduct = createRoute({
await disableCurrentDefault({
req: ctx,
newProduct: body,
newProduct: v1_2Body,
});
const product = await ProductService.insert({
db,
product: constructProduct({
productData: body,
productData: v1_2Body as CreateProductV2Params,
orgId: org.id,
env,
}),
});
const { items, free_trial } = body;
const { items, free_trial } = v1_2Body;
let prices: Price[] = [];
let entitlements: Entitlement[] = [];
@@ -159,6 +171,7 @@ export const createProduct = createRoute({
const newFullProduct: FullProduct = {
...product,
description: body?.description ?? null,
prices,
entitlements: getEntsWithFeature({ ents: entitlements, features }),
free_trial: newFreeTrial,
@@ -179,11 +192,23 @@ export const createProduct = createRoute({
},
});
const productResponse = await getProductResponse({
product: newFullProduct,
features,
});
// const productResponse = await getProductResponse({
// product: newFullProduct,
// features,
// });
return c.json(productResponse);
try {
const planResponse = await getPlanResponse({
product: newFullProduct,
features,
});
console.log(`Plan response:\n ${JSON.stringify(planResponse, null, 4)}`);
return c.json(planResponse);
} catch (error) {
console.error("Error getting plan response:", error);
throw error;
}
},
});

View File

@@ -292,6 +292,13 @@ export const handleNewProductItems = async ({
});
}
console.log(`
Deletion Confirmations:
- New ${newEnts.length} entitlements: ${JSON.stringify(newEnts, null, 4)}
- Updated ${updatedEnts.length} entitlements: ${JSON.stringify(updatedEnts, null, 4)}
- Deleted ${deletedEnts.length} entitlements: ${JSON.stringify(deletedEnts, null, 4)}
`);
return {
prices: [...newPrices, ...updatedPrices],
entitlements: [...newEnts, ...updatedEnts].map((ent) => ({

View File

@@ -1,9 +1,14 @@
import {
type ApiPlanFeature,
ApiPlanFeatureSchema,
type BillingInterval,
Infinite,
type OnDecrease,
type OnIncrease,
type ProductItem,
type ResetInterval,
ResetInterval,
TierInfinite,
type UsageModel,
} from "@autumn/shared";
import {
itemIntvToResetIntv,
@@ -30,60 +35,57 @@ export const itemsToPlanFeatures = ({
// Convert intervals
reset_interval: item.interval
? (itemIntvToResetIntv(item.interval!) as ResetInterval)
: undefined,
reset_interval_count: item.interval_count ?? undefined,
: ResetInterval.OneOff,
...(item.interval_count !== undefined && item.interval_count !== null
? {
reset_interval_count: item.interval_count,
}
: {}),
// Convert price if exists
price: item.price
...(item.price
? {
amount: item.price,
tiers: item.tiers?.map((tier) => ({
amount: tier.amount,
to: tier.to === "inf" ? null : tier.to,
})),
interval: item.interval, // Billing interval (might need conversion)
interval_count: item.interval_count,
billing_units: item.billing_units,
usage_model: item.usage_model,
max_purchase: item.usage_limit
? item.usage_limit -
(item.included_usage === Infinite
? 0
: (item.included_usage ?? 0))
: undefined,
price: {
interval: item.interval as unknown as BillingInterval,
billing_units: item.billing_units ?? 1,
usage_model: item.usage_model as UsageModel,
max_purchase: 1,
amount: item.price || 0,
tiers: item.tiers?.map((tier) => ({
to: tier.to === TierInfinite ? TierInfinite : tier.to,
amount: tier.amount,
})),
interval_count: item.interval_count ?? undefined,
},
}
: undefined,
: {}),
// Convert rollover config
...(item.config?.rollover
? [
{
rollover: {
max: item.config.rollover.max ?? null,
expiry_duration_type: rolloverToResetIntv(
item.config.rollover.duration,
),
expiry_duration_length: item.config.rollover.length,
},
? {
rollover: {
max: item.config.rollover.max ?? null,
expiry_duration_type: rolloverToResetIntv(
item.config.rollover.duration,
),
expiry_duration_length: item.config.rollover.length,
},
]
: []),
}
: {}),
// Convert proration config
...(item.config?.on_increase || item.config?.on_decrease
? [
{
proration: {
on_increase: item.config.on_increase ?? undefined,
on_decrease: item.config.on_decrease ?? undefined,
},
? {
proration: {
on_increase: item.config.on_increase as OnIncrease,
on_decrease: item.config.on_decrease as OnDecrease,
},
]
: []),
}
: {}),
// Other fields
reset_usage_on_enabled: item.reset_usage_when_enabled ?? true,
entity_feature_id: item.entity_feature_id,
// entity_feature_id: item.entity_feature_id,
} satisfies ApiPlanFeature),
);
};

View File

@@ -3,6 +3,7 @@ import {
Infinite,
type ProductItem,
ProductItemSchema,
type ResetInterval,
} from "@autumn/shared";
import {
resetIntvToItemIntv,
@@ -14,18 +15,20 @@ export const planFeaturesToItems = ({
}: {
features: ApiPlanFeature[];
}): ProductItem[] =>
features.map((feature) =>
(features ?? []).map((feature) =>
ProductItemSchema.parse({
feature_id: feature.feature_id,
included_usage: feature.unlimited ? Infinite : feature.granted,
interval: resetIntvToItemIntv(feature.reset_interval),
interval: resetIntvToItemIntv(feature.reset_interval as ResetInterval),
interval_count: feature.reset_interval_count,
config: {
rollover: feature.rollover
? {
max: feature.rollover.max,
duration: resetIntvToRollover(feature.rollover.expiry_duration_type),
duration: resetIntvToRollover(
feature.rollover.expiry_duration_type,
),
length: feature.rollover.expiry_duration_length ?? 0,
}
: undefined,

View File

@@ -1,27 +1,56 @@
import { type ProductV2, ProductV2Schema } from "@autumn/shared";
import {
type CreateProductV2Params,
CreateProductV2ParamsSchema,
} from "@autumn/shared";
import type { ApiPlan } from "@shared/api/products/apiPlan.js";
import { constructPriceItem } from "../../product-items/productItemUtils.js";
import { planFeaturesToItems } from "./planFeatureUtils/planFeaturesToItems.js";
export const planToProductV2 = ({ plan }: { plan: ApiPlan }): ProductV2 => {
return ProductV2Schema.parse({
id: plan.id,
name: plan.name,
is_add_on: plan.add_on,
is_default: plan.default,
version: plan.version,
group: plan.group,
items: [
...planFeaturesToItems({
features: plan.features,
}),
plan.price
? constructPriceItem({
price: plan.price.amount,
interval: plan.price.interval,
})
: undefined,
],
archived: plan.archived,
});
export const planToProductV2 = ({
plan,
}: {
plan: ApiPlan;
}): CreateProductV2Params => {
try {
const featureItems = planFeaturesToItems({
features: plan.features,
});
const items = [...featureItems];
// Only add price item if there isn't a price on a feature already (aka: a "base price" product)
const hasPriceItem = featureItems?.some(
(item) => typeof item.price === "number" && item.price > 0,
);
if (plan.price && !hasPriceItem) {
items.push(
constructPriceItem({
price: plan.price.amount,
interval: plan.price.interval,
}),
);
}
return CreateProductV2ParamsSchema.parse({
id: plan.id,
name: plan.name,
is_add_on: plan.add_on,
is_default: plan.default,
version: plan.version,
group: plan.group ?? "",
items,
free_trial: plan.free_trial
? {
duration: plan.free_trial.duration_type,
length: plan.free_trial.duration_length,
unique_fingerprint: false,
card_required: plan.free_trial.card_required,
}
: null,
} satisfies CreateProductV2Params);
} catch (error) {
console.error("Error converting plan to product V2:", error);
throw error;
}
};

View File

@@ -2,9 +2,11 @@ import {
type ApiPlan,
ApiPlanSchema,
type ApiProduct,
isPriceItem,
type ProductV2,
productV2ToBasePrice,
} from "@autumn/shared";
import { itemsToPlanFeatures } from "./planFeatureUtils/itemsToPlanFeatures.js";
/**
* Convert Product V2 response format to Plan V2 format
*/
@@ -13,6 +15,10 @@ export const productV2ToPlan = ({
}: {
product: ApiProduct;
}): ApiPlan => {
const basePrice = productV2ToBasePrice({ product: product as ProductV2 });
if (basePrice)
product.items = product.items?.filter((item) => !isPriceItem(item)) ?? [];
return ApiPlanSchema.parse({
// Basic fields
id: product.id,
@@ -25,24 +31,21 @@ export const productV2ToPlan = ({
add_on: product.is_add_on,
default: product.is_default,
// Price - need to extract from items or set default
// This might need adjustment based on how price is determined
price: {
amount: 0, // You may need to calculate this from items
interval: "month", // Default, adjust as needed
},
...(basePrice ? { price: basePrice } : {}),
// Convert items to features
features: product.items ? itemsToPlanFeatures({ items: product.items }) : [],
features: itemsToPlanFeatures({ items: product.items ?? [] }),
// Free trial - might need schema conversion
free_trial: product.free_trial
...(product.free_trial
? {
duration_type: product.free_trial.duration,
duration_length: product.free_trial.length,
card_required: product.free_trial.card_required ?? false,
free_trial: {
duration_type: product.free_trial.duration,
duration_length: product.free_trial.length,
card_required: product.free_trial.card_required ?? false,
},
}
: null,
: {}),
// Misc fields
created_at: product.created_at,
@@ -50,4 +53,4 @@ export const productV2ToPlan = ({
archived: product.archived,
base_variant_id: product.base_variant_id,
});
};
};

View File

@@ -0,0 +1,209 @@
import {
type ApiFreeTrialV2,
ApiFreeTrialV2Schema,
type ApiPlan,
ApiPlanSchema,
AttachScenario,
type BillingInterval,
type Feature,
type FeatureOptions,
type FullCustomer,
type FullProduct,
productV2ToBasePrice,
productV2ToFeatureItems,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js";
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
import { mapToProductItems } from "../../productV2Utils.js";
import { itemsToPlanFeatures } from "../apiPlanUtils/planFeatureUtils/itemsToPlanFeatures.js";
import { getAttachScenario } from "./getAttachScenario.js";
/**
* Get free trial response in Plan V2 format
*/
const getFreeTrialV2Response = async ({
db,
product,
fullCus,
attachScenario,
}: {
db?: DrizzleCli;
product: FullProduct;
fullCus?: FullCustomer;
attachScenario: AttachScenario;
}): Promise<ApiFreeTrialV2 | null> => {
if (!product.free_trial) return null;
// Check trial availability if customer exists
if (db && fullCus) {
const trial = await getFreeTrialAfterFingerprint({
db,
freeTrial: product.free_trial,
fingerprint: fullCus.fingerprint,
internalCustomerId: fullCus.internal_id,
multipleAllowed: false,
productId: product.id,
});
// No trial for downgrades
if (attachScenario === AttachScenario.Downgrade || !trial) {
return null;
}
}
return ApiFreeTrialV2Schema.parse({
duration_type: product.free_trial.duration,
duration_length: product.free_trial.length,
card_required: product.free_trial.card_required ?? false,
});
};
/**
* Convert FullProduct (DB format) to Plan API response format
*/
export const getPlanResponse = async ({
product,
features,
fullCus,
db,
options,
}: {
product: FullProduct;
features: Feature[];
fullCus?: FullCustomer;
db?: DrizzleCli;
options?: FeatureOptions[];
}): Promise<ApiPlan> => {
// 1. Convert prices/entitlements to items
const rawItems = mapToProductItems({
prices: product.prices,
entitlements: product.entitlements,
features: features,
});
// 2. Sort items
const sortedItems = sortProductItems(rawItems, features);
// 3. Create a ProductV2-like object for the helper
const productV2 = { items: sortedItems };
// 4. Extract base price using existing helper
const basePrice = productV2ToBasePrice({ product: productV2 as any });
// 5. Get feature items only (no base price)
const featureItems = productV2ToFeatureItems({
items: sortedItems,
withBasePrice: false, // Don't include base price in features
});
// 6. Convert items to plan features
const planFeatures = itemsToPlanFeatures({ items: featureItems });
// 7. Get attach scenario for customer context
const attachScenario = getAttachScenario({
fullCus,
fullProduct: product,
});
// 8. Get free trial in V2 format
const freeTrial = await getFreeTrialV2Response({
db,
product,
fullCus,
attachScenario,
});
console.log(
`New feature:\n ${JSON.stringify(
{
// Basic fields
id: product.id,
name: product.name || "",
description: product.description, // Products don't have descriptions
group: product.group,
version: product.version,
// Boolean flags
add_on: product.is_add_on,
default: product.is_default,
// Price field (required in Plan schema)
price: basePrice
? {
amount: basePrice.amount,
interval: basePrice.interval as unknown as BillingInterval,
}
: {
amount: 0,
interval: "month" as BillingInterval,
},
// Features array
features: planFeatures,
// Free trial
free_trial: freeTrial,
// Metadata fields
created_at: product.created_at,
env: product.env,
archived: product.archived,
base_variant_id: product.base_variant_id,
// Customer context (optional)
// Uncomment when ready to add customer context
// customer_context: {
// trial_available: notNullish(freeTrial),
// scenario: attachScenario,
// },
},
null,
4,
)}`,
);
// 9. Build Plan response
return ApiPlanSchema.parse({
// Basic fields
id: product.id,
name: product.name || "",
description: product.description, // Products don't have descriptions
group: product.group,
version: product.version,
// Boolean flags
add_on: product.is_add_on,
default: product.is_default,
// Price field (required in Plan schema)
price: basePrice
? {
amount: basePrice.amount,
interval: basePrice.interval as unknown as BillingInterval,
}
: {
amount: 0,
interval: "month" as BillingInterval,
},
// Features array
features: planFeatures,
// Free trial
free_trial: freeTrial,
// Metadata fields
created_at: product.created_at,
env: product.env,
archived: product.archived,
base_variant_id: product.base_variant_id,
// Customer context (optional)
// Uncomment when ready to add customer context
// customer_context: {
// trial_available: notNullish(freeTrial),
// scenario: attachScenario,
// },
});
};

View File

@@ -5,17 +5,8 @@ import { BillingInterval } from "@models/productModels/priceModels/priceEnums.js
import { z } from "zod/v4";
import { ApiPlanFeatureSchema } from "./planFeature/apiPlanFeature.js";
export enum ResetInterval {
OneOff = "one_off",
Minute = "minute",
Hour = "hour",
Day = "day",
Week = "week",
Month = "month",
Quarter = "quarter",
SemiAnnual = "semi_annual",
Year = "year",
}
// Re-export for backward compatibility
export { ResetInterval } from "./planEnums.js";
export const ApiFreeTrialV2Schema = z.object({
duration_type: z.enum(FreeTrialDuration),
@@ -23,6 +14,8 @@ export const ApiFreeTrialV2Schema = z.object({
card_required: z.boolean(),
});
export type ApiFreeTrialV2 = z.infer<typeof ApiFreeTrialV2Schema>;
export const ApiPlanSchema = z.object({
id: z.string(),
name: z.string(),
@@ -40,7 +33,7 @@ export const ApiPlanSchema = z.object({
}),
features: z.array(ApiPlanFeatureSchema),
free_trial: ApiFreeTrialV2Schema.nullable(),
free_trial: ApiFreeTrialV2Schema.nullable().optional(),
// Misc
created_at: z.number(),

View File

@@ -0,0 +1,15 @@
/**
* Shared enums for Plan API to avoid circular dependencies
*/
export enum ResetInterval {
OneOff = "one_off",
Minute = "minute",
Hour = "hour",
Day = "day",
Week = "week",
Month = "month",
Quarter = "quarter",
SemiAnnual = "semi_annual",
Year = "year",
}

View File

@@ -6,7 +6,7 @@ import {
} from "@models/productV2Models/productItemModels/productItemEnums.js";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
import { ResetInterval } from "../apiPlan.js";
import { ResetInterval } from "../planEnums.js";
export const ApiPlanFeatureSchema = z
.object({
@@ -14,49 +14,70 @@ export const ApiPlanFeatureSchema = z
granted: z.number(),
unlimited: z.boolean(),
reset_interval: z.enum(ResetInterval),
reset_interval: z.enum(ResetInterval).optional(),
reset_interval_count: z.number().optional(),
reset_usage_on_enabled: z.boolean(),
reset_usage_on_enabled: z.boolean().optional(),
price: z.object({
amount: z.number().optional(),
tiers: z.array(UsageTierSchema).optional(),
price: z
.object({
amount: z.number().optional(),
tiers: z.array(UsageTierSchema).optional(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
billing_units: z.number(),
usage_model: z.enum(UsageModel),
billing_units: z.number(),
usage_model: z.enum(UsageModel),
// Use max_purchase for pay per use features
max_purchase: z.number(),
}),
// Use max_purchase for pay per use features
max_purchase: z.number().optional(),
})
.optional(),
proration: z.object({
on_increase: z.enum(OnIncrease),
on_decrease: z.enum(OnDecrease),
}),
proration: z
.object({
on_increase: z.enum(OnIncrease).optional(),
on_decrease: z.enum(OnDecrease).optional(),
})
.optional(),
rollover: z.object({
max: z.number(),
expiry_duration_type: z.enum(ResetInterval),
expiry_duration_length: z.number().optional(),
}),
rollover: z
.object({
max: z.number().nullable(),
expiry_duration_type: z.enum(ResetInterval),
expiry_duration_length: z.number().optional(),
})
.optional(),
})
.check((ctx) => {
const resetGroup =
ctx.value.reset_interval || ctx.value.reset_interval_count !== undefined;
const intervalGroup =
ctx.value.price.interval || ctx.value.price.interval_count !== undefined;
ctx.value.price?.interval ||
ctx.value.price?.interval_count !== undefined;
if (resetGroup && intervalGroup) {
ctx.issues.push({
code: "custom",
message:
"reset_interval/reset_interval_count and interval/interval_count are mutually exclusive.",
"Top level reset intervals and feature price intervals are mutually exclusive.",
input: ctx.value,
});
}
if (ctx.value.price) {
if (
ctx.value.price.amount &&
ctx.value.price.tiers &&
ctx.value.price.tiers.length > 0
) {
ctx.issues.push({
code: "custom",
message: "Price amount and tiers are mutually exclusive.",
input: ctx.value,
});
}
}
});
// .refine((x) => {

View File

@@ -16,4 +16,4 @@ export type ApiVersionString = `${ApiVersion}`;
export const API_VERSIONS = Object.values(ApiVersion);
export const LATEST_VERSION = ApiVersion.V1_2;
export const LATEST_VERSION = ApiVersion.V2;

View File

@@ -124,13 +124,16 @@ export function createdAtToVersion({
}: {
createdAt?: number;
}): ApiVersionClass {
const v2_0 = new Date("2025-10-15").getTime();
const v1_2 = new Date("2025-05-05").getTime();
const v1_1 = new Date("2025-04-17").getTime();
const v0_2 = new Date("2025-01-30").getTime();
let version: ApiVersion;
if (!createdAt || createdAt >= v1_2) {
if (!createdAt || createdAt >= v2_0) {
version = ApiVersion.V2;
} else if (createdAt >= v1_2) {
version = ApiVersion.V1_2;
} else if (createdAt >= v1_1) {
version = ApiVersion.V1_1;
@@ -140,7 +143,8 @@ export function createdAtToVersion({
version = ApiVersion.V0_1;
}
return new ApiVersionClass(version);
// return new ApiVersionClass(version);
return new ApiVersionClass(ApiVersion.V2);
}
// Convert org creation date

View File

@@ -150,4 +150,17 @@ export * from "./utils/productV2Utils/compareProductUtils.ts/compareProductUtils
export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
export * from "./utils/productV3Utils/mapToProductV3.js";
export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils.js";
export * from "./utils/rewardUtils/rewardMigrationUtils.js";
export enum ResetInterval {
OneOff = "one_off",
Minute = "minute",
Hour = "hour",
Day = "day",
Week = "week",
Month = "month",
Quarter = "quarter",
SemiAnnual = "semi_annual",
Year = "year",
}

View File

@@ -50,12 +50,9 @@ export const UpdateProductSchema = z.object({
});
export const FullProductSchema = ProductSchema.extend({
description: z.string().nullable().optional().default(null),
prices: z.array(PriceSchema),
entitlements: z.array(
EntitlementSchema.extend({
feature: FeatureSchema,
}),
),
entitlements: z.array(EntitlementSchema.extend({ feature: FeatureSchema })),
free_trial: FreeTrialSchema.nullish(),
free_trials: z.array(FreeTrialSchema).nullish(),
free_trial_ids: z.array(z.string()).nullish(),

View File

@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import {
boolean,
foreignKey,
@@ -7,9 +8,8 @@ import {
text,
unique,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { organizations } from "../orgModels/orgTable.js";
import { collatePgColumn, sqlNow } from "../../db/utils.js";
import { organizations } from "../orgModels/orgTable.js";
type ProductProcessor = {
type: string;
@@ -22,6 +22,7 @@ export const products = pgTable(
internal_id: text("internal_id").primaryKey().notNull(),
id: text().notNull(),
name: text(),
description: text(),
org_id: text("org_id").notNull(),
created_at: numeric({ mode: "number" }).notNull().default(sqlNow),
env: text().notNull(),

View File

@@ -1,5 +1,4 @@
import { z } from "zod/v4";
import { ApiProductPropertiesSchema } from "../../api/products/apiProduct.js";
import { AttachScenario } from "../checkModels/checkPreviewModels.js";
import { AppEnv } from "../genModels/genEnums.js";
import { ProductItemInterval } from "../productV2Models/productItemModels/productItemModels.js";
@@ -30,5 +29,4 @@ export const PlanResponseSchema = z.object({
// base_variant_id: z.string().nullable(),
scenario: z.nativeEnum(AttachScenario).optional(),
properties: ApiProductPropertiesSchema.optional(),
});