fix: 🐛 price vs reset
This commit is contained in:
@@ -31,4 +31,5 @@ async function build() {
|
||||
console.timeEnd(`Generating type declarations`);
|
||||
}
|
||||
|
||||
build();
|
||||
await build();
|
||||
process.exit(0);
|
||||
|
||||
@@ -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",
|
||||
|
||||
261
packages/atmn/src/commands/preview/displayUtils.ts
Normal file
261
packages/atmn/src/commands/preview/displayUtils.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user