Merge branch 'main' into staging
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
18
server/src/external/stripe/stripePriceUtils.ts
vendored
18
server/src/external/stripe/stripePriceUtils.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -306,3 +306,7 @@ export const unsetOrgStripeKeys = async ({
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const orgToCurrency = ({ org }: { org: Organization }) => {
|
||||
return org.default_currency || "usd";
|
||||
};
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ platformBetaRouter.post(
|
||||
"/organization/stripe",
|
||||
...handleUpdateOrganizationStripe,
|
||||
);
|
||||
platformBetaRouter.post(
|
||||
"/organizations/stripe",
|
||||
...handleUpdateOrganizationStripe,
|
||||
);
|
||||
|
||||
platformBetaRouter.get("/users", ...listPlatformUsers);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,6 +142,7 @@ export const productsAreSame = ({
|
||||
item1: item,
|
||||
item2: similarItem!,
|
||||
features,
|
||||
logDifferences: false,
|
||||
});
|
||||
|
||||
if (!same) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
152
server/src/utils/routerUtils/expressErrorSkip.ts
Normal file
152
server/src/utils/routerUtils/expressErrorSkip.ts
Normal 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;
|
||||
};
|
||||
@@ -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})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -190,6 +190,7 @@ export const productsAreSame = ({
|
||||
item1: item,
|
||||
item2: similarItem,
|
||||
features,
|
||||
logDifferences: false,
|
||||
});
|
||||
|
||||
if (!same) {
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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+
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -141,9 +141,11 @@ export const EditPlanHeader = () => {
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconBadge variant="muted" icon={<UserIcon />}>
|
||||
{counts?.active || 0}
|
||||
</IconBadge>
|
||||
|
||||
<PlanToolbar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user