Merge branch 'staging' into perf/bun-runtime

This commit is contained in:
John Yeo
2025-07-21 18:59:54 +01:00
58 changed files with 1613 additions and 671 deletions

View File

@@ -112,7 +112,7 @@ jobs:
pnpm build
- name: Startup server
run: sudo docker compose -f docker-compose.ci.yml up --detach
run: sudo docker compose -f docker/docker-compose.ci.yml up --detach
- name: Wait for server to be ready
run: |
@@ -159,4 +159,4 @@ jobs:
- name: Close Docker Containers
if: always()
run: sudo docker compose -f docker-compose.unix.yml down
run: sudo docker compose -f docker/docker-compose.unix.yml down

3
.gitignore vendored
View File

@@ -94,4 +94,5 @@ server/test.sh
# bun (keep main repo clean betweeen different npm flavours)
bun.lock
bun.lockb
bun.lockb
.dev.vars

View File

@@ -18,7 +18,7 @@ services:
shared:
build:
dockerfile: docker/dev.dockerfile
dockerfile: ./dev.dockerfile
context: .
target: shared
volumes:
@@ -33,7 +33,7 @@ services:
# Vite frontend
vite:
build:
dockerfile: docker/dev.dockerfile
dockerfile: ./dev.dockerfile
context: .
target: vite
ports:
@@ -52,7 +52,7 @@ services:
# Main Express server
server:
build:
dockerfile: docker/dev.dockerfile
dockerfile: ./dev.dockerfile
context: .
target: server
ports:
@@ -82,7 +82,7 @@ services:
# BullMQ Workers
workers:
build:
dockerfile: docker/dev.dockerfile
dockerfile: ./dev.dockerfile
context: .
target: workers
volumes:
@@ -102,7 +102,7 @@ services:
# Run localtunnel
localtunnel:
build:
dockerfile: docker/dev.dockerfile
dockerfile: ./dev.dockerfile
context: .
target: localtunnel
volumes:

View File

@@ -7,6 +7,7 @@ MOCHA_PARALLEL=true $MOCHA_SETUP \
&& $MOCHA_CMD \
'tests/attach/basic/*.ts' \
'tests/attach/upgrade/*.ts' \
'tests/attach/downgrade/*.ts'
'tests/attach/downgrade/*.ts' \
'tests/attach/free/*.ts'
# 'tests/attach/basic/basic2.ts' \

View File

