fix: 🐛 price vs reset

This commit is contained in:
amianthus
2026-03-19 15:01:05 +00:00
parent 5918717dc6
commit 03e47b3424
9 changed files with 325 additions and 86 deletions

View File

@@ -31,4 +31,5 @@ async function build() {
console.timeEnd(`Generating type declarations`);
}
build();
await build();
process.exit(0);

View File

@@ -44,7 +44,6 @@
"README.md"
],
"dependencies": {
"@autumn/shared": "workspace:*",
"@inkjs/ui": "^2.0.0",
"@inquirer/prompts": "^7.6.0",
"@mishieck/ink-titled-box": "^0.3.0",
@@ -78,6 +77,7 @@
"conf": "^13.0.1"
},
"devDependencies": {
"@autumn/shared": "workspace:*",
"@sindresorhus/tsconfig": "^3.0.1",
"@types/bun": "^1.3.10",
"@types/node": "^24.0.10",

View File

@@ -0,0 +1,261 @@
// AUTO-GENERATED - DO NOT EDIT MANUALLY
// Generated from @autumn/shared display utilities
// Run `pnpm gen:atmn` to regenerate
/**
* Minimal Feature type for display functions
* Matches the shape expected by @autumn/shared display utils
*/
export interface FeatureForDisplay {
name: string;
display?: {
singular?: string;
plural?: string;
} | null;
}
/**
* Format currency amount
* Adapted from @autumn/shared/utils/common/formatUtils/formatAmount.ts
*/
export const formatAmount = ({
amount,
currency = "USD",
maxFractionDigits = 10,
minFractionDigits = 0,
}: {
amount: number;
currency?: string;
maxFractionDigits?: number;
minFractionDigits?: number;
}): string => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: minFractionDigits,
maximumFractionDigits: maxFractionDigits,
}).format(amount);
};
/**
* Format billing interval
* Copied from @autumn/shared/utils/common/formatUtils/formatInterval.ts
*/
export const formatInterval = ({
interval,
intervalCount = 1,
prefix = "per ",
}: {
interval?: string;
intervalCount?: number;
prefix?: string;
}): string => {
if (!interval) return "";
// Handle one_off (show "one time")
if (interval === "one_off") {
return "one-off";
}
// Handle lifetime (no interval string)
if (interval === "lifetime") {
return "";
}
let intervalStr: string = interval;
// Handle special case for semi_annual
if (interval === "semi_annual") {
intervalStr = "half year";
}
if (intervalCount === 1) {
return `${prefix}${intervalStr}`;
}
return `${prefix}${intervalCount} ${intervalStr}s`;
};
/**
* Get feature name with singular/plural handling
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getFeatureName = ({
feature,
plural,
units,
capitalize = false,
}: {
feature?: FeatureForDisplay;
plural?: boolean;
units?: any;
capitalize?: boolean;
}) => {
if (!feature) {
return "";
}
let featureName = feature.name || "";
if (feature.display) {
let finalPlural: boolean | undefined;
// Case 1: If units and nullish plural
if (plural !== undefined) {
finalPlural = plural;
} else {
finalPlural = units !== 1;
}
if (finalPlural) {
featureName = feature.display.plural || featureName;
} else {
featureName = feature.display.singular || featureName;
}
}
if (capitalize) {
featureName = featureName
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
return featureName;
};
/**
* Get feature name with first letter capitalized
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getFeatureNameWithCapital = ({
feature,
}: {
feature: FeatureForDisplay;
}) => {
if (feature.name && feature.name.length > 0) {
return `${feature.name.charAt(0).toUpperCase()}${feature.name.slice(1)}`;
}
return feature.name;
};
/**
* Get both singular and plural forms of feature name
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getSingularAndPlural = ({
feature,
capitalize = false,
}: {
feature: FeatureForDisplay;
capitalize?: boolean;
}) => {
return {
singular: getFeatureName({ feature, plural: false, capitalize }),
plural: getFeatureName({ feature, plural: true, capitalize }),
};
};
/**
* Format a number with commas
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const numberWithCommas = (x: number) => {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 }).format(
x,
);
};
/**
* Get feature name based on usage count (singular/plural)
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const usageToFeatureName = ({
usage,
feature,
}: {
usage: number;
feature: FeatureForDisplay;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
if (usage === 1) {
return singular;
}
return plural;
};
/**
* Get invoice description for a feature
* Adapted from @autumn/shared/utils/displayUtils.ts
* Note: Simplified to remove date-fns dependency
*/
export const getFeatureInvoiceDescription = ({
feature,
usage,
billingUnits = 1,
prodName,
isPrepaid = false,
}: {
feature: FeatureForDisplay;
usage: number;
billingUnits?: number | null;
prodName?: string;
isPrepaid?: boolean;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
const usageStr = numberWithCommas(Math.ceil(usage));
let result = "";
if (isPrepaid && billingUnits && billingUnits > 1) {
result = `${usageStr} x ${billingUnits} ${plural}`; // eg. 4 x 100 credits
} else {
if (usage === 1) {
result = `${usageStr} ${singular}`; // eg. 1 credit
} else {
result = `${usageStr} ${plural}`; // eg. 4 credits
}
}
if (prodName) {
result = `${prodName} - ${result}`;
}
return result;
};
/**
* Format tiered pricing range
*/
export const formatTiers = ({
tiers,
currency = "USD",
}: {
tiers: Array<{ to: number | "inf"; amount: number }>;
currency?: string;
}): string => {
if (tiers.length === 0) return "";
if (tiers.length === 1) {
return formatAmount({ amount: tiers[0].amount, currency });
}
const firstAmount = formatAmount({ amount: tiers[0].amount, currency });
const lastAmount = formatAmount({ amount: tiers[tiers.length - 1].amount, currency });
return `${firstAmount} - ${lastAmount}`;
};

View File

@@ -52,7 +52,7 @@ export const PlanItemSchema = z.object({
}),
tiers: z.array(UsageTierSchema).optional().meta({
description:
"Tiered pricing. Each tier's 'to' does NOT include included amount. Either 'amount' or 'tiers' is required.",
"Tiered pricing. Either 'amount' or 'tiers' is required.",
}),
tier_behavior: z.union([z.literal("graduated"), z.literal("volume")]).optional(),
@@ -245,88 +245,67 @@ type PriceWithAmount = PriceBaseFields & {
tiers?: never;
};
// Price with graduated tiered pricing (no flat amount per tier)
type PriceWithGraduatedTiers = PriceBaseFields & {
// Price with tiered pricing (no flat amount)
type PriceWithTiers = PriceBaseFields & {
/** Cannot have flat amount when using tiers */
amount?: never;
/** Graduated tiered pricing: each tier's amount applies only to units within that tier */
/** Tiered pricing structure based on usage ranges */
tiers: Array<{ to: number | "inf"; amount: number }>;
/** Graduated: each tier's rate applies only to usage within that tier */
tierBehavior: "graduated";
/** Required when tiers is defined: how tiers are applied */
tierBehaviour: "graduated" | "volume";
};
// Price with volume tiered pricing (flat amount per tier)
type PriceWithVolumeTiers = Omit<PriceBaseFields, "billingMethod"> & {
/** Volume pricing does not support usage_based billing — use 'prepaid' */
billingMethod: Exclude<BillingMethod, "usage_based">;
/** Cannot have flat amount when using tiers */
amount?: never;
/** Volume tiered pricing: the tier the total usage falls into applies to all units */
tiers: Array<{ to: number | "inf"; amount: number; flatAmount?: number }>;
/** Volume: the rate of the tier the total usage falls into applies to all units */
tierBehavior: "volume";
};
type PriceWithTiers = PriceWithGraduatedTiers | PriceWithVolumeTiers;
// Price must have either amount OR tiers (not both, not neither)
type PriceAmountOrTiers = PriceWithAmount | PriceWithTiers;
// Price when reset IS defined - interval is forbidden
type PriceWithoutInterval = PriceAmountOrTiers & {
/** Cannot have interval when using top-level reset */
interval?: never;
intervalCount?: never;
};
// Price when reset is NOT defined - interval is required
type PriceWithInterval = PriceAmountOrTiers & {
/** Billing interval - required when no top-level reset */
interval: BillingInterval;
// Price type - interval is optional (omit for one-off/non-recurring)
type Price = PriceAmountOrTiers & {
/** Billing interval - omit for one-off pricing */
interval?: BillingInterval;
/** Number of intervals between billing cycles (default: 1) */
intervalCount?: number;
};
/**
* Plan item with top-level reset configuration.
* Use this for free allocations or features that reset but aren't priced per-use.
* Plan item with a reset cycle (e.g. 100 messages per month).
* Cannot have price — reset and price are mutually exclusive.
*/
export type PlanItemWithReset = PlanItemBaseFields & {
/** Reset configuration for usage limits */
/** Reset configuration for the included allowance */
reset: ResetConfig;
/** Optional pricing (cannot have price.interval when using top-level reset) */
price?: PriceWithoutInterval;
/** Cannot have price when using reset — use price.interval instead */
price?: never;
};
/**
* Plan item with pricing that includes interval configuration.
* Use this for usage-based pricing where interval determines billing cycle.
* Plan item with usage-based pricing (e.g. $0.10/message, billed monthly).
* price.interval encodes the billing cycle, so reset is not allowed.
*/
export type PlanItemWithPriceInterval = PlanItemBaseFields & {
/** Cannot have top-level reset when using price.interval */
export type PlanItemWithPrice = PlanItemBaseFields & {
/** Cannot have reset when using price — price.interval encodes the billing cycle */
reset?: never;
/** Pricing configuration with billing interval */
price: PriceWithInterval;
/** Pricing configuration */
price: Price;
};
/**
* Plan item without any reset configuration.
* Use this for continuous-use features (like seats) that don't reset.
* Plan item with no reset and no price.
* Use for continuous-use or boolean features (e.g. seats, feature flags).
*/
export type PlanItemNoReset = PlanItemBaseFields & {
/** No reset for continuous-use features */
reset?: never;
/** Pricing with required interval (since no top-level reset) */
price?: PriceWithInterval;
/** No price for free/boolean features */
price?: never;
};
/**
* Plan item configuration with mutually exclusive reset patterns:
* - PlanItemWithReset: Top-level reset (for free allocations)
* - PlanItemWithPriceInterval: price.interval (for usage-based pricing billing cycle)
* - PlanItemNoReset: No reset (for continuous-use features like seats)
* Plan item configuration. reset and price are mutually exclusive:
* - PlanItemWithReset: included allowance that resets on an interval (e.g. 100/month free)
* - PlanItemWithPrice: usage-based pricing with its own billing cycle
* - PlanItemNoReset: no reset, no price (continuous-use or boolean features)
*/
export type PlanItem = PlanItemWithReset | PlanItemWithPriceInterval | PlanItemNoReset;
export type PlanItem = PlanItemWithReset | PlanItemWithPrice | PlanItemNoReset;
// Override Plan type to use PlanItem discriminated union

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
import type { ApiFeatureV1 } from "../../../../../../shared/api/features/apiFeatureV1.js";
/**
* ApiFeature - Raw API response type

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import type { ApiPlanV1 } from "../../../../../../shared/api/products/apiPlanV1.js";
/**
* ApiPlan - Raw API response type

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1.js";
import type { ApiPlanItemV1 } from "../../../../../../shared/api/products/items/apiPlanItemV1.js";
/**
* ApiPlanItem - Raw API response type

View File

@@ -58,33 +58,33 @@ export const plans: Plan[] = [
name: "Hobby",
price: { amount: 5, interval: "month" },
items: [
{
featureId: "credits",
included: 500,
reset: { interval: "month" },
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
},
{
featureId: "credits",
included: 500,
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
interval: "month",
},
],
},
{
id: "pro",
name: "Pro",
price: { amount: 20, interval: "month" },
items: [
{
featureId: "credits",
included: 2000,
reset: { interval: "month" },
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
},
},
],
},
{
id: "pro",
name: "Pro",
price: { amount: 20, interval: "month" },
items: [
{
featureId: "credits",
included: 2000,
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
interval: "month",
},
},
],
},
];

View File

@@ -82,15 +82,13 @@ function transformPlanItem(planItem: PlanItem): ApiPlanItemParams {
}
if (planItem.price) {
// Get interval from price.interval if available, otherwise from top-level reset
// Get interval from price.interval (reset and price are mutually exclusive)
const priceWithInterval = planItem.price as {
interval?: string;
intervalCount?: number;
};
const priceInterval = priceWithInterval.interval;
const priceIntervalCount = priceWithInterval.intervalCount;
const interval = priceInterval ?? planItem.reset?.interval;
const intervalCount = priceIntervalCount ?? planItem.reset?.intervalCount;
const interval = priceWithInterval.interval;
const intervalCount = priceWithInterval.intervalCount;
const priceWithBilling = planItem.price as {
billingUnits?: number;