Merge branch 'main' into staging

This commit is contained in:
John Yeo
2025-10-24 16:17:01 +01:00
56 changed files with 721 additions and 494 deletions

View File

@@ -8,16 +8,16 @@ if [[ "$1" == *"setup"* ]]; then
MOCHA_PARALLEL=true $MOCHA_SETUP
fi
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/referrals/paid/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \
# 'tests/advanced/referrals/*.ts' \
# 'tests/advanced/referrals/paid/*.ts' \
# 'tests/advanced/rollovers/*.ts' \
# 'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
'tests/advanced/usageLimit/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -10,6 +10,7 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
@@ -105,7 +106,7 @@ export const createStripePrepaid = async ({
stripePrice = await stripeCli.prices.create({
...productData,
unit_amount_decimal: unitAmountDecimalStr,
currency: org.default_currency!,
currency: orgToCurrency({ org }),
});
config.stripe_product_id = stripePrice.product as string;
@@ -128,7 +129,7 @@ export const createStripePrepaid = async ({
stripePrice = await stripeCli.prices.create({
...productData,
currency: org.default_currency!,
currency: orgToCurrency({ org }),
...priceAmountData,
recurring: {
...(recurringData as any),

View File

@@ -1,9 +1,14 @@
import type {
EntitlementWithFeature,
FeatureOptions,
Organization,
Price,
UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { FeatureOptions, Organization, UsagePriceConfig } from "@autumn/shared";
import { EntitlementWithFeature } from "@autumn/shared";
import { Price } from "@autumn/shared";
import { Decimal } from "decimal.js";
export const priceToOneOffAndTiered = ({
price,
@@ -19,8 +24,8 @@ export const priceToOneOffAndTiered = ({
stripeProductId: string;
}) => {
const config = price.config as UsagePriceConfig;
let quantity = options?.quantity!;
let overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
const quantity = options?.quantity!;
const overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
// let overage = quantity * config.billing_units! - relatedEnt.allowance!;
// if (overage <= 0) {
@@ -39,7 +44,7 @@ export const priceToOneOffAndTiered = ({
? config.stripe_product_id
: stripeProductId,
unit_amount: Number(amount.toFixed(2)) * 100,
currency: org.default_currency,
currency: orgToCurrency({ org }),
},
quantity: 1,
@@ -58,11 +63,11 @@ export const priceToUsageInAdvance = ({
isCheckout: boolean;
}) => {
const config = price.config as UsagePriceConfig;
let optionsQuantity = options?.quantity;
const optionsQuantity = options?.quantity;
let finalQuantity = optionsQuantity;
// 1. If adjustable quantity is set, use that, else if quantity is undefined, adjustable is true, else false
let adjustable = notNullish(options?.adjustable_quantity)
const adjustable = notNullish(options?.adjustable_quantity)
? options!.adjustable_quantity
: nullish(optionsQuantity)
? true

View File

@@ -136,3 +136,21 @@ export const getPlaceholderItem = ({
quantity: 0,
};
};
export const createEmptySubItem = ({
recurring,
stripeProductId,
}: {
recurring: Stripe.PriceCreateParams.Recurring;
stripeProductId: string;
}) => {
return {
price_data: {
product: stripeProductId,
unit_amount: 1,
currency: "usd",
recurring,
},
quantity: 0,
} satisfies Stripe.SubscriptionCreateParams.Item;
};

View File

@@ -1,4 +1,4 @@
import Stripe from "stripe";
import type Stripe from "stripe";
export const getLatestPeriodEnd = ({
sub,
@@ -34,7 +34,7 @@ export const getEarliestPeriodStart = ({
}: {
sub: Stripe.Subscription;
}) => {
if (sub.items.data.length == 0) {
if (sub.items.data.length === 0) {
return Date.now();
}
@@ -43,7 +43,7 @@ export const getEarliestPeriodStart = ({
}, sub.items.data[0].current_period_start);
};
export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => {
if (sub.items.data.length == 0) {
if (sub.items.data.length === 0) {
return Date.now();
}
@@ -53,7 +53,7 @@ export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => {
};
export const subToPeriodStartEnd = ({ sub }: { sub?: Stripe.Subscription }) => {
if (!sub || sub.items.data.length == 0) {
if (!sub || sub.items.data.length === 0) {
return {
start: Date.now(),
end: Date.now(),

View File

@@ -1,6 +1,5 @@
import {
ApiVersion,
AppEnv,
type Customer,
EntInterval,
type FullCusProduct,
@@ -21,7 +20,6 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js";
import { getInvoiceItemForUsage } from "../../stripePriceUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js";
import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
export const handleUsagePrices = async ({
db,
@@ -136,14 +134,6 @@ export const handleUsagePrices = async ({
return;
}
const allFullCustomers = await getAllFullCustomers({
db,
orgId: org.id,
env: AppEnv.Live,
});
console.log(`All full customers: ${allFullCustomers.length}`);
const ent = relatedCusEnt.entitlement;
const resetBalancesUpdate = getResetBalancesUpdate({

View File

@@ -77,9 +77,11 @@ export const handleConnectWebhook = async (c: Context<HonoEnv>) => {
org = data.org;
features = data.features;
} catch {
logger.error(
`Account ID ${accountId} not linked to any org, skipping Stripe webhook`,
);
if (process.env.NODE_ENV !== "development") {
logger.error(
`Account ID ${accountId} not linked to any org, skipping Stripe webhook`,
);
}
return c.json(
{ message: "Account ID not linked to any org, skipping Stripe webhook" },
200,

View File

@@ -77,6 +77,42 @@ const STRIPE_RULES = [
statusCode: 400,
code: ErrCode.InvalidRequest,
},
{
name: "Card declined error",
match: (err: Error) =>
err instanceof Stripe.errors.StripeError &&
err.message.includes("Your card was declined."),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
{
name: "Cannot delete org with production customers",
match: (err: Error) =>
err instanceof Stripe.errors.StripeError &&
err.message.includes("Cannot delete org with production mode customers"),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
{
name: "Webhook endpoint limit reached",
match: (err: Error) =>
err instanceof Stripe.errors.StripeError &&
err.message.includes(
"You have reached the maximum of 16 test webhook endpoints",
),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
{
name: "Invalid URL scheme error",
match: (err: Error) =>
err instanceof Stripe.errors.StripeError &&
err.message.includes(
"Invalid URL: An explicit scheme (such as https) must be provided",
),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
] as const;
/** Zod-specific error handling rules */

View File

@@ -1,10 +1,9 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { Job, Queue } from "bullmq";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { JobName } from "@/queue/JobName.js";
import { getLock, releaseLock } from "@/queue/lockUtils.js";
import { Queue } from "bullmq";
import { Job } from "bullmq";
import { handleProductsUpdated } from "./handlers/handleProductsUpdated.js";
import { handleCustomerCreated } from "./handlers/handleCustomerCreated.js";
import { handleProductsUpdated } from "./handlers/handleProductsUpdated.js";
export const runActionHandlerTask = async ({
queue,
@@ -19,12 +18,12 @@ export const runActionHandlerTask = async ({
db: DrizzleCli;
useBackup: boolean;
}) => {
let payload = job.data;
let internalCustomerId = payload.internalCustomerId;
let lockKey = `action:${internalCustomerId}`;
const payload = job.data;
const internalCustomerId = payload.internalCustomerId;
const lockKey = `action:${internalCustomerId}`;
try {
let lock = await getLock({ queue, job, lockKey, useBackup });
const lock = await getLock({ queue, job, lockKey, useBackup });
if (!lock) return;
switch (job.name) {
@@ -44,11 +43,7 @@ export const runActionHandlerTask = async ({
break;
}
} catch (error: any) {
logger.error("Error processing action handler job:", {
// jobName: job.name,
// payload,
message: error.message,
});
logger.error(`Error processing action handler job: ${error.message}`);
} finally {
await releaseLock({ lockKey, useBackup });
}

View File

@@ -9,6 +9,7 @@ import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js";
@@ -124,12 +125,13 @@ export const handleCreateCheckout = async ({
line_items: items,
subscription_data: subscriptionData,
mode: isRecurring ? "subscription" : "payment",
currency: org.default_currency,
currency: orgToCurrency({ org }),
success_url: successUrl || toSuccessUrl({ org, env: customer.env }),
allow_promotion_codes: allowPromotionCodes,
invoice_creation: !isRecurring ? { enabled: true } : undefined,
saved_payment_method_options: { payment_method_save: "enabled" },
...rewardData,
...(attachParams.checkoutSessionParams || {}),
metadata: {
@@ -166,11 +168,7 @@ export const handleCreateCheckout = async ({
);
} catch (error: any) {
const msg = error.message;
if (
msg &&
msg.includes("No valid payment method types") &&
!paymentMethodSet
) {
if (msg?.includes("No valid payment method types") && !paymentMethodSet) {
checkout = await stripeCli.checkout.sessions.create({
...sessionParams,
payment_method_types: ["card"],

View File

@@ -18,6 +18,7 @@ import {
attachToInvoiceResponse,
insertInvoiceFromAttach,
} from "@/internal/invoices/invoiceUtils.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
@@ -99,7 +100,7 @@ export const handleOneOffFunction = async ({
description,
price_data: {
unit_amount: new Decimal(amount).mul(100).round().toNumber(),
currency: org.default_currency,
currency: orgToCurrency({ org }),
product: price.config?.stripe_product_id || product?.processor?.id!,
},
};
@@ -135,7 +136,7 @@ export const handleOneOffFunction = async ({
let stripeInvoice = await stripeCli.invoices.create({
customer: customer.processor.id!,
auto_advance: false,
currency: org.default_currency!,
currency: orgToCurrency({ org }),
discounts: rewards ? rewards.map((r) => ({ coupon: r.id })) : undefined,
collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined,
days_until_due: attachParams.invoiceOnly ? 30 : undefined,

View File

@@ -6,6 +6,7 @@ import {
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
@@ -66,7 +67,7 @@ export const getDowngradeProductPreview = async ({
// console.log("Items:", items);
return {
currency: attachParams.org.default_currency,
currency: orgToCurrency({ org: attachParams.org }),
due_next_cycle: {
line_items: items,
due_at: nextCycleAt,

View File

@@ -5,15 +5,21 @@ import { isStripeConnected } from "../../orgUtils.js";
export const handleGetStripeAccount = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { org, env } = ctx;
const { org, env, logger } = ctx;
if (!isStripeConnected({ org, env })) {
return c.json(null);
}
const stripeCli = createStripeCli({ org, env });
const account_details = await stripeCli.accounts.retrieve();
return c.json(account_details);
try {
const stripeCli = createStripeCli({ org, env });
const accountDetails = await stripeCli.accounts.retrieve();
return c.json(accountDetails);
} catch (error) {
logger.warn(
`Failed to retrieve Stripe account for org ${org.slug}, ${error}`,
);
return c.json(null);
}
},
});

View File

@@ -306,3 +306,7 @@ export const unsetOrgStripeKeys = async ({
},
});
};
export const orgToCurrency = ({ org }: { org: Organization }) => {
return org.default_currency || "usd";
};

View File

@@ -3,6 +3,7 @@ import {
member,
type Organization,
organizations,
RecaseError,
user as userTable,
} from "@autumn/shared";
import { generateId } from "better-auth";
@@ -82,19 +83,25 @@ export const handleCreatePlatformOrg = createRoute({
)
.limit(1);
const orgExists = OrgService.getBySlug({
const orgExists = await OrgService.getBySlug({
db,
slug: orgSlug,
});
let org: Organization;
if (orgExists && existingMembership.length === 0) {
throw new RecaseError({
message: `Organization with slug '${orgSlug}' already exists but ${user_email} is not a member`,
});
}
let org: Organization & { master?: Organization | null };
if (existingMembership.length === 0) {
// Create new organization
const orgId = generateId();
console.log(`Creating new organization: ${orgId} (${orgSlug})`);
[org] = await db
const [insertedOrg] = await db
.insert(organizations)
.values({
id: orgId,
@@ -107,6 +114,8 @@ export const handleCreatePlatformOrg = createRoute({
})
.returning();
org = { ...insertedOrg, master: masterOrg };
// Create membership
await db.insert(member).values({
id: generateId(),
@@ -121,7 +130,7 @@ export const handleCreatePlatformOrg = createRoute({
logger.info(`Created new organization: ${org.id} (${orgSlug})`);
} else {
org = existingMembership[0].organizations;
org = { ...existingMembership[0].organizations, master: masterOrg };
logger.info(`Found existing organization: ${org.id} (${orgSlug})`);
}

View File

@@ -78,6 +78,10 @@ platformBetaRouter.post(
"/organization/stripe",
...handleUpdateOrganizationStripe,
);
platformBetaRouter.post(
"/organizations/stripe",
...handleUpdateOrganizationStripe,
);
platformBetaRouter.get("/users", ...listPlatformUsers);

View File

@@ -90,7 +90,7 @@ export const constructProduct = ({
is_add_on: productData.is_add_on,
is_default: productData.is_default,
version: productData.version || 1,
group: productData.group,
group: productData.group || "",
env,
internal_id: generateId("prod"),
@@ -447,6 +447,7 @@ export const copyProduct = async ({
db,
product: {
...ProductSchema.parse(newProduct),
// group: newProduct.group || "",
version: 1,
},
});

View File

@@ -142,6 +142,7 @@ export const productsAreSame = ({
item1: item,
item2: similarItem!,
features,
logDifferences: false,
});
if (!same) {

View File

@@ -88,6 +88,8 @@ export const addProductFromSubs = async ({
sub,
});
const disableFreeTrial = true;
const newCusProduct = await createFullCusProduct({
db,
attachParams: {
@@ -108,10 +110,12 @@ export const addProductFromSubs = async ({
entityId: entity?.id,
isCustom: isCustom,
},
disableFreeTrial,
logger,
trialEndsAt: trialEndsAt || undefined,
subscriptionIds: sub ? [sub.id] : [],
anchorToUnix: anchorToUnix || end,
anchorToUnix: anchorToUnix || end * 1000,
subscriptionStatus: sub?.status
? (stripeToAutumnSubStatus(sub?.status) as CusProductStatus)

View File

@@ -1,7 +1,6 @@
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import qs from "qs";
import Stripe from "stripe";
import { ZodAny, ZodError, ZodObject } from "zod";
import { withSpan as withSpanTracer } from "@/internal/analytics/tracer/spanUtils.js";
import RecaseError, {
@@ -9,6 +8,7 @@ import RecaseError, {
handleRequestError,
} from "./errorUtils.js";
import type { ExtendedRequest } from "./models/Request.js";
import { handleExpressErrorSkip } from "./routerUtils/expressErrorSkip.js";
/**
* Parses query parameters with proper type coercion for validation
@@ -263,78 +263,25 @@ export const routeHandler = async <TLoad = undefined>({
});
}
} catch (error) {
if (error instanceof RecaseError) {
if (error.code === ErrCode.EntityNotFound) {
req.logger.warn(`${error.message}, org: ${req.org?.slug || req.orgId}`);
return res.status(404).json({
message: error.message,
code: error.code,
});
}
}
const originalUrl = req.originalUrl;
if (error instanceof Stripe.errors.StripeError) {
if (
originalUrl.includes("/exchange") &&
error.message.includes("Invalid API Key provided")
) {
req.logger.warn(`Exchange router, invalid API Key provided`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
if (
error.message.includes("not a valid email address") ||
error.message.includes("email: Invalid input")
) {
req.logger.warn(`Invalid email address`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
if (
originalUrl.includes("/billing_portal") &&
error.message.includes("Provide a configuration or create your default")
) {
req.logger.warn(`Billing portal config error, org: ${req.org?.slug}`);
return res.status(404).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
if (
originalUrl.includes("/billing_portal") &&
error.message.includes(
"Invalid URL: An explicit scheme (such as https)",
)
) {
req.logger.warn(
`Billing portal return_url error, org: ${req.org?.slug}, return_url: ${req.body.return_url}`,
);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Check if error should be skipped (logged as warning)
const skipResponse = handleExpressErrorSkip({ error, req, res });
if (skipResponse) {
return skipResponse;
}
// Handle Zod errors on /attach endpoint
let handledError = error;
if (error instanceof ZodError && req.originalUrl.includes("/attach")) {
error = new RecaseError({
handledError = new RecaseError({
message: formatZodError(error as any),
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// Handle all other errors
handleRequestError({
error,
error: handledError,
req,
res,
action,

View File

@@ -0,0 +1,152 @@
import { ErrCode } from "@autumn/shared";
import Stripe from "stripe";
import RecaseError from "../errorUtils.js";
type ExpressRequest = {
originalUrl: string;
logger: {
warn: (message: string) => void;
};
org?: {
slug?: string;
};
orgId?: string;
body?: any;
};
type ExpressResponse = {
status: (code: number) => {
json: (data: any) => any;
};
};
/**
* Checks if an error should be handled as a warning instead of an error.
* Returns response object if handled, null otherwise.
*/
export const handleExpressErrorSkip = ({
error,
req,
res,
}: {
error: any;
req: ExpressRequest;
res: ExpressResponse;
}) => {
const originalUrl = req.originalUrl;
// Handle RecaseError with EntityNotFound code
if (error instanceof RecaseError) {
if (error.code === ErrCode.EntityNotFound) {
req.logger.warn(`${error.message}, org: ${req.org?.slug || req.orgId}`);
return res.status(404).json({
message: error.message,
code: error.code,
});
}
}
// Handle Stripe errors
if (error instanceof Stripe.errors.StripeError) {
// Exchange router invalid API key
if (
originalUrl.includes("/exchange") &&
error.message.includes("Invalid API Key provided")
) {
req.logger.warn("Exchange router, invalid API Key provided");
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Invalid email address
if (
error.message.includes("not a valid email address") ||
error.message.includes("email: Invalid input")
) {
req.logger.warn("Invalid email address");
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Billing portal config error
if (
originalUrl.includes("/billing_portal") &&
error.message.includes("Provide a configuration or create your default")
) {
req.logger.warn(`Billing portal config error, org: ${req.org?.slug}`);
return res.status(404).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Billing portal return_url error
if (
originalUrl.includes("/billing_portal") &&
error.message.includes("Invalid URL: An explicit scheme (such as https)")
) {
req.logger.warn(
`Billing portal return_url error, org: ${req.org?.slug}, return_url: ${req.body?.return_url}`,
);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Card declined error
if (error.message.includes("Your card was declined.")) {
req.logger.warn(`Card declined error, org: ${req.org?.slug}`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Cannot delete org with production customers
if (
error.message.includes("Cannot delete org with production mode customers")
) {
req.logger.warn(
`Cannot delete org with production customers, org: ${req.org?.slug}`,
);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Webhook endpoint limit reached
if (
error.message.includes(
"You have reached the maximum of 16 test webhook endpoints",
)
) {
req.logger.warn(`Webhook endpoint limit reached, org: ${req.org?.slug}`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
// Generic invalid URL scheme error
if (
error.message.includes(
"Invalid URL: An explicit scheme (such as https) must be provided",
)
) {
req.logger.warn(`Invalid URL scheme error, org: ${req.org?.slug}`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
}
// No skip case matched
return null;
};

View File

@@ -5,9 +5,13 @@ import { subItemToAutumnInterval } from "@/external/stripe/utils.js";
export const logSubItems = ({
sub,
subItems,
withPriceId = false,
withItemId = false,
}: {
sub?: Stripe.Subscription;
subItems?: Stripe.SubscriptionItem[];
withPriceId?: boolean;
withItemId?: boolean;
}) => {
const finalSubItems = subItems || sub!.items.data;
for (const item of finalSubItems) {
@@ -24,7 +28,7 @@ export const logSubItems = ({
const price = atmnPrice;
const subInterval = subItemToAutumnInterval(item);
console.log(
`${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval}`,
`${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`,
);
}
}

View File

@@ -48,7 +48,7 @@ describe(`${chalk.yellowBright("basic5: Testing cancel through Stripe at period
await timeout(5000);
});
test.skip("should have pro product active, and canceled_at != null, and free scheduled", async () => {
test("should have pro product active, and canceled_at != null, and free scheduled", async () => {
const cusRes: any = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: products.pro,

View File

@@ -2,9 +2,9 @@ import { beforeAll, describe, test } from "bun:test";
import chalk from "chalk";
import { AutumnCli } from "tests/cli/AutumnCli.js";
import { features, products } from "tests/global.js";
import { compareMainProduct } from "tests/utils/compare.js";
import { timeout } from "tests/utils/genUtils.js";
import { completeCheckoutForm } from "tests/utils/stripeUtils.js";
import { compareMainProduct } from "tests/utils/compare.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";

View File

@@ -135,7 +135,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro
const entity2Res = await autumn.entities.get(customerId, entity2.id);
const premiumProd = entity2Res.products.find(
(p: any) => p.id == premium.id,
(p: any) => p.id === premium.id,
);
expect(premiumProd).toBeDefined();
expect(premiumProd.status).toBe(CusProductStatus.Active);

View File

@@ -130,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create entity payme
it("should try to create entities and fail", async () => {
await expectAutumnError({
errMessage: "(Stripe Error) Your card was declined.",
errMessage: "Your card was declined.",
func: async () => {
await autumn.entities.create(customerId, [
{

View File

@@ -65,17 +65,22 @@ export const ProductItemSchema = z.object({
feature_id: z.string().nullish(),
feature_type: z.nativeEnum(ProductItemFeatureType).nullish(),
included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(),
interval: z.preprocess((val) => {
if (val === "") {
throw new Error("Interval cannot be empty.");
}
return val;
}, z.enum(ProductItemInterval).nullish()),
interval: z
.enum(ProductItemInterval, {
error: (issue) => {
if (issue.input === "") {
return {
message: "Interval cannot be empty.",
};
}
},
})
.nullish(),
interval_count: z.number().nullish(),
entity_feature_id: z.string().nullish(),
// Price config
usage_model: z.nativeEnum(UsageModel).nullish(),
usage_model: z.enum(UsageModel).nullish(),
price: z.number().nullish(),
tiers: z.array(PriceTierSchema).nullish(),
billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units)

View File

@@ -1,5 +1,5 @@
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import { nullish } from "../utils.js";
export const productToCusProduct = ({
@@ -18,19 +18,44 @@ export const productToCusProduct = ({
inStatuses?: CusProductStatus[];
}) => {
if (cusProductId) {
return cusProducts.find((cusProduct) => cusProduct.id === cusProductId);
return cusProducts.find((cusProduct) => {
const cusProductIdMatch = cusProduct.id === cusProductId;
const versionMatch = version
? cusProduct.product.version === version
: true;
const prodIdMatch = cusProduct.product.id === productId;
const entityMatch = internalEntityId
? cusProduct.internal_entity_id === internalEntityId
: nullish(cusProduct.internal_entity_id);
const statusMatch = inStatuses
? inStatuses.includes(cusProduct.status)
: true;
return (
cusProductIdMatch &&
versionMatch &&
prodIdMatch &&
entityMatch &&
statusMatch
);
});
}
return cusProducts.find((cusProduct) => {
let prodIdMatch = cusProduct.product.id === productId;
const versionMatch = version
? cusProduct.product.version === version
: true;
let entityMatch = internalEntityId
const prodIdMatch = cusProduct.product.id === productId;
const entityMatch = internalEntityId
? cusProduct.internal_entity_id === internalEntityId
: nullish(cusProduct.internal_entity_id);
let versionMatch = version ? cusProduct.product.version === version : true;
let statusMatch = inStatuses
const statusMatch = inStatuses
? inStatuses.includes(cusProduct.status)
: true;

View File

@@ -1,8 +1,8 @@
import { FeatureType } from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js";
import { Infinite } from "../models/productModels/productEnums.js";
import type {
ProductItem,
import {
type ProductItem,
ProductItemInterval,
} from "../models/productV2Models/productItemModels/productItemModels.js";
import {
@@ -58,11 +58,19 @@ export const getIntervalString = ({
interval: ProductItemInterval | null | undefined;
intervalCount?: number | null;
}) => {
let intervalStr: string = interval || "";
if (interval === ProductItemInterval.SemiAnnual) {
intervalStr = "half year";
}
console.log("intervalStr", intervalStr);
if (!interval) return "";
if (intervalCount === 1) {
return `per ${interval}`;
return `per ${intervalStr}`;
}
return `per ${intervalCount} ${interval}s`;
return `per ${intervalCount} ${intervalStr}s`;
};
export const getFeatureItemDisplay = ({

View File

@@ -105,9 +105,11 @@ const tiersAreSame = (
export const featureItemsAreSame = ({
item1,
item2,
logDifferences = false,
}: {
item1: FeatureItem;
item2: FeatureItem;
logDifferences?: boolean;
}) => {
const checks = {
feature_id: {
@@ -135,15 +137,22 @@ export const featureItemsAreSame = ({
item1.reset_usage_when_enabled == item2.reset_usage_when_enabled,
message: `Reset usage when enabled different: ${item1.reset_usage_when_enabled} !== ${item2.reset_usage_when_enabled}`,
},
config: {
condition: JSON.stringify(item1.config) === JSON.stringify(item2.config),
message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`,
rollover_config: {
condition: rolloversAreSame({
rollover1: item1.config?.rollover || undefined,
rollover2: item2.config?.rollover || undefined,
}),
message: `Rollover config different: ${JSON.stringify(item1.config?.rollover)} !== ${JSON.stringify(item2.config?.rollover)}`,
},
// config: {
// condition: JSON.stringify(item1.config) === JSON.stringify(item2.config),
// message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`,
// },
};
const same = Object.values(checks).every((d) => d.condition);
if (!same) {
if (!same && logDifferences) {
console.log(
"Feature items different:",
Object.values(checks)
@@ -158,16 +167,18 @@ export const featureItemsAreSame = ({
export const priceItemsAreSame = ({
item1,
item2,
logDifferences = false,
}: {
item1: PriceItem;
item2: PriceItem;
logDifferences?: boolean;
}) => {
const same =
item1.price === item2.price &&
item1.interval == item2.interval &&
(item1.interval_count || 1) == (item2.interval_count || 1);
if (!same) {
if (!same && logDifferences) {
console.log(`Price items different: ${item1.price}`);
}
@@ -210,9 +221,11 @@ const rolloversAreSame = ({
export const featurePriceItemsAreSame = ({
item1,
item2,
logDifferences = false,
}: {
item1: FeaturePriceItem;
item2: FeaturePriceItem;
logDifferences?: boolean;
}) => {
// console.log("Item 1 config:", item1.config);
// console.log("Item 2 config:", item2.config);
@@ -297,7 +310,7 @@ export const featurePriceItemsAreSame = ({
const pricesChanged = Object.values(pricesSame).some((d) => !d.condition);
if (!same) {
if (!same && logDifferences) {
console.log(
"Feature price items different:",
Object.values(entsSame)
@@ -319,10 +332,12 @@ export const itemsAreSame = ({
item1,
item2,
features,
logDifferences = false,
}: {
item1: ProductItem;
item2: ProductItem;
features?: Feature[];
logDifferences?: boolean;
}) => {
// 1. If feature item
let same = false;
@@ -339,6 +354,7 @@ export const itemsAreSame = ({
same = featureItemsAreSame({
item1: item1 as FeatureItem,
item2: item2 as FeatureItem,
logDifferences,
});
pricesChanged = false;
@@ -356,6 +372,7 @@ export const itemsAreSame = ({
featurePriceItemsAreSame({
item1: item1 as FeaturePriceItem,
item2: item2 as FeaturePriceItem,
logDifferences,
});
same = same_;
@@ -377,6 +394,7 @@ export const itemsAreSame = ({
same = priceItemsAreSame({
item1: item1 as PriceItem,
item2: item2 as PriceItem,
logDifferences,
});
if (!same) {
pricesChanged = true;

View File

@@ -190,6 +190,7 @@ export const productsAreSame = ({
item1: item,
item2: similarItem,
features,
logDifferences: false,
});
if (!same) {

View File

@@ -1,3 +1,4 @@
import type { FixedPriceConfig } from "@models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
import type {
ProductItem,
ProductItemInterval,
@@ -14,6 +15,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): {
interval: ProductItemInterval;
intervalCount: number;
item: ProductItem;
config: FixedPriceConfig;
priceId: string;
} | null {
const item = product.items.find((x) => isPriceItem(x));
@@ -23,6 +26,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): {
interval: (item.interval as unknown as ProductItemInterval) || null,
intervalCount: item.interval_count || 1,
item: item,
config: item.price_config as FixedPriceConfig,
priceId: item.price_id || "",
};
}

View File

@@ -5,7 +5,6 @@ import {
type ProductV2,
productV2ToFeatureItems,
} from "@autumn/shared";
import { useState } from "react";
import { Button } from "@/components/v2/buttons/Button";
import { Card, CardContent, CardHeader } from "@/components/v2/cards/Card";
import { Separator } from "@/components/v2/separator";

View File

@@ -5,6 +5,7 @@ import { useCustomer } from "autumn-js/react";
import { useState } from "react";
import { useOrg } from "@/hooks/common/useOrg";
import OnboardingCheckoutDialog from "@/views/onboarding3/OnboardingCheckoutDialog";
import { useOnboardingStore } from "@/views/onboarding3/store/useOnboardingStore";
import { PlanCardPreview } from "./PlanCardPreview";
interface PricingTableProps {
@@ -23,6 +24,9 @@ export default function PricingTablePreview({
refreshInterval: 0,
},
});
const setLastUsedProductId = useOnboardingStore(
(state) => state.setLastUsedProductId,
);
const [loadingProductId, setLoadingProductId] = useState<string | null>(null);
if (!products || products.length === 0) {
@@ -30,10 +34,9 @@ export default function PricingTablePreview({
}
const handleSubscribe = async (product: ProductV2) => {
// Check if Stripe is connected (works for both OAuth and API key)
if (!org || org.stripe_connection === "default") {
setConnectStripeOpen(true);
return;
// Track the product ID that was clicked
if (product.id) {
setLastUsedProductId(product.id);
}
if (product.id) {
@@ -84,7 +87,7 @@ export default function PricingTablePreview({
} else if (productCount === 2) {
return "flex flex-col gap-6 max-w-2xl mx-auto px-4 sm:grid sm:grid-cols-2 sm:flex-none"; // Vertical on mobile, 2 columns on sm+
} else {
return "flex flex-col gap-6 max-w-7xl mx-auto px-4 sm:grid md:grid-cols-2 xl:grid-cols-3 sm:flex-none"; // Vertical on mobile, 2 columns on sm+, 3 on lg+
return "flex flex-col gap-6 max-w-7xl mx-auto px-4 sm:grid lg:grid-cols-2 2xl:grid-cols-3 sm:flex-none"; // Vertical on mobile, 2 columns on sm+, 3 on lg+
}
};

View File

@@ -1,16 +1,13 @@
import React, {
useState,
forwardRef,
cloneElement,
isValidElement,
} from "react";
import { Check } from "lucide-react";
import { Tooltip, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { TooltipContent } from "../ui/tooltip";
import { Copy } from "lucide-react";
import { useSession } from "@/lib/auth-client";
import { Check, Copy } from "lucide-react";
import type React from "react";
import { cloneElement, forwardRef, isValidElement, useState } from "react";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
export const AdminHover = forwardRef<
HTMLElement,
@@ -19,8 +16,9 @@ export const AdminHover = forwardRef<
texts: (string | { key: string; value: string } | undefined | null)[];
hide?: boolean;
asChild?: boolean;
side?: "top" | "bottom" | "left" | "right";
}
>(({ children, texts, hide = false, asChild = false }, ref) => {
>(({ children, texts, hide = false, asChild = true, side = "bottom" }, ref) => {
const { isAdmin } = useAdmin();
if (!isAdmin || hide) return <>{children}</>;
@@ -34,14 +32,12 @@ export const AdminHover = forwardRef<
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="w-fit !cursor-default" asChild={asChild}>
{triggerChild}
</TooltipTrigger>
<TooltipTrigger asChild={asChild}>{triggerChild}</TooltipTrigger>
{isAdmin && (
<TooltipContent
className="bg-white/50 backdrop-blur-sm shadow-sm border-1 px-2 pr-6 py-2 max-w-none"
align="start"
side="bottom"
side={side}
>
<div className="text-xs text-gray-500 flex flex-col gap-2">
{texts.map((text: any) => {

View File

@@ -10,7 +10,7 @@ export const WarningBox = ({
return (
<div
className={cn(
"rounded-sm px-2 py-1 bg-yellow-50 border border-yellow-500 text-yellow-500 text-xs min-h-8 flex items-center",
"rounded-lg px-2 py-1 bg-yellow-50 border border-yellow-600 text-yellow-600 text-xs min-h-8 flex items-center",
className,
)}
>

View File

@@ -190,6 +190,18 @@ const CommandBar = () => {
setOpen(true);
});
// Direct shortcut to open impersonation search (admin only)
useHotkeys(
"meta+6",
() => {
if (isAdmin) {
setOpen(true);
setCurrentPage("impersonate");
}
},
[isAdmin],
);
useHotkeys(
"escape",
(e) => {
@@ -463,17 +475,6 @@ const CommandBar = () => {
return (
<>
{!showResults && (
<CommandGroup
heading="Search users and organizations to impersonate"
className="text-body-secondary p-1.5"
>
<div className="px-2 py-1 text-sm text-muted-foreground">
Start typing to search...
</div>
</CommandGroup>
)}
{showResults && (
<>
{userResults.length > 0 && (

View File

@@ -12,7 +12,7 @@ export const useCusProductQuery = () => {
const { customer_id, product_id } = useParams();
const [queryStates] = useQueryStates({
version: parseAsInteger,
customer_product_id: parseAsString,
id: parseAsString,
entity_id: parseAsString,
});
@@ -23,7 +23,7 @@ export const useCusProductQuery = () => {
productId: product_id,
queryStates: {
version: stableStates.version ?? undefined,
customerProductId: stableStates.customer_product_id ?? undefined,
customerProductId: stableStates.id ?? undefined,
entityId: stableStates.entity_id ?? undefined,
},
});
@@ -33,7 +33,7 @@ export const useCusProductQuery = () => {
const fetcher = async () => {
const queryParams = {
version: stableStates.version,
customer_product_id: stableStates.customer_product_id,
customer_product_id: stableStates.id,
entity_id: stableStates.entity_id,
};
@@ -57,7 +57,7 @@ export const useCusProductQuery = () => {
customer_id,
product_id,
stableStates.version,
stableStates.customer_product_id,
stableStates.id,
stableStates.entity_id,
],
queryFn: fetcher,

View File

@@ -111,11 +111,16 @@ export const ConfigureStripe = () => {
const accountName =
stripeAccount?.business_profile?.name ||
stripeAccount?.settings?.dashboard?.display_name;
const accountId = stripeAccount?.id;
const prefix = accountId
? `You have connected the Stripe account ${accountId}`
: "You have your connected your Stripe account";
if (connection === "secret_key") {
return {
description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via secret key.`, // Will show dashboard link in the same line
description: `${prefix} ${accountName ? ` (${accountName})` : ""} via secret key.`, // Will show dashboard link in the same line
showDisconnect: true,
showConnectButtons: false,
showDefaultAccountLink: true,
@@ -126,9 +131,9 @@ export const ConfigureStripe = () => {
const accountName =
stripeAccount?.business_profile?.name ||
stripeAccount?.settings?.dashboard?.display_name;
const accountId = stripeAccount?.id;
return {
description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via OAuth.`,
description: `${prefix} ${accountName ? ` (${accountName})` : ""} via OAuth.`,
showDisconnect: true,
showConnectButtons: false,
showDefaultAccountLink: false,

View File

@@ -21,13 +21,6 @@ export default function SidebarBottom() {
title="Connect to Stripe"
env={env}
/> */}
<NavButton
value="cmdk"
icon={<CommandIcon size={16} />}
title="Command Palette"
onClick={openCommandBar}
isGroup={true}
/>
<NavButton
value="docs"
icon={<Book size={14} />}

View File

@@ -1,6 +1,6 @@
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { productV2ToFeatureItems } from "@autumn/shared";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
@@ -27,18 +27,30 @@ export const OnboardingStepRenderer = () => {
// Get state from Zustand
const playgroundMode = useOnboardingStore((state) => state.playgroundMode);
const setLastUsedProductId = useOnboardingStore(
(state) => state.setLastUsedProductId,
);
const feature = useFeatureStore((state) => state.feature);
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const sheetType = useSheetStore((s) => s.type);
const itemId = useSheetStore((s) => s.itemId);
const [trackResponse, setTrackResponse] = useState<any>(null);
const [checkResponse, setCheckResponse] = useState<any>(null);
const [lastUsedFeatureId, setLastUsedFeatureId] = useState<
string | undefined
>(undefined);
// Track product ID changes when in playground mode
useEffect(() => {
if (step === OnboardingStep.Playground && product?.id) {
setLastUsedProductId(product.id);
}
}, [product?.id, step, setLastUsedProductId]);
// Don't render overrides when on Integration step or Playground preview mode - allow the step to render normally
const shouldSkipOverrides =
step === OnboardingStep.Integration ||

View File

@@ -1,6 +1,6 @@
import { FeatureType } from "@autumn/shared";
import { ArrowRightIcon } from "@phosphor-icons/react";
import { useCustomer } from "autumn-js/react";
import { PaywallDialog, useCustomer } from "autumn-js/react";
import { useState } from "react";
import { Button } from "@/components/v2/buttons/Button";
import { Input } from "@/components/v2/inputs/Input";
@@ -104,11 +104,11 @@ export const AvailableFeatures = ({
const featureId = customer?.features[x].id;
// Check the feature access
const { data: checkResponse, error: checkError } =
await check({
featureId: featureId,
requiredBalance: value,
});
const { data: checkResponse, error: checkError } = check({
featureId: featureId,
requiredBalance: value,
dialog: PaywallDialog,
});
if (!checkError && checkResponse && onCheckSuccess) {
onCheckSuccess(checkResponse);
@@ -118,6 +118,8 @@ export const AvailableFeatures = ({
onFeatureUsed(featureId);
}
if (!checkResponse?.allowed) return;
// Track the usage
const { data, error } = await track({
featureId: featureId,
@@ -135,8 +137,8 @@ export const AvailableFeatures = ({
))
) : (
<span className="text-sm text-muted-foreground">
Your current plan doesn't have any features. Try purchasing a
plan in the preview first.
Your current plan doesn't have any features. Try purchasing a plan
in the preview first.
</span>
)}
</div>

View File

@@ -13,6 +13,7 @@ import {
import { SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useOnboardingStore } from "../../store/useOnboardingStore";
import { getCodeSnippets } from "../../utils/completionStepCode";
type CodeLanguage = "react" | "nodejs" | "response";
@@ -125,13 +126,18 @@ export const QuickStartCodeGroup = ({
}) => {
const { product } = useProductStore();
const { features } = useFeaturesQuery();
const lastUsedProductId = useOnboardingStore(
(state) => state.lastUsedProductId,
);
// Use the feature that was actually used (if available), otherwise fallback to first feature
const firstFeatureItem = product?.items?.find(
(item: ProductItem) => item.feature_id,
);
const featureId = usedFeatureId || firstFeatureItem?.feature_id || undefined;
const productId = product?.id || undefined;
// Use lastUsedProductId (from pricing card clicks) or fallback to current product
const productId = lastUsedProductId || product?.id || undefined;
// Get the actual feature name from features list
const featureName = features.find((f) => f.id === featureId)?.name;
@@ -152,10 +158,7 @@ export const QuickStartCodeGroup = ({
snippets={snippets.track}
trackResponse={trackResponse}
/>
<CodeSnippetSection
title="Create checkout session"
snippets={snippets.checkout}
/>
<CodeSnippetSection title="Checkout" snippets={snippets.checkout} />
<CustomerSection />
</div>
</SheetSection>

View File

@@ -11,6 +11,7 @@ interface OnboardingState {
// UI state
isButtonLoading: boolean;
lastUsedProductId: string | undefined;
// Action handlers (set by initialization hooks)
handleNext: (() => void) | null;
@@ -33,6 +34,7 @@ interface OnboardingState {
// Actions - UI
setIsButtonLoading: (loading: boolean) => void;
setLastUsedProductId: (productId: string | undefined) => void;
// Actions - Set handlers (called by initialization hooks)
setHandleNext: (handler: () => void) => void;
@@ -63,6 +65,7 @@ const createInitialState = () => ({
// UI
isButtonLoading: false,
lastUsedProductId: undefined as string | undefined,
// Action handlers (initialized by hooks)
handleNext: null as (() => void) | null,
@@ -84,6 +87,7 @@ export const useOnboardingStore = create<OnboardingState>((set) => ({
// UI actions
setIsButtonLoading: (isButtonLoading) => set({ isButtonLoading }),
setLastUsedProductId: (lastUsedProductId) => set({ lastUsedProductId }),
// Set action handlers (called by initialization hooks)
setHandleNext: (handleNext) => set({ handleNext }),

View File

@@ -11,10 +11,14 @@ export const getCodeSnippets = (
allowed: {
react: `import { useCustomer } from 'autumn-js/react';
const { allowed } = useCustomer();
const { check } = useCustomer();
const handleCheckFeature = async () => {
if ( !allowed({ featureId: '${actualFeatureId}' }) ) {
const { data } = await check({
featureId: '${actualFeatureId}',
requiredQuantity: 1
});
if (!data?.allowed) {
alert('Feature not allowed');
}
}`,
@@ -24,7 +28,7 @@ const autumn = new Autumn({
apiKey: process.env.AUTUMN_API_KEY
});
const allowed = await autumn.check({
const { data, error } = await autumn.check({
customerId: 'cust_123',
featureId: '${actualFeatureId}'
});`,
@@ -92,21 +96,9 @@ console.log(session.checkout_url);`,
track: {
react: `import { useCustomer } from 'autumn-js/react';
const { check, track } = useCustomer();
const { track } = useCustomer();
const handleAction = async () => {
// 1. Check if user has access first
const { data } = await check({
featureId: '${actualFeatureId}',
requiredQuantity: 1
});
if (!data?.allowed) {
alert("You've reached your limit!");
return;
}
// 2. Track usage after successful check
await track({
featureId: '${actualFeatureId}',
value: 1,

View File

@@ -27,9 +27,13 @@ import { getDefaultFeature } from "../utils/defaultFeature";
function CreateFeatureSheet({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onSuccess,
isControlled = false,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: (featureId: string) => void;
isControlled?: boolean;
} = {}) {
const [loading, setLoading] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
@@ -55,17 +59,24 @@ function CreateFeatureSheet({
setLoading(false);
} else {
try {
await FeatureService.createFeature(axiosInstance, {
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
event_names: feature.event_names,
});
const { data: createdFeature } = await FeatureService.createFeature(
axiosInstance,
{
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
event_names: feature.event_names,
},
);
await refetch();
toast.success("Feature created successfully");
setOpen(false);
if (onSuccess && createdFeature.id) {
onSuccess(createdFeature.id);
}
} catch (error: unknown) {
toast.error(
getBackendErr(error as AxiosError, "Failed to create feature"),
@@ -90,22 +101,18 @@ function CreateFeatureSheet({
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="add" className="w-full">
Feature
</Button>
</SheetTrigger>
{!isControlled && (
<SheetTrigger asChild>
<Button variant="add" className="w-full">
Feature
</Button>
</SheetTrigger>
)}
<SheetContent className="flex flex-col overflow-hidden">
<SheetHeader
title="Create new feature"
description="Configure how this feature is used in your app"
/>
{/* <SheetHeader>
<SheetTitle>New Feature</SheetTitle>
<SheetDescription>
Configure how this feature is used in your app
</SheetDescription>
</SheetHeader> */}
<div className="flex-1 overflow-y-auto">
<NewFeatureDetails feature={feature} setFeature={setFeature} />

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { toast } from "sonner";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { Button } from "@/components/v2/buttons/Button";
import {
Dialog,
@@ -64,9 +65,13 @@ export const ConfirmMigrationDialog = ({
<DialogDescription className="max-w-[400px] break-words flex flex-col gap-3">
<p>
This will migrate all customers on {product.name} (version{" "}
{version}) to the latest version. Custom plans and cancelled plans
will not be migrated.
{version}) to the latest version.
</p>
<WarningBox>
Features and balances will be immediately migrated. Pricing
changes will take effect from the next billing cycle. Custom plans
and cancelled plans will not be migrated.
</WarningBox>
<p>
Type <code className="font-mono font-semibold">{product.id}</code>{" "}
to continue.

View File

@@ -141,9 +141,11 @@ export const EditPlanHeader = () => {
size="sm"
/>
)}
<IconBadge variant="muted" icon={<UserIcon />}>
{counts?.active || 0}
</IconBadge>
<PlanToolbar />
</div>

View File

@@ -1,5 +1,6 @@
import {
formatAmount,
getIntervalString,
mapToProductV3,
type Organization,
} from "@autumn/shared";
@@ -26,7 +27,7 @@ export const BasePriceDisplay = () => {
});
const secondaryText = productV3.price?.interval
? `per ${productV3.price.interval}`
? `${getIntervalString({ interval: productV3.price.interval, intervalCount: productV3.price.intervalCount })}`
: "once";
const priceExists = notNullish(productV3.price) && productV3.price.amount > 0;

View File

@@ -1,4 +1,5 @@
import { mapToProductV3 } from "@autumn/shared";
import { AdminHover } from "@/components/general/AdminHover";
import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges";
import { CardHeader } from "@/components/v2/cards/Card";
import { useOrg } from "@/hooks/common/useOrg";
@@ -16,16 +17,30 @@ export const PlanCardHeader = () => {
const isPlanBeingEdited = useIsEditingPlan();
const productV3 = mapToProductV3({ product });
const adminHoverText = () => {
return [
{
key: "Price ID",
value: productV3.price?.priceId || "N/A",
},
{
key: "Stripe Price ID",
value: productV3.price?.config?.stripe_price_id || "N/A",
},
];
};
return (
<CardHeader>
<div className="flex flex-row items-center justify-between w-full">
<div className="flex flex-row items-center gap-2">
<span className="text-main-sec w-fit whitespace-nowrap">
{product.name.length > MAX_PLAN_NAME_LENGTH
? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...`
: product.name}
</span>
<AdminHover texts={adminHoverText()} side="top">
<span className="text-main-sec w-fit whitespace-nowrap">
{product.name.length > MAX_PLAN_NAME_LENGTH
? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...`
: product.name}
</span>
</AdminHover>
<PlanTypeBadges
product={product}
iconOnly={product.name.length > MAX_PLAN_NAME_LENGTH - 10}

View File

@@ -52,28 +52,14 @@ export const PlanCardToolbar = ({
aria-label="Edit plan"
variant="muted"
disabled={editDisabled}
size="sm"
className={cn(isEditingPlan && "btn-secondary-active !opacity-100 ")}
// size="sm"
className={cn(
// "text-body",
isEditingPlan && "btn-secondary-active !opacity-100 ",
)}
>
Edit Details
Plan Details
</IconButton>
{/* {product?.archived ? (
<Button variant="muted" onClick={() => setDeleteOpen(true)} size="sm">
Archived
</Button>
) : (
<IconButton
icon={<TrashIcon />}
onClick={() => setDeleteOpen(true)}
aria-label="Delete plan"
variant="muted"
iconOrientation="center"
disabled={deleteDisabled}
title={deleteDisabled && deleteTooltip ? deleteTooltip : undefined}
className={cn(deleteDisabled && "opacity-50 cursor-not-allowed")}
/>
)} */}
</div>
</>
);

View File

@@ -4,6 +4,7 @@ import type { ProductItem } from "@autumn/shared";
import { getProductItemDisplay, productV2ToFeatureItems } from "@autumn/shared";
import { TrashIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { AdminHover } from "@/components/general/AdminHover";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useOrg } from "@/hooks/common/useOrg";
@@ -108,6 +109,51 @@ export const PlanFeatureRow = ({
}
};
const adminHoverText = () => {
return [
...(item.entitlement_id
? [
{
key: "Entitlement ID",
value: item.entitlement_id || "N/A",
},
]
: []),
...(item.price_id
? [
{
key: "Price ID",
value: item.price_id || "N/A",
},
]
: []),
...(item.price_config?.stripe_price_id
? [
{
key: "Stripe Price ID",
value: item.price_config?.stripe_price_id || "N/A",
},
]
: []),
...(item.price_config?.stripe_empty_price_id
? [
{
key: "Stripe Empty Price ID",
value: item.price_config?.stripe_empty_price_id || "N/A",
},
]
: []),
...(item.price_config?.stripe_product_id
? [
{
key: "Stripe Product ID",
value: item.price_config?.stripe_product_id || "N/A",
},
]
: []),
];
};
return (
<div
role="button"
@@ -144,23 +190,29 @@ export const PlanFeatureRow = ({
>
{/* Left side - Icons and text */}
<div className="flex flex-row items-center flex-1 gap-4 min-w-0 relative">
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PlanFeatureIcon item={item} position="left" />
<CustomDotIcon />
<PlanFeatureIcon item={item} position="right" />
</div>
<AdminHover texts={adminHoverText()}>
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PlanFeatureIcon item={item} position="left" />
<CustomDotIcon />
<PlanFeatureIcon item={item} position="right" />
</div>
</AdminHover>
<div className="flex items-center gap-2 flex-1 min-w-0 max-w-[90%] ">
<p className="whitespace-nowrap truncate max-w-full">
<span className={cn("text-body", !hasFeatureName && "!text-t4")}>
{displayText}
</span>
<span className="text-body-secondary">
{" "}
{display.secondary_text}
</span>
</p>
</div>
<CopyButton
text={item.feature_id || ""}
disableActive={true}

View File

@@ -16,79 +16,96 @@ import { getItemType } from "@/utils/product/productItemUtils";
import { CreateItemStep } from "../utils/CreateItemStep";
import { useEffect, useState } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import CreateFeatureSheet from "@/views/products/features/components/CreateFeatureSheet";
export const SelectItemFeature = () => {
const { features } = useFeaturesQuery();
const { item, setItem, isUpdate, stepState } = useProductItemContext();
const [open, setOpen] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const itemType = getItemType(item);
const handleFeatureCreated = (featureId: string) => {
setItem({ ...item, feature_id: featureId });
};
return (
<div className="flex items-center gap-2 w-full">
<Select
open={open}
onOpenChange={setOpen}
value={item.feature_id || ""}
onValueChange={(value) => {
setItem({ ...item, feature_id: value });
}}
disabled={isUpdate}
>
<SelectTrigger className="overflow-hidden">
<SelectValue placeholder="Select a feature" />
</SelectTrigger>
<SelectContent>
{features
.filter((feature: Feature) => {
if (feature.archived && feature.id !== item.feature_id)
return false;
if (itemType === ProductItemType.FeaturePrice) {
return feature.type !== FeatureType.Boolean;
}
return true;
})
.map((feature: Feature) => (
<SelectItem key={feature.id} value={feature.id!}>
<div className="flex gap-2 items-center max-w-sm">
<span className="truncate">{feature.name}</span>
<FeatureTypeBadge {...feature} />
</div>
</SelectItem>
))}
<>
<div className="flex items-center gap-2 w-full">
<Select
open={open}
onOpenChange={setOpen}
value={item.feature_id || ""}
onValueChange={(value) => {
setItem({ ...item, feature_id: value });
}}
disabled={isUpdate}
>
<SelectTrigger className="overflow-hidden">
<SelectValue placeholder="Select a feature" />
</SelectTrigger>
<SelectContent>
{features
.filter((feature: Feature) => {
if (feature.archived && feature.id !== item.feature_id)
return false;
if (itemType === ProductItemType.FeaturePrice) {
return feature.type !== FeatureType.Boolean;
}
return true;
})
.map((feature: Feature) => (
<SelectItem key={feature.id} value={feature.id!}>
<div className="flex gap-2 items-center max-w-sm">
<span className="truncate">{feature.name}</span>
<FeatureTypeBadge {...feature} />
</div>
</SelectItem>
))}
<Button
className="flex w-full font-medium bg-white shadow-none text-primary hover:bg-stone-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
setSheetOpen(true);
}}
>
<PlusIcon className="w-3 h-3 mr-2" />
Create new feature
</Button>
</SelectContent>
</Select>
{!isUpdate && item.feature_id && (
<Button
className="flex w-full font-medium bg-white shadow-none text-primary hover:bg-stone-200"
onClick={(e) => {
e.preventDefault();
stepState.pushStep(CreateItemStep.CreateFeature);
isIcon
size="sm"
variant="ghost"
className="w-fit text-t3"
onClick={() => {
setItem({
...item,
feature_id: null,
included_usage: null,
feature_type: null,
tiers: null,
price: null,
// price: item.tiers?.[0]?.amount || 0,
});
// setShow({ ...show, feature: false });
}}
>
<PlusIcon className="w-3 h-3 mr-2" />
Create new feature
<X size={12} className="text-t3" />
</Button>
</SelectContent>
</Select>
{!isUpdate && item.feature_id && (
<Button
isIcon
size="sm"
variant="ghost"
className="w-fit text-t3"
onClick={() => {
setItem({
...item,
feature_id: null,
included_usage: null,
feature_type: null,
tiers: null,
price: null,
// price: item.tiers?.[0]?.amount || 0,
});
// setShow({ ...show, feature: false });
}}
>
<X size={12} className="text-t3" />
</Button>
)}
</div>
)}
</div>
<CreateFeatureSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
onSuccess={handleFeatureCreated}
isControlled={true}
/>
</>
);
};

View File

@@ -1,59 +1,52 @@
import React from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { isFeatureItem, isFeaturePriceItem } from "@/utils/product/getItemType";
import { useProductItemContext } from "../ProductItemContext";
import { BillingInterval, FeatureUsageType, Infinite } from "@autumn/shared";
import FeaturePrice from "./components/feature-price/FeaturePrice";
import { SelectCycle } from "./components/feature-price/SelectBillingCycle";
import { IncludedUsage } from "./components/IncludedUsage";
import { SelectResetCycle } from "./components/SelectResetCycle";
import FeaturePrice from "./components/feature-price/FeaturePrice";
import { isFeatureItem, isFeaturePriceItem } from "@/utils/product/getItemType";
import React from "react";
import { notNullish } from "@/utils/genUtils";
import {
getFeature,
getFeatureUsageType,
} from "@/utils/product/entitlementUtils";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const FeatureConfig = () => {
const { features } = useFeaturesQuery();
const { item, setItem } = useProductItemContext();
if (!item.feature_id) return null;
if (!item?.feature_id) return null;
const isFeaturePrice = isFeaturePriceItem(item);
const isFeature = isFeatureItem(item);
const handleAddUsagePrice = () => {
const newIncludedUsage =
item.included_usage == Infinite ? 0 : item.included_usage;
// const handleAddUsagePrice = () => {
// const newIncludedUsage =
// item.included_usage == Infinite ? 0 : item.included_usage;
let newInterval = item.interval;
if (
notNullish(item.interval) &&
!Object.values(BillingInterval).includes(item.interval)
) {
newInterval = BillingInterval.Month;
}
// let newInterval = item.interval;
// if (
// notNullish(item.interval) &&
// !Object.values(BillingInterval).includes(item.interval)
// ) {
// newInterval = BillingInterval.Month;
// }
setItem({
...item,
included_usage: newIncludedUsage,
tiers: [{ to: Infinite, amount: 0 }],
interval: newInterval,
});
};
// setItem({
// ...item,
// included_usage: newIncludedUsage,
// tiers: [{ to: Infinite, amount: 0 }],
// interval: newInterval,
// });
// };
const price =
getFeatureUsageType({ item, features }) == FeatureUsageType.Continuous
? "10"
: "1";
// const price =
// getFeatureUsageType({ item, features }) == FeatureUsageType.Continuous
// ? "10"
// : "1";
const feature = getFeature(item?.feature_id, features);
// const feature = getFeature(item?.feature_id, features);
return (
<>
{isFeature && (
<div className="flex items-center gap-2 w-full">
<div className="flex items-start gap-2 w-full">
<IncludedUsage />
<SelectResetCycle />
</div>

View File

@@ -9,20 +9,6 @@ import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/v2/buttons/Button";
import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils";
import { isFeaturePriceItem } from "@/utils/product/getItemType";
import { itemIsUnlimited } from "@/utils/product/productItemUtils";
import { useProductItemContext } from "../../ProductItemContext";
@@ -123,94 +109,6 @@ export const IncludedUsage = () => {
</ToggleDisplayButton>
</div>
</div>
<div className="space-y-2">
<FieldLabel className="flex items-center gap-2">
Usage Reset & Billing Interval
<InfoTooltip>
<span className="">
How often usage counts reset for this feature. Choose "no reset"
for items that don't expire.
</span>
</InfoTooltip>
</FieldLabel>
<div className="flex flex-col gap-2">
<Select
value={currentInterval}
onValueChange={handleBillingIntervalSelected}
>
<SelectTrigger>
<SelectValue placeholder="Select reset interval" />
</SelectTrigger>
<SelectContent>
{/* Add EntInterval.Lifetime for "no reset" */}
<SelectItem value={EntInterval.Lifetime}>
{formatIntervalText({
interval: EntInterval.Lifetime,
intervalCount: item.interval_count,
})}
</SelectItem>
{/* Add BillingInterval options */}
{Object.values(BillingInterval).map((interval) => (
<SelectItem key={interval} value={interval}>
{formatIntervalText({
billingInterval: interval,
intervalCount: item.interval_count,
isBillingInterval: true,
})}
</SelectItem>
))}
{/* Custom interval option */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
className="w-full justify-start px-2"
variant="skeleton"
disabled={
item.included_usage === Infinite || item.interval == null
}
>
<p className="text-t3">Customise Interval</p>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="p-3 w-[200px]"
sideOffset={-1}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2">
<FieldLabel>Interval Count</FieldLabel>
</div>
<div className="flex items-center gap-2">
<Input
className="flex-1"
value={intervalCount}
onChange={(e) => setIntervalCount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveCustomInterval();
}
if (e.key === "Escape") {
setOpen(false);
}
}}
/>
<Button
variant="secondary"
className="px-4 h-7"
onClick={handleSaveCustomInterval}
>
Save
</Button>
</div>
</PopoverContent>
</Popover>
</SelectContent>
</Select>
</div>
</div>
</div>
);
};

View File

@@ -25,8 +25,8 @@ export default defineConfig({
"@radix/tabs": "@radix-ui/react-tabs",
"@radix/tooltip": "@radix-ui/react-tooltip",
},
// Preserve symlinks for workspace dependencies
preserveSymlinks: true,
// // Preserve symlinks for workspace dependencies
// preserveSymlinks: true,
},
optimizeDeps: {
// Exclude workspace dependencies from pre-bundling to avoid cache issues