@@ -21,6 +21,7 @@ import { getCusPaymentMethod } from "../../stripeCusUtils.js";
import { webhookToAttachParams } from "../../webhookUtils/webhookUtils.js";
import { createUsageInvoice } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.js";
import { CusService } from "@/internal/customers/CusService.js";
import { notNullish } from "@/utils/genUtils.js";
export const handleCusProductDeleted = async ({
req,
@@ -63,7 +64,7 @@ export const handleCusProductDeleted = async ({
if (usagePrices.length > 0) {
logger.info(
`sub.deleted, submitting usage for ${fullCus.id}, ${cusProduct.product.name}`,
`sub.deleted, submitting usage for ${fullCus.id}, ${cusProduct.product.name}`
);
await createUsageInvoice({
@@ -91,14 +92,14 @@ export const handleCusProductDeleted = async ({
if (scheduled_ids && scheduled_ids.length > 0 && !prematurelyCanceled) {
// If cusProduct has scheduled_ids, remove sub id from it?
logger.info(
`sub.deleted: removing sub_id from cus product ${cusProduct.id}`,
`sub.deleted: removing sub_id from cus product ${cusProduct.id}`
);
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
subscription_ids: cusProduct.subscription_ids?.filter(
(id) => id !== subscription.id,
(id) => id !== subscription.id
),
},
});
@@ -127,7 +128,10 @@ export const handleCusProductDeleted = async ({
logger,
});
if (cusProduct.product.is_add_on) {
if (
cusProduct.product.is_add_on ||
notNullish(cusProduct.internal_entity_id)
) {
return;
}

View File

@@ -56,8 +56,9 @@ export const handleSubCanceled = async ({
let defaultProducts = allDefaultProducts.filter((p) =>
updatedCusProducts.some(
(cp: FullCusProduct) => cp.product.group == p.group,
),
(cp: FullCusProduct) =>
cp.product.group == p.group && nullish(cp.internal_entity_id)
)
);
if (defaultProducts.length == 0) return;
@@ -66,14 +67,14 @@ export const handleSubCanceled = async ({
const productNames = defaultProducts.map((p) => p.name).join(", ");
const periodEnd = formatUnixToDateTime(sub.current_period_end * 1000);
logger.info(
`subscription.updated: canceled -> attempting to schedule default products: ${productNames}, period end: ${periodEnd}`,
`subscription.updated: canceled -> attempting to schedule default products: ${productNames}, period end: ${periodEnd}`
);
}
let scheduledCusProducts: FullCusProduct[] = [];
for (let product of defaultProducts) {
let alreadyScheduled = cusProducts.some(
(cp: FullCusProduct) => cp.product.group == product.group,
(cp: FullCusProduct) => cp.product.group == product.group
);
if (alreadyScheduled) {
@@ -112,7 +113,7 @@ export const handleSubCanceled = async ({
scenario: AttachScenario.Cancel,
cusProduct: cusProd,
scheduledCusProduct: scheduledCusProducts.find(
(cp) => cp.product.group === cusProd.product.group,
(cp) => cp.product.group === cusProd.product.group
),
});
} catch (error) {}

View File

@@ -121,7 +121,7 @@ export const handleProductsUpdated = async ({
let product = cusProduct.product;
let prices = cusProduct.customer_prices.map((cp) => cp.price);
let entitlements = cusProduct.customer_entitlements.map(
(ce) => ce.entitlement,
(ce) => ce.entitlement
);
let freeTrial = cusProduct.free_trial;
@@ -169,6 +169,7 @@ export const handleProductsUpdated = async ({
// 1. Log action to DB
// console.log(`handling products.updated for customer ${customer.id}`);
try {
if (req) {
let action = constructAction({
@@ -193,14 +194,17 @@ export const handleProductsUpdated = async ({
await ActionService.insert(db, action);
} else {
logger.warn(
"products.updated, no req object found, skipping action insert",
"products.updated, no req object found, skipping action insert"
);
}
} catch (error: any) {
logger.error("Failed to log action to DB", {
message: error.message,
error: error,
});
// 23503 is for internal_customer_id not found
if (error?.code !== "23503") {
logger.error("Failed to log action to DB", {
message: error.message,
error: error,
});
}
}
// 2. Send Svix event

View File

@@ -1,7 +1,7 @@
import { apiAuthMiddleware } from "@/middleware/apiAuthMiddleware.js";
import { Router } from "express";
import { eventsRouter } from "./events/eventRouter.js";
import { cusRouter } from "./cusRouter.js";
import { cusRouter } from "../customers/cusRouter.js";
import { productBetaRouter, productRouter } from "../products/productRouter.js";
import { featureRouter } from "../features/featureRouter.js";

View File

@@ -66,13 +66,12 @@ export class EventService {
and(
eq(events.internal_customer_id, internalCustomerId),
eq(events.org_id, orgId),
eq(events.env, env),
),
eq(events.env, env)
)
)
.orderBy(desc(events.created_at))
.limit(limit);
return results;
}
}

View File

@@ -81,6 +81,13 @@ const getEventAndCustomer = async ({
const parsedEvent = CreateEventSchema.parse(event_data);
const timestamp = getEventTimestamp(parsedEvent.timestamp);
let internalEntityId = null;
if (event_data.entity_id) {
internalEntityId = customer.entities.find(
(e) => e.id === event_data.entity_id
)?.internal_id;
}
const newEvent: EventInsert = {
...parsedEvent,
properties: parsedEvent.properties || {},
@@ -91,6 +98,7 @@ const getEventAndCustomer = async ({
internal_customer_id: customer.internal_id,
created_at: timestamp.getTime(),
timestamp: timestamp,
internal_entity_id: internalEntityId,
};
let event = await EventService.insert({ db, event: newEvent });
@@ -123,7 +131,7 @@ const getAffectedFeatures = async ({
creditSystemContainsFeature({
creditSystem: cs,
meteredFeatureId: f.id,
}),
})
)
);
});

View File

@@ -6,6 +6,7 @@ import {
EventInsert,
FeatureType,
FeatureUsageType,
FullCustomer,
} from "@autumn/shared";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { generateId, nullish } from "@/utils/genUtils.js";
@@ -45,9 +46,9 @@ const getCusFeatureAndOrg = async ({
customerId,
customerData,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId,
entityData: req.body.entity_data,
withEntities: true,
});
let feature = features.find((f) => f.id == featureId);
@@ -57,7 +58,7 @@ const getCusFeatureAndOrg = async ({
creditSystemContainsFeature({
creditSystem: f,
meteredFeatureId: featureId,
}),
})
);
if (!feature) {
@@ -81,7 +82,7 @@ const createAndInsertEvent = async ({
idempotencyKey,
}: {
req: any;
customer: Customer;
customer: FullCustomer;
featureId: string;
value?: number;
set_usage?: boolean;
@@ -98,6 +99,14 @@ const createAndInsertEvent = async ({
const timestamp = getEventTimestamp(req.body.timestamp);
const entityId = req.body.entity_id;
let internalEntityId = null;
if (entityId) {
internalEntityId = customer.entities.find(
(e) => e.id === entityId
)?.internal_id;
}
const newEvent: EventInsert = {
id: generateId("evt"),
org_id: req.orgId,
@@ -114,6 +123,8 @@ const createAndInsertEvent = async ({
properties,
value,
set_usage: set_usage || false,
entity_id: req.body.entity_id,
internal_entity_id: internalEntityId,
};
return await EventService.insert({ db: req.db, event: newEvent });

View File

@@ -103,7 +103,7 @@ export const cancelCusProductSubscriptions = async ({
} catch (error: any) {
if (error.code != "resource_missing") {
console.log(
`Error canceling stripe subscription ${error.code}: ${error.message}`,
`Error canceling stripe subscription ${error.code}: ${error.message}`
);
} // else subscription probably already cancelled
}
@@ -219,7 +219,7 @@ export const expireAndActivate = async ({
let product = cusProduct.product;
const isOneOffOrAddOn = product.is_add_on || isOneOff(prices);
if (isOneOffOrAddOn) {
if (isOneOffOrAddOn || notNullish(cusProduct.internal_entity_id)) {
return;
}
@@ -263,7 +263,7 @@ export const activateFutureProduct = async ({
if (subIsPrematurelyCanceled(subscription)) {
console.log(
" 🔔 Subscription prematurely canceled, deleting scheduled products",
" 🔔 Subscription prematurely canceled, deleting scheduled products"
);
await deleteScheduledIds({
@@ -392,7 +392,7 @@ export const processFullCusProduct = ({
org.config.api_version >= BREAK_API_VERSION
) {
let baseSub = subs?.find(
(s) => s.id == subIds[0] || (s as Subscription).stripe_id == subIds[0],
(s) => s.id == subIds[0] || (s as Subscription).stripe_id == subIds[0]
);
stripeSubData = {
current_period_end: baseSub?.current_period_end
@@ -520,7 +520,7 @@ export const getMainCusProduct = async ({
});
let mainCusProduct = cusProducts.find(
(cusProduct: FullCusProduct) => !cusProduct.product.is_add_on,
(cusProduct: FullCusProduct) => !cusProduct.product.is_add_on
);
return mainCusProduct;
@@ -538,7 +538,7 @@ export const getCusProductsWithStripeSubId = ({
return cusProducts.filter(
(cusProduct) =>
cusProduct.subscription_ids?.includes(stripeSubId) &&
cusProduct.id !== curCusProductId,
cusProduct.id !== curCusProductId
);
};
@@ -551,7 +551,7 @@ export const getFeatureQuantity = ({
}) => {
const options = cusProduct.options;
const option = options.find(
(o) => o.internal_feature_id == internalFeatureId,
(o) => o.internal_feature_id == internalFeatureId
);
return nullish(option?.quantity) ? 1 : option?.quantity!;
};

View File

@@ -5,22 +5,23 @@ import { ErrCode } from "@autumn/shared";
import { Router } from "express";
import { StatusCodes } from "http-status-codes";
import { CusService } from "../customers/CusService.js";
import { CusService } from "./CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { handleDeleteCustomer } from "../customers/handlers/cusDeleteHandlers.js";
import { handleUpdateBalances } from "../customers/handlers/handleUpdateBalances.js";
import { handleUpdateEntitlement } from "../customers/handlers/handleUpdateEntitlement.js";
import { handleCusProductExpired } from "../customers/handlers/handleCusProductExpired.js";
import { handleAddCouponToCus } from "../customers/handlers/handleAddCouponToCus.js";
import { handlePostCustomerRequest } from "../customers/handlers/handlePostCustomer.js";
import { entityRouter } from "./entities/entityRouter.js";
import { handleUpdateCustomer } from "../customers/handlers/handleUpdateCustomer.js";
import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBillingPortal.js";
import { handleGetCustomer } from "../customers/handlers/handleGetCustomer.js";
import { handleDeleteCustomer } from "./handlers/cusDeleteHandlers.js";
import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js";
import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js";
import { handleCusProductExpired } from "./handlers/handleCusProductExpired.js";
import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js";
import { handlePostCustomerRequest } from "./handlers/handlePostCustomer.js";
import { entityRouter } from "../api/entities/entityRouter.js";
import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js";
import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js";
import { handleGetCustomer } from "./handlers/handleGetCustomer.js";
import { CusSearchService } from "@/internal/customers/CusSearchService.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { handleTransferProduct } from "./handlers/handleTransferProduct.js";
export const cusRouter: Router = Router();
@@ -58,14 +59,14 @@ cusRouter.post("/:customer_id", handleUpdateCustomer);
// Update customer entitlement directly
cusRouter.post(
"/customer_entitlements/:customer_entitlement_id",
handleUpdateEntitlement,
handleUpdateEntitlement
);
cusRouter.post("/:customer_id/balances", handleUpdateBalances);
cusRouter.post(
"/customer_products/:customer_product_id",
handleCusProductExpired,
handleCusProductExpired
);
cusRouter.get("/:customer_id/billing_portal", async (req: any, res: any) => {
@@ -162,3 +163,5 @@ cusRouter.post("/:customer_id/billing_portal", handleCreateBillingPortal);
cusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus);
cusRouter.use("/:customer_id/entities", entityRouter);
cusRouter.post("/:customer_id/transfer_product", handleTransferProduct);

View File

@@ -5,6 +5,7 @@ import { routeHandler } from "@/utils/routerUtils.js";
import { CusProductStatus, ErrCode, FullCusProduct } from "@autumn/shared";
import { Router } from "express";
import { expireCusProduct } from "../handlers/handleCusProductExpired.js";
import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js";
const expireRouter: Router = Router();
@@ -27,7 +28,7 @@ expireRouter.post("", async (req, res) =>
env,
withEntities: true,
entityId: entity_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
inStatuses: RELEVANT_STATUSES,
allowNotFound: false,
});
@@ -39,11 +40,12 @@ expireRouter.post("", async (req, res) =>
}
let cusProducts = fullCus.customer_products;
let entity = fullCus.entity;
let cusProductsToExpire = cusProducts.filter(
(cusProduct: FullCusProduct) =>
cusProduct.product.id == product_id &&
(entity_id ? cusProduct.entity_id == entity_id : true),
(entity ? cusProduct.internal_entity_id == entity.internal_id : true)
);
if (cusProductsToExpire.length == 0) {
@@ -69,7 +71,7 @@ expireRouter.post("", async (req, res) =>
product_id: product_id,
});
},
}),
})
);
export default expireRouter;

View File

@@ -86,13 +86,13 @@ export const expireCusProduct = async ({
logger.info(
`🔔 Expiring cutomer product (${
expireImmediately ? "immediately" : "end of cycle"
})`,
})`
);
logger.info(
`Customer: ${fullCus.id || fullCus.internal_id} (${env}), Org: ${org.id}`,
`Customer: ${fullCus.id || fullCus.internal_id} (${env}), Org: ${org.id}`
);
logger.info(
`Product: ${cusProduct.product.name}, Status: ${cusProduct.status}`,
`Product: ${cusProduct.product.name}, Status: ${cusProduct.status}`
);
// If current product is scheduled
@@ -144,6 +144,12 @@ export const expireCusProduct = async ({
cusProductId: cusProduct.id,
updates: { status: CusProductStatus.Expired },
});
} else {
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: { canceled_at: Date.now() },
});
}
return;

View File

@@ -0,0 +1,63 @@
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { Request, Response } from "express";
import { CusService } from "../CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@autumn/shared";
import { CusProductService } from "../cusProducts/CusProductService.js";
export const handleTransferProduct = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "transfer product",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { customer_id } = req.params;
const { internal_entity_id, customer_product_id } = req.body;
const customer = await CusService.getFull({
idOrInternalId: customer_id,
orgId: req.orgId,
env: req.env,
db: req.db,
withEntities: true,
});
const cusProduct = customer.customer_products.find(
(cp: any) => cp.id === customer_product_id
);
if (!cusProduct) {
throw new RecaseError({
code: ErrCode.CusProductNotFound,
message: "Customer product not found",
statusCode: 404,
});
}
let entity = customer.entities.find(
(e: any) => e.internal_id === internal_entity_id
);
if (!entity) {
throw new RecaseError({
code: ErrCode.EntityNotFound,
message: "Entity not found",
statusCode: 404,
});
}
await CusProductService.update({
db: req.db,
cusProductId: customer_product_id,
updates: {
entity_id: entity.id,
internal_entity_id: entity.internal_id,
},
});
res.status(200).json({
message: "Product transferred successfully",
});
},
});

View File

@@ -36,7 +36,6 @@ cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
const limit = req.query.limit || 10;
const period = req.query.period || "all";
const events = await EventService.getByCustomerId({
db,
internalCustomerId: customer_id,
@@ -49,7 +48,7 @@ cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
} catch (error) {
handleFrontendReqError({ req, error, res, action: "get customer events" });
}
})
});
cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try {
@@ -81,21 +80,6 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
}),
]);
// for (const cusProduct of customer.customer_products) {
// console.log(
// "Cus ents:",
// cusProduct.customer_entitlements.map((ce) => {
// return `${ce.entitlement.feature.id}, ${ce.balance}, ${ce.replaceables}`;
// }),
// );
// console.log(
// "Cus prices:",
// cusProduct.customer_prices.map((cp) => {
// return `${cp.price.id}`;
// }),
// );
// }
if (!customer) {
throw new RecaseError({
message: "Customer not found",
@@ -118,22 +102,22 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
let cusProducts = fullCustomer.customer_products;
fullCustomer.products = fullCustomer.customer_products;
fullCustomer.entitlements = cusProducts.flatMap(
(product: FullCusProduct) => product.customer_entitlements,
(product: FullCusProduct) => product.customer_entitlements
);
fullCustomer.prices = cusProducts.flatMap(
(product: FullCusProduct) => product.customer_prices,
(product: FullCusProduct) => product.customer_prices
);
for (const product of fullCustomer.products) {
product.entitlements = product.customer_entitlements.map(
(cusEnt: FullCustomerEntitlement) => {
return cusEnt.entitlement;
},
}
);
product.prices = product.customer_prices.map(
(cusPrice: FullCustomerPrice) => {
return cusPrice.price;
},
}
);
}
@@ -142,7 +126,7 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try {
const stripeCli = createStripeCli({ org, env });
const stripeCus: any = await stripeCli.customers.retrieve(
customer.processor.id,
customer.processor.id
);
if (stripeCus.discount) {
@@ -158,34 +142,34 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
invoice.internal_product_ids = invoice.internal_product_ids.sort();
}
fullCustomer.entitlements = fullCustomer.entitlements.sort(
(a: any, b: any) => {
const productA = fullCustomer.products.find(
(p: any) => p.id === a.customer_product_id,
);
const productB = fullCustomer.products.find(
(p: any) => p.id === b.customer_product_id,
);
// fullCustomer.entitlements = fullCustomer.entitlements.sort(
// (a: any, b: any) => {
// const productA = fullCustomer.products.find(
// (p: any) => p.id === a.customer_product_id
// );
// const productB = fullCustomer.products.find(
// (p: any) => p.id === b.customer_product_id
// );
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ||
b.id.localeCompare(a.id)
);
},
);
// return (
// new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ||
// b.id.localeCompare(a.id)
// );
// }
// );
for (const cusEnt of fullCustomer.entitlements) {
// let entitlement = cusEnt.entitlement;
// for (const cusEnt of fullCustomer.entitlements) {
// // let entitlement = cusEnt.entitlement;
// Show used, limit, etc.
let { balance, unused } = getCusEntMasterBalance({
cusEnt,
entities,
});
// // Show used, limit, etc.
// let { balance, unused } = getCusEntMasterBalance({
// cusEnt,
// entities,
// });
cusEnt.balance = balance;
cusEnt.unused = unused;
}
// cusEnt.balance = balance;
// cusEnt.unused = unused;
// }
res.status(200).json({
customer: fullCustomer,
@@ -242,7 +226,7 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
]);
let redeemedCustomerIds = redeemed.map(
(redemption: any) => redemption.referral_code.internal_customer_id,
(redemption: any) => redemption.referral_code.internal_customer_id
);
let redeemedCustomers = await CusReadService.getInInternalIds({
@@ -255,7 +239,7 @@ cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
redemption.referral_code.customer = redeemedCustomers.find(
(customer: any) =>
customer.internal_id ===
redemption.referral_code!.internal_customer_id,
redemption.referral_code!.internal_customer_id
);
}
}
@@ -313,7 +297,7 @@ cusRouter.get(
cusProduct = cusProducts.find(
(p: any) =>
p.id === customer_product_id &&
(entity ? p.internal_entity_id === entity.internal_id : true),
(entity ? p.internal_entity_id === entity.internal_id : true)
);
} else if (notNullish(version)) {
cusProduct = cusProducts.find(
@@ -322,7 +306,7 @@ cusRouter.get(
(p.status === CusProductStatus.Active ||
p.status === CusProductStatus.PastDue) &&
p.product.version === parseInt(version) &&
(entity ? p.internal_entity_id === entity.internal_id : true),
(entity ? p.internal_entity_id === entity.internal_id : true)
);
} else {
cusProduct = cusProducts.find(
@@ -330,7 +314,7 @@ cusRouter.get(
p.product.id === product_id &&
(p.status === CusProductStatus.Active ||
p.status === CusProductStatus.PastDue) &&
(entity ? p.internal_entity_id === entity.internal_id : true),
(entity ? p.internal_entity_id === entity.internal_id : true)
);
}
@@ -382,5 +366,5 @@ cusRouter.get(
action: "get customer product",
});
}
},
}
);

View File

@@ -20,10 +20,10 @@ export const autoCreateEntity = async ({
entityData?: CreateEntity;
}) => {
// Validate CreatEntity
// Failed to auto-create entity, no `feature_id` provided. Please pass in `feature_id` into the `entity_data` field of the request body",
if (!entityData || !entityData.feature_id) {
throw new RecaseError({
message:
"Failed to auto-create entity, no `feature_id` provided. Please pass in `feature_id` into the `entity_data` field of the request body",
message: `Entity with id ${entityData?.id || "unknown"} not found. To automatically create this entity, please pass in 'feature_id' into the 'entity_data' field of the request body.`,
code: ErrCode.InvalidInputs,
});
}

View File

@@ -22,7 +22,7 @@ export const handleListProducts = async (req: any, res: any) =>
]);
let prods = await Promise.all(
products.map((p) => getProductResponse({ product: p, features })),
products.map((p) => getProductResponse({ product: p, features }))
);
if (req.query.v1_schema === "true") {

View File

@@ -49,7 +49,7 @@ export const handleListProductsBeta = async (req: any, res: any) =>
currency: org.default_currency || undefined,
db,
fullCus: customer ? customer : undefined,
}),
})
);
}

View File

@@ -15,6 +15,7 @@ import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js
import { handleUpdateProductDetails } from "./updateProductDetails.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
@@ -23,7 +24,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
action: "Update product",
handler: async () => {
const { productId } = req.params;
const { orgId, env, logtail: logger, db } = req;
const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms] = await Promise.all([
FeatureService.getFromReq(req),
@@ -70,22 +71,26 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
let itemsExist = notNullish(req.body.items);
// let itemsDifferent = productsAreDifferent2(
// req.body,
// fullProduct,
// features,
// );
if (cusProductExists && itemsExist) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
if (!productSame) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
});
return;
}
res.status(200).send(fullProduct);
return;
}

View File

@@ -56,7 +56,7 @@ const tiersAreSame = (tiers1: any, tiers2: any) => {
return tiers1.every(
(tier: any, index: number) =>
tier.amount === tiers2[index].amount && tier.to === tiers2[index].to,
tier.amount === tiers2[index].amount && tier.to === tiers2[index].to
);
};
@@ -67,13 +67,18 @@ export const featureItemsAreSame = ({
item1: FeatureItem;
item2: FeatureItem;
}) => {
return (
const same =
item1.feature_id === item2.feature_id &&
item1.included_usage == item2.included_usage &&
item1.interval == item2.interval &&
item1.entity_feature_id == item2.entity_feature_id &&
item1.reset_usage_when_enabled == item2.reset_usage_when_enabled
);
item1.reset_usage_when_enabled == item2.reset_usage_when_enabled;
if (!same) {
console.log(`Feature items different: ${item1.feature_id}`);
}
return same;
};
export const priceItemsAreSame = ({
@@ -83,7 +88,13 @@ export const priceItemsAreSame = ({
item1: PriceItem;
item2: PriceItem;
}) => {
return item1.price === item2.price && item1.interval == item2.interval;
const same = item1.price === item2.price && item1.interval == item2.interval;
if (!same) {
console.log(`Price items different: ${item1.price}`);
}
return same;
};
export const featurePriceItemsAreSame = ({
@@ -155,7 +166,7 @@ export const featurePriceItemsAreSame = ({
.map((d) => d.message),
Object.values(pricesSame)
.filter((d) => !d.condition)
.map((d) => d.message),
.map((d) => d.message)
);
}

View File

@@ -18,13 +18,19 @@ import {
UsagePriceConfig,
OnIncrease,
OnDecrease,
FeatureUsageType,
features,
} from "@autumn/shared";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { isFeatureItem, isPriceItem } from "./getItemType.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "./getItemType.js";
import {
itemToBillingInterval,
itemToEntInterval,
@@ -32,6 +38,25 @@ import {
import { itemCanBeProrated } from "./classifyItem.js";
import { shouldProrate } from "../../prices/priceUtils/prorationConfigUtils.js";
export const getResetUsage = ({
item,
feature,
}: {
item: ProductItem;
feature?: Feature;
}) => {
if (!item.feature_id) {
return undefined;
}
if (
nullish(item.reset_usage_when_enabled) &&
(isFeatureItem(item) || isFeaturePriceItem(item)) &&
feature
) {
return feature?.config?.usage_type == FeatureUsageType.Single;
}
return item.reset_usage_when_enabled;
};
// ITEM TO PRICE AND ENTITLEMENT
export const toPrice = ({
item,
@@ -92,7 +117,7 @@ export const toFeature = ({
}) => {
let isBoolean = feature?.type == FeatureType.Boolean;
let resetUsage = item.reset_usage_when_enabled || false;
let resetUsage = getResetUsage({ item, feature });
let ent: Entitlement = {
id: item.entitlement_id || generateId("ent"),
@@ -149,7 +174,10 @@ export const toFeatureAndPrice = ({
newVersion?: boolean;
features: Feature[];
}) => {
let resetUsage = item.reset_usage_when_enabled || false;
let resetUsage = getResetUsage({
item,
feature: features.find((f) => f.id == item.feature_id),
});
let ent: Entitlement = {
id: item.entitlement_id || generateId("ent"),

View File

@@ -12,6 +12,7 @@ import {
AppEnv,
OnIncrease,
UsageModel,
FeatureUsageType,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { notNullish, nullish } from "@/utils/genUtils.js";

View File

@@ -2,7 +2,7 @@ import { ProductService } from "@/internal/products/ProductService.js";
import { Router } from "express";
import { handleRequestError } from "@/utils/errorUtils.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { checkStripeProductExists } from "@/internal/products/productUtils.js";
@@ -16,6 +16,11 @@ import { handleCopyProduct } from "./handlers/handleCopyProduct.js";
import { handleCreateProduct } from "./handlers/handleCreateProduct.js";
import { handleListProducts } from "./handlers/handleListProducts.js";
import { handleListProductsBeta } from "./handlers/handleListProductsBeta.js";
import { ErrCode } from "@autumn/shared";
import { CusProductService } from "../customers/cusProducts/CusProductService.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { mapToProductItems } from "./productV2Utils.js";
import { productsAreSame } from "./productUtils/compareProductUtils.js";
export const productBetaRouter: Router = Router();
productBetaRouter.get("", handleListProductsBeta);
@@ -49,7 +54,7 @@ productRouter.post("/all/init_stripe", async (req: any, res) => {
console.log(
"fullProducts",
fullProducts.map((p) => p.id),
fullProducts.map((p) => p.id)
);
const stripeCli = createStripeCli({
@@ -67,7 +72,7 @@ productRouter.post("/all/init_stripe", async (req: any, res) => {
env,
product,
logger,
}),
})
);
await Promise.all(batchPromises);
}
@@ -88,10 +93,10 @@ productRouter.post("/all/init_stripe", async (req: any, res) => {
price,
entitlements,
product: fullProducts.find(
(p) => p.internal_id == price.internal_product_id,
(p) => p.internal_id == price.internal_product_id
)!,
logger,
}),
})
);
}
@@ -102,3 +107,58 @@ productRouter.post("/all/init_stripe", async (req: any, res) => {
handleRequestError({ req, error, res, action: "Init stripe products" });
}
});
productRouter.get("/:productId/has_customers", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get product has customers",
handler: async () => {
const { productId } = req.params;
const { db, features } = req;
const { id, items, free_trial } = req.body;
const product = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: req.orgId,
env: req.env,
});
if (!product) {
throw new RecaseError({
message: `Product with id ${productId} not found`,
code: ErrCode.ProductNotFound,
statusCode: 404,
});
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: product.internal_id,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: product,
features,
});
const productSame = itemsSame && freeTrialsSame;
console.log(`Product ID: ${product.id}`);
console.log(
`Items same: ${itemsSame}, Free trials same: ${freeTrialsSame}`
);
console.log(
`Cus products on cur version: ${cusProductsCurVersion.length}`
);
res.status(200).json({
current_version: product.version,
will_version: !productSame && cusProductsCurVersion.length > 0,
});
},
})
);

View File

@@ -17,7 +17,29 @@ import {
isPriceItem,
} from "../product-items/productItemUtils/getItemType.js";
import { StatusCodes } from "http-status-codes";
import { itemToPriceOrTiers } from "../product-items/productItemUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { getResetUsage } from "../product-items/productItemUtils/itemToPriceAndEnt.js";
const sanitizeItems = ({
items,
features,
}: {
items: ProductItem[];
features: Feature[];
}) => {
return items.map((item) => {
let priceData = itemToPriceOrTiers({ item });
return {
...item,
reset_usage_when_enabled: getResetUsage({
item,
feature: features.find((f) => f.id === item.feature_id),
}),
...priceData,
};
});
};
export const productsAreSame = ({
newProductV1,
newProductV2,
@@ -61,6 +83,9 @@ export const productsAreSame = ({
features,
});
items1 = sanitizeItems({ items: items1, features });
items2 = sanitizeItems({ items: items2, features });
let itemsSame = true;
let pricesChanged = false;
const newItems: ProductItem[] = [];
@@ -71,10 +96,9 @@ export const productsAreSame = ({
}
// Check if any feature's usage limits have changed
let usageLimitsChanged = false;
items1.some((item1) => {
const matchingItem2 = items2?.find(
(item2) => item2.feature_id === item1.feature_id,
(item2) => item2.feature_id === item1.feature_id
);
if (!matchingItem2) return false;
@@ -84,13 +108,13 @@ export const productsAreSame = ({
return false;
});
items2 =
curProductV2?.items ||
mapToProductItems({
prices: curProductV1?.prices || [],
entitlements: curProductV1?.entitlements || [],
features,
});
// items2 =
// curProductV2?.items ||
// mapToProductItems({
// prices: curProductV1?.prices || [],
// entitlements: curProductV1?.entitlements || [],
// features,
// });
if (items1.length !== items2.length) {
itemsSame = false;

View File

@@ -5,8 +5,10 @@ import {
import {
AppEnv,
BillingInterval,
CreateFreeTrial,
CreateFreeTrialSchema,
FeatureUsageType,
FreeTrial,
FreeTrialDuration,
ProductItem,
ProductV2,
@@ -81,6 +83,7 @@ export const constructProduct = ({
excludeBase = false,
isDefault = true,
isAddOn = false,
freeTrial,
}: {
id?: string;
items: ProductItem[];
@@ -91,6 +94,7 @@ export const constructProduct = ({
excludeBase?: boolean;
isDefault?: boolean;
isAddOn?: boolean;
freeTrial?: CreateFreeTrial;
}) => {
let price = 0;
if (type == "pro") {
@@ -110,7 +114,7 @@ export const constructProduct = ({
: interval
? interval
: BillingInterval.Month,
}),
})
);
}
@@ -119,7 +123,7 @@ export const constructProduct = ({
constructPriceItem({
price: 10,
interval: null,
}),
})
);
}
@@ -141,13 +145,14 @@ export const constructProduct = ({
is_default: type == "free" && isDefault,
version: 1,
group: "",
free_trial: trial
? (CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
}) as any)
: null,
free_trial:
freeTrial || trial
? (CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
}) as any)
: null,
created_at: Date.now(),
};

View File

@@ -0,0 +1,199 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
CreateFreeTrialSchema,
CusProductStatus,
FreeTrialDuration,
Organization,
organizations,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays, addWeeks } from "date-fns";
import { expect } from "chai";
import { eq } from "drizzle-orm";
const testCase = "free1";
let trial1 = CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
});
export let free = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
isDefault: false,
freeTrial: trial1,
type: "free",
id: "enterprise_trial",
});
export let addOn = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 1000,
}),
],
isDefault: false,
type: "free",
isAddOn: true,
id: "add_on",
});
describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and attaching add on`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
let numUsers = 0;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
await db
.update(organizations)
.set({
config: {
...this.org.config,
multiple_trials: true,
},
})
.where(eq(organizations.id, org.id));
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [free, addOn],
prefix: testCase,
});
await createProducts({
autumn,
products: [free, addOn],
db,
orgId: org.id,
env,
});
testClockId = testClockId1!;
});
const approximateDiff = 1000 * 60 * 30; // 30 minutes
it("should attach pro product (prepaid single use)", async function () {
let attachPreview = await autumn.attachPreview({
customer_id: customerId,
product_id: free.id,
});
let attach = await autumn.attach({
customer_id: customerId,
product_id: free.id,
});
let customer = await autumn.customers.get(customerId);
let freeProduct = customer.products.find((p) => p.id === free.id);
expect(freeProduct).to.exist;
expect(freeProduct?.status).to.equal(CusProductStatus.Trialing);
expect(freeProduct?.current_period_end).to.approximately(
addDays(Date.now(), trial1.length).getTime(),
approximateDiff
);
});
const trial2 = CreateFreeTrialSchema.parse({
length: 14,
duration: FreeTrialDuration.Day,
});
it("should update free product's trial end date", async function () {
let attachPreview = await autumn.attachPreview({
customer_id: customerId,
product_id: free.id,
free_trial: trial2,
is_custom: true,
});
let attach = await autumn.attach({
customer_id: customerId,
product_id: free.id,
free_trial: trial2,
is_custom: true,
});
let customer = await autumn.customers.get(customerId);
let freeProduct = customer.products.find((p) => p.id === free.id);
expect(freeProduct?.status).to.equal(CusProductStatus.Trialing);
expect(freeProduct?.current_period_end).to.approximately(
addDays(Date.now(), trial2.length).getTime(),
approximateDiff
);
});
it("should attach add on product", async function () {
let attachPreview = await autumn.attachPreview({
customer_id: customerId,
product_id: addOn.id,
});
let attach = await autumn.attach({
customer_id: customerId,
product_id: addOn.id,
});
let customer = await autumn.customers.get(customerId);
let addOnProduct = customer.products.find((p) => p.id === addOn.id);
let freeProduct = customer.products.find((p) => p.id === free.id);
expect(addOnProduct).to.exist;
expect(addOnProduct?.status).to.equal(CusProductStatus.Active);
expect(freeProduct?.status).to.equal(CusProductStatus.Trialing);
});
after(async function () {
await db
.update(organizations)
.set({
config: {
...this.org.config,
multiple_trials: false,
},
})
.where(eq(organizations.id, org.id));
});
});

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env node
import { randomBytes } from 'crypto';
import { writeFileSync, copyFileSync } from 'fs';
import inquirer from 'inquirer';
import { spawnSync } from 'child_process';
import chalk from 'chalk';
const genUrlSafeBase64 = (bytes) => {
@@ -13,25 +11,6 @@ const genUrlSafeBase64 = (bytes) => {
.replace(/=+$/g, '');
}
const genRandomSubdomain = (length = 10) => {
const chars = 'abcdefghijklmnopqrstuvwxyz';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
const genAlphanumericPassword = (length = 24) => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
async function main() {
// Step 1: Generate secrets
console.log(chalk.magentaBright('\n================ Autumn Setup ================\n'));

View File

@@ -29,6 +29,7 @@ export const events = pgTable(
value: numeric({ mode: "number" }),
set_usage: boolean("set_usage").default(false),
entity_id: text("entity_id"),
internal_entity_id: text("internal_entity_id"),
// Optional stuff...
customer_id: text("customer_id").notNull(),
@@ -45,9 +46,9 @@ export const events = pgTable(
table.env,
table.customer_id,
table.event_name,
table.idempotency_key,
table.idempotency_key
),
],
]
);
export type Event = typeof events.$inferSelect;

View File

@@ -29,6 +29,7 @@ export const ProductItemResponseSchema = z.object({
reset_usage_when_enabled: z.boolean().nullish(),
quantity: z.number().nullish(),
next_cycle_quantity: z.number().nullish(),
entity_feature_id: z.string().nullish(),
display: z
.object({

View File

@@ -19,13 +19,16 @@ export const getFeatureName = ({
let featureName = feature.name || "";
if (feature.display) {
if (!plural) {
if (units && units === 1) {
plural = false;
} else {
plural = true;
}
if (units && units === 1) {
plural = false;
}
// if (!plural) {
// if (units && units === 1) {
// plural = false;
// } else {
// plural = true;
// }
// }
if (plural) {
featureName = feature.display.plural || featureName;

View File

View File

@@ -9,12 +9,14 @@ function CopyButton({
children,
copySize,
variant = "outline",
icon,
}: {
text: string;
className?: string;
children?: React.ReactNode;
copySize?: number;
variant?: string;
icon?: React.ReactNode;
}) {
const [copied, setCopied] = useState(false);
@@ -30,7 +32,7 @@ function CopyButton({
size="icon"
className={cn(
"h-6 px-2 text-t2 w-fit font-mono rounded-md truncate justify-start",
className,
className
)}
onClick={(e) => {
e.preventDefault();

View File

@@ -0,0 +1,34 @@
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const TableButton = ({
className,
children,
icon,
onClick,
}: {
className?: string;
children?: any;
icon?: React.ReactNode;
onClick?: any;
}) => {
return (
<Button
variant="outline"
size="icon"
className={cn(
"h-6 px-2 text-t2 w-fit font-mono rounded-md truncate justify-start",
"bg-transparent text-t3 border-none px-1 shadow-none max-w-full",
className
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
}}
>
{children && <span className="truncate block">{children}</span>}
{icon && <div className="flex items-center justify-center">{icon}</div>}
</Button>
);
};

View File

@@ -37,9 +37,10 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
onClick={(e) => e.stopPropagation()}
className={cn(
"fixed inset-0 z-50 bg-white/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
className
)}
{...props}
/>
@@ -62,7 +63,7 @@ function DialogContent({
bg-stone-50
min-w-sm
`,
className,
className
)}
{...props}
>
@@ -98,7 +99,7 @@ function DialogFooter({
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
variant == "new" &&
"bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200",
className,
className
)}
{...props}
/>

View File

@@ -0,0 +1,17 @@
export const updateSearchParams = ({
navigate,
params,
}: {
navigate: any;
params: Record<string, string>;
}) => {
const url = new URL(window.location.href);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
// console.log("New url:", url.toString());
navigate({
search: url.search,
});
};

View File

@@ -29,7 +29,7 @@ import CopyButton from "@/components/general/CopyButton";
const CustomerWithProductsSchema = CustomerSchema.extend({
customer_products: z.array(
CusProductSchema.extend({ product: ProductSchema }),
CusProductSchema.extend({ product: ProductSchema })
),
});
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
@@ -53,7 +53,7 @@ export const CustomersTable = ({
// Filter out expired products first
const activeProducts = customer.customer_products.filter(
(cusProduct) => cusProduct.status !== CusProductStatus.Expired,
(cusProduct) => cusProduct.status !== CusProductStatus.Expired
);
if (activeProducts.length === 0) {
@@ -172,19 +172,23 @@ export const CustomersTable = ({
<Link
to={getRedirectUrl(
`/customers/${customer.id || customer.internal_id}`,
env,
env
)}
key={index}
className="grid grid-cols-16 gap-2 items-center px-10 w-full text-sm h-8 cursor-default hover:bg-primary/5 text-t2 whitespace-nowrap"
>
<CustomTableCell colSpan={3}>{customer.name}</CustomTableCell>
<CustomTableCell className="font-mono -translate-x-1" colSpan={3}>
<CopyButton
text={customer.id || ""}
className="bg-transparent text-t3 border-none px-1 shadow-none max-w-full"
>
<span className="truncate">{customer.id}</span>
</CopyButton>
{customer.id ? (
<CopyButton
text={customer.id || ""}
className="bg-transparent text-t3 border-none px-1 shadow-none max-w-full"
>
<span className="truncate">{customer.id}</span>
</CopyButton>
) : (
<span className="px-1 text-t3">NULL</span>
)}
</CustomTableCell>
<CustomTableCell colSpan={3}>{customer.email}</CustomTableCell>
<CustomTableCell colSpan={5}>
@@ -218,7 +222,7 @@ export const CustomTableCell = ({
className={cn(
colSpan ? `col-span-${colSpan}` : "col-span-3",
"overflow-hidden text-ellipsis pr-1",
className,
className
)}
>
{children}

View File

@@ -133,10 +133,18 @@ function CustomersView({ env }: { env: AppEnv }) {
params.status = filters.status.join(",");
}
if (filters?.product_id?.length > 0) {
params.product_id = filters.product_id.join(",");
if (filters?.product_id) {
if (Array.isArray(filters.product_id)) {
params.product_id = filters.product_id.join(",");
} else {
params.product_id = filters.product_id;
}
}
// if (filters?.product_id?.length > 0) {
// params.product_id = filters.product_id.join(",");
// }
setSearchParams(params);
}, [filters]);

View File

@@ -1,30 +1,22 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { navigateTo } from "@/utils/genUtils";
import {
formatUnixToDateTime,
formatUnixToDateTimeWithMs,
} from "@/utils/formatUtils/formatDateUtils";
import CopyButton from "@/components/general/CopyButton";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useState } from "react";
import { Row, Item } from "@/components/general/TableGrid";
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { ViewUserEvents } from "../analytics/ViewUserEvents";
import { useNavigate } from "react-router";
import { AppEnv } from "@autumn/shared";
import { useCustomerContext } from "./CustomerContext";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "./components/CusProductEntityItem";
export const CustomerEventsList = ({
events,
@@ -37,8 +29,7 @@ export const CustomerEventsList = ({
}) => {
const [selectedEvent, setSelectedEvent] = useState<any>(null);
const navigate = useNavigate();
console.log(events);
const { showEntityView } = useCustomerContext();
return (
<div>
@@ -81,10 +72,15 @@ export const CustomerEventsList = ({
</p>
</div>
) : (
<Row type="header" className="grid-cols-12 pr-0">
<Row
type="header"
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
>
<Item className="col-span-3">Event Name</Item>
<Item className="col-span-3">Value</Item>
<Item className="col-span-3">Status</Item>
{showEntityView && <Item className="col-span-3"></Item>}
<Item className="col-span-2">Timestamp</Item>
<Item className="col-span-1" />
</Row>
@@ -93,22 +89,21 @@ export const CustomerEventsList = ({
{events.map((event: any) => (
<Row
key={event.id}
className="grid-cols-12 pr-0"
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
onClick={() => setSelectedEvent(event)}
>
<Item className="col-span-3 font-mono">{event.event_name}</Item>
<Item className="col-span-3 relative">
<span className="font-mono truncate">
{event.value || event.properties.value || 1}
</span>
{/* <div className="absolute hidden group-hover:block top-1/2 -translate-y-1/2 right-0 rounded-sm h-5 w-5">
<CopyButton text={event.id} className="bg-white h-full w-full" />
</div> */}
</Item>
<Item className="col-span-3 font-mono">
<span className="text-t3">POST </span>
<span className="text-lime-600">200</span>
</Item>
{showEntityView && <Item className="col-span-3"></Item>}
<Item className="col-span-2 text-t3 text-xs">
<Tooltip>
<TooltipTrigger>

View File

@@ -1,335 +0,0 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import {
compareStatus,
getBackendErr,
navigateTo,
notNullish,
} from "@/utils/genUtils";
import { CusProduct, CusProductStatus, FullCusProduct } from "@autumn/shared";
import { useNavigate } from "react-router";
import { useCustomerContext } from "./CustomerContext";
import {
DropdownMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import SmallSpinner from "@/components/general/SmallSpinner";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { AdminHover } from "@/components/general/AdminHover";
import AddProduct from "./add-product/NewProductDropdown";
import { Item, Row } from "@/components/general/TableGrid";
import { cn } from "@/lib/utils";
import { CusProductStripeLink } from "./components/CusProductStripeLink";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
export const CustomerProductList = ({
customer,
products,
}: {
customer: any;
products: any;
}) => {
const navigate = useNavigate();
const { env, versionCounts, entities, entityId } = useCustomerContext();
const [showExpired, setShowExpired] = useState(false);
const sortedProducts = customer.products
.filter((p: CusProduct & { entitlements: any[] }) => {
if (showExpired) {
return true;
}
const entity = entities.find((e: any) => e.id === entityId);
const entityMatches =
entity && notNullish(p.internal_entity_id)
? p.internal_entity_id === entity.internal_id ||
p.entitlements.some(
(cusEnt: any) =>
cusEnt.entities &&
Object.keys(cusEnt.entities).includes(entity.internal_id),
)
: true;
return (
p.status !== CusProductStatus.Expired &&
(entityId ? entityMatches : true)
);
})
.sort((a: any, b: any) => {
if (a.status !== b.status) {
return compareStatus(a.status, b.status);
}
return b.created_at - a.created_at;
});
return (
<div>
<div className="flex items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 pl-10 pr-7 h-10">
<h2 className="text-sm text-t2 font-medium col-span-2 flex">
Products
</h2>
<div className="flex w-full h-full items-center col-span-8 justify-end">
<div className="flex w-fit h-full items-center gap-4">
<Button
variant="ghost"
className={cn(
"text-t3 text-xs font-normal p-0",
showExpired && "text-t1 hover:text-t1",
)}
size="sm"
onClick={() => setShowExpired(!showExpired)}
>
Show Expired
</Button>
{/* <CreateEntitlement buttonType={"feature"} /> */}
<AddProduct />
</div>
</div>
</div>
{sortedProducts.length === 0 ? (
<div className="flex pl-10 items-center h-10">
<p className="text-t3">Attach a product to this customer</p>
</div>
) : (
<Row type="header" className="grid-cols-12 pr-0">
<Item className="col-span-3">Name</Item>
<Item className="col-span-3">Product ID</Item>
<Item className="col-span-3">Status</Item>
<Item className="col-span-2">Created At</Item>
<Item className="col-span-1" />
</Row>
)}
{sortedProducts.map((cusProduct: FullCusProduct) => {
return (
<Row
key={cusProduct.id}
className="grid-cols-12 pr-0"
onClick={() => {
const entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id,
);
navigateTo(
`/customers/${customer.id || customer.internal_id}/${
cusProduct.product_id
}?id=${cusProduct.id}${
entity ? `&entity_id=${entity.id || entity.internal_id}` : ""
}`,
navigate,
env,
);
}}
>
<Item className="col-span-3">
<AdminHover
texts={[
{
key: "Cus Product ID",
value: cusProduct.id,
},
// {
// key: "Stripe Subscription ID",
// value: cusProduct.subscription_ids?.[0] || "N/A",
// },
...(cusProduct.subscription_ids
? cusProduct.subscription_ids.map((id: string) => ({
key: "Stripe Subscription ID",
value: id,
}))
: []),
{
key: "Entity ID",
value: cusProduct.entity_id || "N/A",
},
]}
>
<div className="flex items-center gap-2">
<p>{cusProduct.product.name}</p>
{versionCounts[cusProduct.product.id] > 1 && (
<Badge
variant="outline"
className="text-xs bg-stone-50 text-t3 px-2 py-0 ml-2 font-mono"
>
v{cusProduct.product.version}
</Badge>
)}
</div>
</AdminHover>
</Item>
<Item className="col-span-3 text-t3 font-mono overflow-hidden text-ellipsis">
{cusProduct.product_id}
</Item>
<Item className="col-span-3">
<div className="flex gap-0.5 items-center">
{cusProduct.status === "active" && (
<Badge variant="status" className="bg-lime-500 h-fit">
active
</Badge>
)}
{cusProduct.status === "expired" && (
<Badge variant="status" className="bg-stone-800 h-fit">
expired
</Badge>
)}
{cusProduct.status === "past_due" && (
<Badge variant="status" className="bg-red-500 h-fit">
past due
</Badge>
)}
{cusProduct.status === "scheduled" && (
<Badge variant="status" className="bg-blue-500 h-fit">
scheduled
</Badge>
)}
<CusProductStripeLink cusProduct={cusProduct} />
</div>
</Item>
<Item className="col-span-2 text-xs text-t3">
{formatUnixToDateTime(cusProduct.created_at).date}{" "}
{formatUnixToDateTime(cusProduct.created_at).time}
</Item>
<Item className="col-span-1 pr-4 flex items-center justify-center">
<EditCustomerProductToolbar cusProduct={cusProduct} />
</Item>
</Row>
);
})}
</div>
);
};
const EditCustomerProductToolbar = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<DropdownMenu open={dialogOpen} onOpenChange={setDialogOpen}>
<DropdownMenuTrigger asChild>
<ToolbarButton className="!w-4 !h-6 !rounded-md text-t3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2">
{/* Update status */}
{[CusProductStatus.Expired].map((status) => (
<DropdownMenuItem
key={status}
className="p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<UpdateStatusDropdownBtn cusProduct={cusProduct} status={status} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
const UpdateStatusDropdownBtn = ({
cusProduct,
status,
}: {
cusProduct: FullCusProduct;
status: CusProductStatus;
}) => {
const [loading, setLoading] = useState(false);
const [showDefaultWarning, setShowDefaultWarning] = useState(false);
const { env, cusMutate } = useCustomerContext();
const axiosInstance = useAxiosInstance({ env });
const handleStatusUpdate = async () => {
setLoading(true);
try {
await CusService.updateCusProductStatus(axiosInstance, cusProduct.id, {
status,
});
await cusMutate();
} catch (error) {
toast.error(getBackendErr(error, "Failed to update status"));
}
setLoading(false);
};
const handleExpireClick = () => {
// Check if this is the expired status and if the product is default
if (status === CusProductStatus.Expired && cusProduct.product?.is_default) {
setShowDefaultWarning(true);
} else {
handleStatusUpdate();
}
};
return (
<>
<Dialog open={showDefaultWarning} onOpenChange={setShowDefaultWarning}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Expire Default Product</DialogTitle>
</DialogHeader>
<div className="">
<p className="text-sm text-gray-600">
This is the default product. Expiring it will reattach it to the
customer and reset their features.
</p>
</div>
<DialogFooter>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowDefaultWarning(false)}
>
Cancel
</Button>
<Button
onClick={() => {
setShowDefaultWarning(false);
handleStatusUpdate();
}}
>
Expire Default
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="ghost"
dim={5}
size="sm"
className="p-2 h-full w-full flex justify-between"
onClick={handleExpireClick}
>
{loading ? (
<SmallSpinner />
) : (
<>
<span>{keyToTitle(status)}</span>
</>
)}
</Button>
</>
);
};

View File

@@ -2,29 +2,27 @@
import { useAxiosSWR } from "@/services/useAxiosSwr";
import LoadingScreen from "@/views/general/LoadingScreen";
import { AppEnv } from "@autumn/shared";
import { AppEnv, CusProductStatus } from "@autumn/shared";
import { CustomerContext } from "./CustomerContext";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { CustomerProductList } from "./CustomerProductList";
import { CustomerEntitlementsList } from "./entitlements/CustomerEntitlementsList";
import { getRedirectUrl, navigateTo } from "@/utils/genUtils";
import { CustomerEventsList } from "./product/CustomerEventsList";
import { getRedirectUrl, notNullish } from "@/utils/genUtils";
import { CustomerEventsList } from "./CustomerEventsList";
import { useEffect, useState } from "react";
import ErrorScreen from "@/views/general/ErrorScreen";
import { InvoicesTable } from "./InvoicesTable";
import { CustomerSidebar } from "./customer-sidebar/customer-sidebar";
import { CustomerSidebar } from "./customer-sidebar/CustomerSidebar";
import { CustomerBreadcrumbs } from "./customer-breadcrumbs";
import { SelectEntity } from "./customer-sidebar/select-entity";
import { CustomerProductList } from "./customer-product-list/CustomerProductList";
export default function CustomerView({ env }: { env: AppEnv }) {
const { customer_id } = useParams();
const [searchParams] = useSearchParams();
const entityIdParam = searchParams.get("entity_id");
const navigate = useNavigate();
const {
data,
isLoading,
@@ -69,6 +67,12 @@ export default function CustomerView({ env }: { env: AppEnv }) {
const { customer, products, invoices, coupons, discount, events, entities } =
data;
const showEntityView = customer.customer_products.some(
(cp: any) =>
notNullish(cp.internal_entity_id) &&
cp.status !== CusProductStatus.Expired
);
return (
<CustomerContext.Provider
value={{
@@ -84,6 +88,7 @@ export default function CustomerView({ env }: { env: AppEnv }) {
referrals,
entityId,
setEntityId,
showEntityView,
}}
>
<div className="flex w-full overflow-auto h-full ">
@@ -116,7 +121,11 @@ export default function CustomerView({ env }: { env: AppEnv }) {
</div>
<InvoicesTable />
<CustomerEventsList customer={customer} events={events} env={env} />
<CustomerEventsList
customer={customer}
events={events}
env={env}
/>
</div>
{/* customer details */}
</div>

View File

@@ -6,19 +6,22 @@ import { toast } from "sonner";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { Row, Item } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "./components/CusProductEntityItem";
export const InvoicesTable = () => {
const { env, invoices, products, entityId, entities } = useCustomerContext();
const { env, invoices, products, entityId, entities, showEntityView } =
useCustomerContext();
const axiosInstance = useAxiosInstance({ env });
const entity = entities.find(
(e: any) => e.id === entityId || e.internal_id === entityId,
(e: any) => e.id === entityId || e.internal_id === entityId
);
const getStripeInvoice = async (stripeInvoiceId: string) => {
try {
const { data } = await axiosInstance.get(
`/v1/invoices/${stripeInvoiceId}/stripe`,
`/v1/invoices/${stripeInvoiceId}/stripe`
);
return data;
} catch (error) {
@@ -54,8 +57,15 @@ export const InvoicesTable = () => {
</div>
) : (
<>
<Row type="header" className="grid-cols-12 pr-0">
<Row
type="header"
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15"
)}
>
<Item className="col-span-3">Products</Item>
{showEntityView && <Item className="col-span-3">Entity</Item>}
<Item className="col-span-3">Total</Item>
<Item className="col-span-3">Status</Item>
<Item className="col-span-2">Created At</Item>
@@ -67,7 +77,7 @@ export const InvoicesTable = () => {
{invoicesFiltered.map((invoice: Invoice) => (
<Row
key={invoice.id}
className="grid-cols-12 pr-0"
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
onClick={async () => {
const stripeInvoice = await getStripeInvoice(invoice.stripe_id);
if (!stripeInvoice.hosted_invoice_url) {
@@ -96,6 +106,13 @@ export const InvoicesTable = () => {
.join(", ")}
</AdminHover>
</Item>
{showEntityView && (
<Item className="col-span-3 -translate-x-1">
<CusProductEntityItem
internalEntityId={invoice.internal_entity_id}
/>
</Item>
)}
<Item className="col-span-3">
{invoice.total.toFixed(2)} {invoice.currency.toUpperCase()}
{getTotalDiscountAmount(invoice) > 0 && (

View File

@@ -0,0 +1,37 @@
import { TableButton } from "@/components/general/table-components/TableButton";
import { updateSearchParams } from "@/utils/navUtils";
import { FullCusProduct } from "@autumn/shared";
import { ExternalLinkIcon } from "lucide-react";
import { useNavigate } from "react-router";
import { useCustomerContext } from "../CustomerContext";
export const CusProductEntityItem = ({
internalEntityId,
}: {
internalEntityId?: string | null;
}) => {
const { entities } = useCustomerContext();
// console.log("Cus product", cusProduct);
const entity = entities.find((e: any) => e.internal_id === internalEntityId);
const navigate = useNavigate();
return internalEntityId ? (
<TableButton
onClick={() => {
updateSearchParams({
navigate,
params: {
entity_id: entity?.id || entity?.internal_id,
},
});
}}
// icon={<ExternalLinkIcon size={12} />}
>
<span className="truncate">
{entity?.name || entity?.id || "Unknown"}
</span>
</TableButton>
) : (
<span></span>
);
};

View File

@@ -0,0 +1,152 @@
import {
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useState } from "react";
import { toast } from "sonner";
import { useCustomerContext } from "../CustomerContext";
import { notNullish } from "@/utils/genUtils";
export const CancelProductDialog = ({
cusProduct,
open,
setOpen,
}: {
cusProduct: FullCusProduct;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const [immediateLoading, setImmediateLoading] = useState(false);
const [endOfCycleLoading, setEndOfCycleLoading] = useState(false);
const axiosInstance = useAxiosInstance();
const { cusMutate, customer, entities } = useCustomerContext();
const handleClicked = async (cancelImmediately?: boolean) => {
if (cancelImmediately) {
setImmediateLoading(true);
} else {
setEndOfCycleLoading(true);
}
const entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
);
try {
await axiosInstance.post(`/v1/cancel`, {
customer_id: cusProduct.customer_id || cusProduct.internal_customer_id,
product_id: cusProduct.product_id,
entity_id: entity?.id || entity?.internal_id,
cancel_immediately: cancelImmediately,
});
await cusMutate();
setOpen(false);
toast.success("Product cancelled");
} catch (error) {
toast.error("Failed to cancel product");
} finally {
if (cancelImmediately) {
setImmediateLoading(false);
} else {
setEndOfCycleLoading(false);
}
}
};
const isDefault = cusProduct.product.is_default;
const isScheduled = cusProduct.status == CusProductStatus.Scheduled;
const hasSubscription =
cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0;
const currentMain = customer.customer_products.find(
(cp: any) =>
!cp.is_add_on &&
cp.product_id !== cusProduct.product_id &&
cp.product.group == cusProduct.product.group &&
(cp.status == CusProductStatus.Active ||
cp.status == CusProductStatus.PastDue) &&
cp.internal_entity_id == cusProduct.internal_entity_id
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Cancel Product</DialogTitle>
</DialogHeader>
<div className="mb-2 text-sm">
{isScheduled ? (
<p className="text-t2">
This product is scheduled to start on{" "}
{formatUnixToDateTime(cusProduct.starts_at).date}.
{currentMain
? ` Are you sure you want to remove this schedule and renew the current product (${currentMain.product.name})?`
: " Are you sure you want to remove this schedule?"}
</p>
) : isDefault ? (
<p className="text-t2">
This is a default product. Cancelling it will simply reset the
feature usages for this customer.
</p>
) : (
<p className="text-t2">
Are you sure you want to cancel this product? This action cannot
be undone.
</p>
)}
</div>
<DialogFooter>
<div className="flex gap-2">
{isScheduled ? (
<Button
onClick={() => handleClicked(true)}
variant="destructive"
isLoading={immediateLoading}
>
Cancel scheduled product
</Button>
) : isDefault ? (
<Button
onClick={() => handleClicked(true)}
variant="destructive"
isLoading={immediateLoading}
>
Cancel default product
</Button>
) : (
<>
{hasSubscription && (
<Button
onClick={() => handleClicked(false)}
variant="outline"
isLoading={endOfCycleLoading}
// disabled={immediateLoading || endOfCycleLoading}
>
Cancel at end of cycle
</Button>
)}
<Button
variant="destructive"
onClick={() => handleClicked(true)}
isLoading={immediateLoading}
disabled={immediateLoading || endOfCycleLoading}
>
Cancel immediately
</Button>
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,63 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { CusProductStripeLink } from "./CusProductStripeLink";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { notNullish } from "@/utils/genUtils";
export const CusProductStatusItem = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const getStatus = () => {
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
const canceled =
notNullish(cusProduct.canceled_at) &&
cusProduct.status !== CusProductStatus.Expired;
if (canceled) return "canceled";
if (trialing) {
return CusProductStatus.Trialing;
}
return cusProduct.status;
};
const isCanceled = notNullish(cusProduct.canceled_at);
const statusToColor: Record<CusProductStatus | "canceled", string> = {
[CusProductStatus.Active]: "bg-lime-500",
[CusProductStatus.Expired]: "bg-stone-800",
[CusProductStatus.PastDue]: "bg-red-500",
[CusProductStatus.Scheduled]: "bg-blue-500",
[CusProductStatus.Trialing]: "bg-yellow-400",
canceled: "bg-gray-500",
[CusProductStatus.Unknown]: "bg-gray-500",
};
return (
<div className="flex gap-0.5 items-center">
<Badge
variant="status"
className={cn("h-fit", statusToColor[getStatus()])}
>
{keyToTitle(getStatus()).toLowerCase()}
</Badge>
{/* {isCanceled && (
<Badge variant="status" className="ml-2 bg-gray-500">
canceled
</Badge>
)} */}
{/* <span className="text-t3">
ends {formatUnixToDateTime(cusProduct.trial_ends_at).date}
</span> */}
<CusProductStripeLink cusProduct={cusProduct} />
</div>
);
};

View File

@@ -0,0 +1,75 @@
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { UpdateStatusDropdownBtn } from "./UpdateStatusDropdownBtn";
import { useState } from "react";
import { useCustomerContext } from "../CustomerContext";
import { TransferProductDialog } from "./TransferProductDialog";
import { ArrowLeftRight, ArrowRightFromLine, Delete } from "lucide-react";
import { CancelProductDialog } from "./CancelProductDialog";
export const CusProductToolbar = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const { showEntityView } = useCustomerContext();
const [dialogOpen, setDialogOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
return (
<>
<TransferProductDialog
cusProduct={cusProduct}
open={transferOpen}
setOpen={setTransferOpen}
/>
<CancelProductDialog
cusProduct={cusProduct}
open={cancelOpen}
setOpen={setCancelOpen}
/>
<DropdownMenu open={dialogOpen} onOpenChange={setDialogOpen}>
<DropdownMenuTrigger asChild>
<ToolbarButton className="!w-4 !h-6 !rounded-md text-t3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="text-t2 w-36" align="end">
{showEntityView && (
<DropdownMenuItem
className="flex items-center justify-between w-full text-t2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTransferOpen(true);
setDialogOpen(false);
}}
>
<p>Transfer</p>
<ArrowLeftRight width={14} className="text-t3" />
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex items-center justify-between w-full text-t2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCancelOpen(true);
setDialogOpen(false);
}}
>
<p>Cancel</p>
<Delete width={14} className="text-t3" />
{/* <ArrowRightFromLine width={14} className="text-t3" /> */}
{/* <UpdateStatusDropdownBtn cusProduct={cusProduct} /> */}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -0,0 +1,212 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import {
compareStatus,
getBackendErr,
navigateTo,
notNullish,
} from "@/utils/genUtils";
import { CusProduct, CusProductStatus, FullCusProduct } from "@autumn/shared";
import { useNavigate } from "react-router";
import { useCustomerContext } from "../CustomerContext";
import {
DropdownMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import SmallSpinner from "@/components/general/SmallSpinner";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { AdminHover } from "@/components/general/AdminHover";
import AddProduct from "../add-product/NewProductDropdown";
import { Item, Row } from "@/components/general/TableGrid";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { CusProductStatusItem } from "../customer-product-list/CusProductStatus";
import { CusProductEntityItem } from "../components/CusProductEntityItem";
import { CusProductToolbar } from "./CusProductToolbar";
export const CustomerProductList = ({
customer,
products,
}: {
customer: any;
products: any;
}) => {
const navigate = useNavigate();
const { env, versionCounts, entities, entityId, showEntityView } =
useCustomerContext();
const [showExpired, setShowExpired] = useState(false);
const sortedProducts = customer.products
.filter((p: CusProduct & { entitlements: any[] }) => {
if (showExpired) {
return true;
}
const entity = entities.find((e: any) => e.id === entityId);
const entityMatches =
entity && notNullish(p.internal_entity_id)
? p.internal_entity_id === entity.internal_id ||
p.entitlements.some(
(cusEnt: any) =>
cusEnt.entities &&
Object.keys(cusEnt.entities).includes(entity.internal_id)
)
: true;
return (
p.status !== CusProductStatus.Expired &&
(entityId ? entityMatches : true)
);
})
.sort((a: any, b: any) => {
if (a.status !== b.status) {
return compareStatus(a.status, b.status);
}
return b.created_at - a.created_at;
});
const getCusProductHoverTexts = (cusProduct: FullCusProduct) => {
return [
{
key: "Cus Product ID",
value: cusProduct.id,
},
...(cusProduct.subscription_ids
? cusProduct.subscription_ids.map((id: string) => ({
key: "Stripe Subscription ID",
value: id,
}))
: []),
{
key: "Entity ID",
value: cusProduct.entity_id || "N/A",
},
];
};
return (
<div>
<div className="flex items-center grid grid-cols-10 gap-8 justify-between border-y bg-stone-100 pl-10 pr-7 h-10">
<h2 className="text-sm text-t2 font-medium col-span-2 flex">
Products
</h2>
<div className="flex w-full h-full items-center col-span-8 justify-end">
<div className="flex w-fit h-full items-center gap-4">
<Button
variant="ghost"
className={cn(
"text-t3 text-xs font-normal p-0",
showExpired && "text-t1 hover:text-t1"
)}
size="sm"
onClick={() => setShowExpired(!showExpired)}
>
Show Expired
</Button>
{/* <CreateEntitlement buttonType={"feature"} /> */}
<AddProduct />
</div>
</div>
</div>
{sortedProducts.length === 0 ? (
<div className="flex pl-10 items-center h-10">
<p className="text-t3">Attach a product to this customer</p>
</div>
) : (
<Row
type="header"
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
>
<Item className="col-span-3">Name</Item>
{showEntityView && <Item className="col-span-3">Entity</Item>}
<Item className="col-span-3">Product ID</Item>
<Item className="col-span-3">Status</Item>
<Item className="col-span-2">Created At</Item>
<Item className="col-span-1" />
</Row>
)}
{sortedProducts.map((cusProduct: FullCusProduct) => {
return (
<Row
key={cusProduct.id}
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15"
)}
onClick={() => {
const entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
);
navigateTo(
`/customers/${customer.id || customer.internal_id}/${
cusProduct.product_id
}?id=${cusProduct.id}${
entity ? `&entity_id=${entity.id || entity.internal_id}` : ""
}`,
navigate,
env
);
}}
>
<Item className="col-span-3">
<AdminHover texts={getCusProductHoverTexts(cusProduct)}>
<div className="flex items-center gap-2">
<p>{cusProduct.product.name}</p>
{versionCounts[cusProduct.product.id] > 1 && (
<Badge
variant="outline"
className="text-xs bg-stone-50 text-t3 px-2 py-0 ml-2 font-mono"
>
v{cusProduct.product.version}
</Badge>
)}
</div>
</AdminHover>
</Item>
{showEntityView && (
<Item className="col-span-3 -translate-x-1">
<CusProductEntityItem
internalEntityId={cusProduct.internal_entity_id}
/>
</Item>
)}
<Item className="col-span-3 text-t3 font-mono overflow-hidden text-ellipsis">
{cusProduct.product_id}
</Item>
<Item className="col-span-3">
<CusProductStatusItem cusProduct={cusProduct} />
</Item>
<Item className="col-span-2 text-xs text-t3">
{formatUnixToDateTime(cusProduct.created_at).date}{" "}
{formatUnixToDateTime(cusProduct.created_at).time}
</Item>
<Item className="col-span-1 pr-4 flex items-center justify-center">
{cusProduct.status !== CusProductStatus.Expired && (
<CusProductToolbar cusProduct={cusProduct} />
)}
</Item>
</Row>
);
})}
</div>
);
};

View File

@@ -0,0 +1,119 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogOverlay,
DialogPortal,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FullCusProduct } from "@autumn/shared";
import { ArrowLeftRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useCustomerContext } from "../CustomerContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
export const TransferProductDialog = ({
cusProduct,
open,
setOpen,
}: {
cusProduct: FullCusProduct;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { entities, cusMutate } = useCustomerContext();
const axiosInstance = useAxiosInstance();
const [loading, setLoading] = useState(false);
const filteredEntities = entities.filter(
(entity: any) => entity.internal_id !== cusProduct.internal_entity_id
);
const [selectedEntity, setSelectedEntity] = useState<any>(null);
useEffect(() => {
if (open) {
setSelectedEntity(null);
}
}, [open]);
const handleClicked = async () => {
if (!selectedEntity) {
toast.error("Please select an entity to transfer the product to");
return;
}
setLoading(true);
try {
await axiosInstance.post(
`/v1/customers/${cusProduct.customer_id}/transfer_product`,
{
internal_entity_id: selectedEntity.internal_id,
customer_product_id: cusProduct.id,
}
);
await cusMutate();
toast.success("Product transferred successfully");
setOpen(false);
} catch (error) {
console.log(error);
toast.error(getBackendErr(error, "Failed to transfer product"));
}
setLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Transfer Product</DialogTitle>
</DialogHeader>
<div className="">
<p className="text-sm text-t2">
Select another entity to transfer this product to.
</p>
</div>
<Select
value={selectedEntity?.id}
onValueChange={(value) => {
setSelectedEntity(entities.find((e: any) => e.id === value));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select an entity" />
</SelectTrigger>
<SelectContent>
{filteredEntities.length > 0 ? (
filteredEntities.map((entity: any) => (
<SelectItem key={entity.id} value={entity.id}>
{entity.name || entity.internal_id}{" "}
{entity.id && <span className="text-t3">({entity.id})</span>}
</SelectItem>
))
) : (
<SelectItem value="no-entities">
No other entities. Please create an entity first.
</SelectItem>
)}
</SelectContent>
</Select>
<DialogFooter>
<div className="flex gap-2">
<Button onClick={handleClicked} isLoading={loading}>
Transfer
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,102 @@
import SmallSpinner from "@/components/general/SmallSpinner";
import {
DialogHeader,
DialogFooter,
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { getBackendErr } from "@/utils/genUtils";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useCustomerContext } from "../CustomerContext";
export const UpdateStatusDropdownBtn = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const [loading, setLoading] = useState(false);
const [showDefaultWarning, setShowDefaultWarning] = useState(false);
const { env, cusMutate } = useCustomerContext();
const axiosInstance = useAxiosInstance({ env });
const handleStatusUpdate = async () => {
setLoading(true);
try {
await CusService.updateCusProductStatus(axiosInstance, cusProduct.id, {
status: CusProductStatus.Expired,
});
await cusMutate();
} catch (error) {
toast.error(getBackendErr(error, "Failed to update status"));
}
setLoading(false);
};
const handleExpireClick = () => {
// Check if this is the expired status and if the product is default
if (cusProduct.product?.is_default) {
setShowDefaultWarning(true);
} else {
handleStatusUpdate();
}
};
return (
<>
<Dialog open={showDefaultWarning} onOpenChange={setShowDefaultWarning}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Expire Default Product</DialogTitle>
</DialogHeader>
<div className="">
<p className="text-sm text-gray-600">
This is the default product. Expiring it will reattach it to the
customer and reset their features.
</p>
</div>
<DialogFooter>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowDefaultWarning(false)}
>
Cancel
</Button>
<Button
onClick={() => {
setShowDefaultWarning(false);
handleStatusUpdate();
}}
>
Expire Default
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="ghost"
dim={5}
size="sm"
className="p-2 h-full w-full flex justify-between"
onClick={handleExpireClick}
>
{loading ? (
<SmallSpinner />
) : (
<>
<span>Expire</span>
</>
)}
</Button>
</>
);
};

View File

@@ -11,10 +11,13 @@ import CopyButton from "@/components/general/CopyButton";
export const CustomerEntities = () => {
const { entityId, setEntityId, entities, features } = useCustomerContext();
const entity = entities.find((entity: Entity) => entity.id === entityId);
const entity = entities.find(
(entity: Entity) =>
entity.id === entityId || entity.internal_id === entityId
);
const feature = features.find(
(feature: Feature) => entity?.internal_feature_id === feature.internal_id,
(feature: Feature) => entity?.internal_feature_id === feature.internal_id
);
const featureName = getFeatureName({
@@ -22,6 +25,7 @@ export const CustomerEntities = () => {
plural: false,
capitalize: true,
});
if (!entity) {
return null;
}
@@ -34,9 +38,13 @@ export const CustomerEntities = () => {
<SidebarLabel>ID</SidebarLabel>
<div className="col-span-6 justify-end flex">
<div className="w-full flex justify-end">
<CopyButton text={entity?.id} className="">
{entity?.id}
</CopyButton>
{entity.id ? (
<CopyButton text={entity?.id} className="">
{entity?.id}
</CopyButton>
) : (
<span className="px-1 text-t3">N/A</span>
)}
</div>
</div>
{entity && (

View File

@@ -27,9 +27,13 @@ export const CustomerDetails = ({
<SidebarLabel>ID</SidebarLabel>
<div className="col-span-6 justify-end flex">
<div className="w-full flex justify-end">
<CopyButton text={customer.id} className="">
{customer.id}
</CopyButton>
{customer.id ? (
<CopyButton text={customer.id} className="">
{customer.id}
</CopyButton>
) : (
<span className="px-2 text-t3">N/A</span>
)}
</div>
</div>

View File

@@ -28,8 +28,6 @@ export const SelectEntity = ({
const navigate = useNavigate();
const location = useLocation();
console.log(cusContext);
if (!entities || entities.length === 0) {
// Only show create entity flow if there are continuous use features
const hasContinuousUseFeatures = cusContext?.features?.some(

View File

@@ -1,6 +1,7 @@
import {
AllowanceType,
FeatureType,
FullCusEntWithFullCusProduct,
FullCustomerEntitlement,
} from "@autumn/shared";
@@ -16,23 +17,33 @@ import { Item, Row } from "@/components/general/TableGrid";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CusProductEntityItem } from "../components/CusProductEntityItem";
export const CustomerEntitlementsList = () => {
const [featureType, setFeatureType] = useState<FeatureType>(
FeatureType.Metered,
FeatureType.Metered
);
const [showExpired, setShowExpired] = useState(false);
const { products, customer, entities, entityId } = useCustomerContext();
const { products, customer, entities, entityId, showEntityView } =
useCustomerContext();
const [selectedCusEntitlement, setSelectedCusEntitlement] =
useState<FullCustomerEntitlement | null>(null);
const filteredEntitlements = customer.entitlements.filter(
(cusEnt: FullCustomerEntitlement) => {
const cusEnts: FullCusEntWithFullCusProduct[] =
customer.customer_products.flatMap((cp: any) => {
return cp.customer_entitlements.map((e: any) => ({
...e,
customer_product: cp,
}));
});
const filteredEntitlements = cusEnts.filter(
(cusEnt: FullCusEntWithFullCusProduct) => {
const entFeatureType = cusEnt.entitlement.feature.type;
const cusProduct = customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id,
);
const cusProduct = cusEnt.customer_product;
const isExpired = cusProduct?.status === "expired";
const isScheduled = cusProduct?.status === "scheduled";
@@ -53,7 +64,7 @@ export const CustomerEntitlementsList = () => {
entityMatch = false;
const cusProduct = customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id,
(p: any) => p.id === cusEnt.customer_product_id
);
// 1. Product match
@@ -73,21 +84,9 @@ export const CustomerEntitlementsList = () => {
!isScheduled &&
entityMatch
);
},
}
);
const getProductName = (cusEnt: FullCustomerEntitlement) => {
const cusProduct = customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id,
);
const product = products.find((p: any) => p.id === cusProduct?.product_id);
return product?.name;
};
const sortedEntitlements = filteredEntitlements;
const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => {
setSelectedCusEntitlement(cusEnt);
};
@@ -95,7 +94,7 @@ export const CustomerEntitlementsList = () => {
const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => {
const entitlement = cusEnt.entitlement;
const featureEntities = entities.filter(
(e: any) => e.feature_id === entitlement.feature.id,
(e: any) => e.feature_id === entitlement.feature.id
);
const hoverTexts = [
@@ -144,7 +143,7 @@ export const CustomerEntitlementsList = () => {
variant="ghost"
className={cn(
"text-t3 text-xs font-normal p-0",
showExpired && "text-t1 hover:text-t1",
showExpired && "text-t1 hover:text-t1"
)}
size="sm"
onClick={() => setShowExpired(!showExpired)}
@@ -177,7 +176,7 @@ export const CustomerEntitlementsList = () => {
selectedCusEntitlement={selectedCusEntitlement}
setSelectedCusEntitlement={setSelectedCusEntitlement}
/>
{sortedEntitlements.length === 0 ? (
{filteredEntitlements.length === 0 ? (
<div className="flex pl-10 items-center h-10">
<p className="text-t3">
Attach a product to grant access to features
@@ -185,8 +184,15 @@ export const CustomerEntitlementsList = () => {
</div>
) : (
<>
<Row type="header" className="grid-cols-12 pr-0">
<Row
type="header"
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15"
)}
>
<Item className="col-span-3">Feature</Item>
{showEntityView && <Item className="col-span-3">Entity</Item>}
<Item className="col-span-3">
{featureType === FeatureType.Metered && "Balance"}
</Item>
@@ -199,68 +205,78 @@ export const CustomerEntitlementsList = () => {
</>
)}
{sortedEntitlements.map(
(cusEnt: FullCustomerEntitlement & { unused: number }) => {
const entitlement = cusEnt.entitlement;
const allowanceType = entitlement.allowance_type;
{filteredEntitlements.map((cusEnt: FullCusEntWithFullCusProduct) => {
const entitlement = cusEnt.entitlement;
const allowanceType = entitlement.allowance_type;
return (
<Row
key={cusEnt.id}
className="grid-cols-12 pr-0"
onClick={() =>
featureType === FeatureType.Metered &&
handleSelectCusEntitlement(cusEnt)
}
>
<Item className="col-span-3">
<AdminHover texts={getAdminHoverTexts(cusEnt)}>
{entitlement.feature.name}
</AdminHover>
return (
<Row
key={cusEnt.id}
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15"
)}
onClick={() =>
featureType === FeatureType.Metered &&
handleSelectCusEntitlement(cusEnt)
}
>
<Item className="col-span-3">
<AdminHover texts={getAdminHoverTexts(cusEnt)}>
{entitlement.feature.name}
</AdminHover>
</Item>
{showEntityView && (
<Item className="col-span-3 -translate-x-1">
<CusProductEntityItem
internalEntityId={cusEnt.customer_product.internal_entity_id}
/>
</Item>
<Item className="col-span-3">
<div className="flex items-center font-mono font-medium rounded-md px-1 border-b border-stone-300 border-dashed ">
{entitlement.feature.type == FeatureType.Boolean ? (
<></>
) : allowanceType == AllowanceType.Unlimited ? (
"Unlimited"
) : entityId && cusEnt.entities?.[entityId] ? (
<div className="flex items-center gap-2">
{cusEnt.entities?.[entityId]?.balance}{" "}
</div>
) : (
<>
{cusEnt.balance}{" "}
<span className="text-t3">
{cusEnt.replaceables.length > 0
? ` (${cusEnt.replaceables.length} free)`
: ""}
</span>
</>
)}
</div>
</Item>
<Item className="col-span-3">
<div className="flex items-center gap-2 max-w-[150px] truncate text-t3">
{getProductName(cusEnt)}
{customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id,
)?.status === "expired" && (
<Badge variant="status" className="bg-black">
expired
</Badge>
)}
</div>
</Item>
<Item className="col-span-2 text-xs text-t3">
{formatUnixToDateTime(cusEnt.next_reset_at).date}{" "}
{formatUnixToDateTime(cusEnt.next_reset_at).time}
</Item>
<Item className="col-span-1" />
</Row>
);
},
)}
)}
<Item className="col-span-3">
<div className="flex items-center font-mono font-medium rounded-md px-1 border-b border-stone-300 border-dashed ">
{entitlement.feature.type == FeatureType.Boolean ? (
<></>
) : allowanceType == AllowanceType.Unlimited ? (
"Unlimited"
) : entityId && cusEnt.entities?.[entityId] ? (
<div className="flex items-center gap-2">
{cusEnt.entities?.[entityId]?.balance}{" "}
</div>
) : (
<>
{cusEnt.balance}{" "}
<span className="text-t3">
{cusEnt.replaceables.length > 0
? ` (${cusEnt.replaceables.length} free)`
: ""}
</span>
</>
)}
</div>
</Item>
<Item className="col-span-3">
<div className="flex items-center gap-2 max-w-[150px] truncate text-t3">
{/* {getProductName(cusEnt)} */}
{cusEnt.customer_product.product.name}
{customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id
)?.status === "expired" && (
<Badge variant="status" className="bg-black">
expired
</Badge>
)}
</div>
</Item>
<Item className="col-span-2 text-xs text-t3">
{formatUnixToDateTime(cusEnt.next_reset_at).date}{" "}
{formatUnixToDateTime(cusEnt.next_reset_at).time}
</Item>
<Item className="col-span-1" />
</Row>
);
})}
</div>
);
};

View File

@@ -206,7 +206,7 @@ export const ProductItemRow = ({
key={index}
className={cn(
"grid grid-cols-17 gap-4 px-10 text-t2 h-10 items-center hover:bg-primary/3",
isOnboarding && "grid-cols-12 px-2",
isOnboarding && "grid-cols-12 px-2"
)}
onClick={() => onRowClick(item, index)}
>
@@ -233,7 +233,7 @@ export const ProductItemRow = ({
"text-xs flex gap-1 items-center opacity-0",
(itemType === ProductItemType.Feature ||
itemType === ProductItemType.FeaturePrice) &&
"opacity-100",
"opacity-100"
)}
>
<Flag size={12} /> Feature
@@ -245,7 +245,7 @@ export const ProductItemRow = ({
"text-xs flex gap-1 items-center opacity-0",
(itemType === ProductItemType.Price ||
itemType === ProductItemType.FeaturePrice) &&
"opacity-100",
"opacity-100"
)}
>
<DollarSign size={12} /> Price