Merge branch 'staging' into perf/bun-runtime
This commit is contained in:
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -94,4 +94,5 @@ server/test.sh
|
||||
|
||||
# bun (keep main repo clean betweeen different npm flavours)
|
||||
bun.lock
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
.dev.vars
|
||||
@@ -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:
|
||||
@@ -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' \
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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!;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -49,7 +49,7 @@ export const handleListProductsBeta = async (req: any, res: any) =>
|
||||
currency: org.default_currency || undefined,
|
||||
db,
|
||||
fullCus: customer ? customer : undefined,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
199
server/tests/attach/free/free1.ts
Normal file
199
server/tests/attach/free/free1.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
21
setupci.js
21
setupci.js
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
vite/src/components/general/table-components/TableButton.tsx
Normal file
34
vite/src/components/general/table-components/TableButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
17
vite/src/utils/navUtils.ts
Normal file
17
vite/src/utils/navUtils.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user