feat: add delete balances ui
This commit is contained in:
39
server/src/external/redis/redisScope.ts
vendored
Normal file
39
server/src/external/redis/redisScope.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
type RedisScope = {
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
const redisScope = new AsyncLocalStorage<RedisScope>();
|
||||
|
||||
export const runWithRedisScope = async <T>({
|
||||
orgId,
|
||||
fn,
|
||||
}: {
|
||||
orgId?: string;
|
||||
fn: () => Promise<T> | T;
|
||||
}): Promise<T> => {
|
||||
const store = redisScope.getStore();
|
||||
|
||||
if (store) {
|
||||
const previousOrgId = store.orgId;
|
||||
store.orgId = orgId ?? store.orgId;
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
store.orgId = previousOrgId;
|
||||
}
|
||||
}
|
||||
|
||||
return await redisScope.run({ orgId }, fn);
|
||||
};
|
||||
|
||||
export const setRedisScopeOrgId = ({ orgId }: { orgId?: string }) => {
|
||||
const store = redisScope.getStore();
|
||||
if (!store) return;
|
||||
|
||||
store.orgId = orgId;
|
||||
};
|
||||
|
||||
export const getRedisScopeOrgId = () => redisScope.getStore()?.orgId;
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
cusEntsToBalance,
|
||||
type DeleteBalanceParamsV0,
|
||||
findFeatureById,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
isPaidCustomerEntitlement,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
@@ -18,7 +21,13 @@ export const deleteBalance = async ({
|
||||
ctx: AutumnContext;
|
||||
params: DeleteBalanceParamsV0;
|
||||
}) => {
|
||||
const { customer_id, entity_id, feature_id } = params;
|
||||
const { customer_id, entity_id, feature_id, recalculate_balances } = params;
|
||||
|
||||
if (recalculate_balances && !feature_id) {
|
||||
throw new RecaseError({
|
||||
message: "feature_id is required when recalculate_balances is true",
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Get full customer
|
||||
const fullCustomer = await CusService.getFull({
|
||||
@@ -51,6 +60,14 @@ export const deleteBalance = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const remainingBalanceToRecalculate = recalculate_balances
|
||||
? cusEntsToBalance({
|
||||
cusEnts: customerEntitlements,
|
||||
entityId: fullCustomer.entity?.id ?? undefined,
|
||||
withRollovers: true,
|
||||
})
|
||||
: 0;
|
||||
|
||||
for (const cusEnt of customerEntitlements) {
|
||||
await CusEntService.delete({
|
||||
db: ctx.db,
|
||||
@@ -72,4 +89,44 @@ export const deleteBalance = async ({
|
||||
ctx,
|
||||
customerId: fullCustomer.id ?? "",
|
||||
});
|
||||
|
||||
if (!recalculate_balances || remainingBalanceToRecalculate <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const survivingFullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customer_id,
|
||||
entityId: entity_id,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
const targetFeatureId = feature_id ?? customerEntitlements[0]?.feature_id;
|
||||
if (!targetFeatureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = findFeatureById({
|
||||
features: ctx.features,
|
||||
featureId: targetFeatureId,
|
||||
errorOnNotFound: true,
|
||||
});
|
||||
|
||||
await executePostgresDeduction({
|
||||
ctx,
|
||||
fullCustomer: survivingFullCustomer,
|
||||
customerId: survivingFullCustomer.id ?? customer_id,
|
||||
entityId: entity_id,
|
||||
deductions: [
|
||||
{
|
||||
feature,
|
||||
deduction: remainingBalanceToRecalculate,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
alterGrantedBalance: false,
|
||||
overageBehaviour: "allow",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { CheckResponseV2 } from "@autumn/shared";
|
||||
import { type CheckResponseV2, customerEntitlements } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-1: Basic delete of a loose balance removes it from
|
||||
@@ -109,6 +111,249 @@ test.concurrent(`${chalk.yellowBright("delete-balance-2: balance_id targets only
|
||||
expect(checkDb.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-2A: deleting a partially-used balance without
|
||||
// recalculate_balances leaves other balances unchanged.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("delete-balance-2a: default delete does not deduct deleted remaining amount")}`, async () => {
|
||||
const { customerId, autumnV2 } = await initScenario({
|
||||
customerId: "del-bal-2a",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 100,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 200,
|
||||
balance_id: "balance-b",
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
remaining: 60,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await autumnV2.balances.delete({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
const check = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
expect(check.balance?.breakdown).toHaveLength(1);
|
||||
expect(check.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(check.balance?.current_balance).toBe(200);
|
||||
expect(check.balance?.granted_balance).toBe(200);
|
||||
expect(check.balance?.usage).toBe(0);
|
||||
expect(check.balance?.breakdown?.[0].current_balance).toBe(200);
|
||||
expect(check.balance?.breakdown?.[0].granted_balance).toBe(200);
|
||||
expect(check.balance?.breakdown?.[0].usage).toBe(0);
|
||||
|
||||
const checkDb = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
skip_cache: true,
|
||||
});
|
||||
expect(checkDb.balance?.breakdown).toHaveLength(1);
|
||||
expect(checkDb.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(checkDb.balance?.current_balance).toBe(200);
|
||||
expect(checkDb.balance?.granted_balance).toBe(200);
|
||||
expect(checkDb.balance?.usage).toBe(0);
|
||||
expect(checkDb.balance?.breakdown?.[0].current_balance).toBe(200);
|
||||
expect(checkDb.balance?.breakdown?.[0].granted_balance).toBe(200);
|
||||
expect(checkDb.balance?.breakdown?.[0].usage).toBe(0);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-2B: recalculate_balances deducts the deleted balance's
|
||||
// current remaining amount from the surviving balances for the feature.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("delete-balance-2b: recalculate_balances deducts deleted remaining amount")}`, async () => {
|
||||
const { customerId, autumnV2 } = await initScenario({
|
||||
customerId: "del-bal-2b",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 100,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 200,
|
||||
balance_id: "balance-b",
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
remaining: 60,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await autumnV2.balances.delete({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
balance_id: "balance-a",
|
||||
recalculate_balances: true,
|
||||
});
|
||||
|
||||
const check = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
expect(check.balance?.breakdown).toHaveLength(1);
|
||||
expect(check.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(check.balance?.current_balance).toBe(140);
|
||||
expect(check.balance?.granted_balance).toBe(200);
|
||||
expect(check.balance?.usage).toBe(60);
|
||||
expect(check.balance?.breakdown?.[0].current_balance).toBe(140);
|
||||
expect(check.balance?.breakdown?.[0].granted_balance).toBe(200);
|
||||
expect(check.balance?.breakdown?.[0].usage).toBe(60);
|
||||
|
||||
const checkDb = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
skip_cache: true,
|
||||
});
|
||||
expect(checkDb.balance?.breakdown).toHaveLength(1);
|
||||
expect(checkDb.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(checkDb.balance?.current_balance).toBe(140);
|
||||
expect(checkDb.balance?.granted_balance).toBe(200);
|
||||
expect(checkDb.balance?.usage).toBe(60);
|
||||
expect(checkDb.balance?.breakdown?.[0].current_balance).toBe(140);
|
||||
expect(checkDb.balance?.breakdown?.[0].granted_balance).toBe(200);
|
||||
expect(checkDb.balance?.breakdown?.[0].usage).toBe(60);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-2C: recalculate_balances requires feature_id so we do
|
||||
// not delete across multiple features and recalculate the wrong one.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("delete-balance-2c: recalculate_balances requires feature_id")}`, async () => {
|
||||
const { customerId, autumnV2 } = await initScenario({
|
||||
customerId: "del-bal-2c",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 100,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await expectAutumnError({
|
||||
errMessage: "feature_id is required when recalculate_balances is true",
|
||||
func: async () => {
|
||||
await autumnV2.balances.delete({
|
||||
customer_id: customerId,
|
||||
balance_id: "balance-a",
|
||||
recalculate_balances: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const check = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
expect(check.balance?.breakdown).toHaveLength(1);
|
||||
expect(check.balance?.breakdown?.[0].id).toBe("balance-a");
|
||||
expect(check.balance?.current_balance).toBe(100);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-2D: non-positive deleted balances should not trigger
|
||||
// recalculation, otherwise surviving balances can be credited.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("delete-balance-2d: recalculate_balances skips non-positive deleted balances")}`, async () => {
|
||||
const { customerId, autumnV2, ctx } = await initScenario({
|
||||
customerId: "del-bal-2d",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 100,
|
||||
balance_id: "balance-a",
|
||||
});
|
||||
|
||||
await autumnV2.balances.create({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
included_grant: 200,
|
||||
balance_id: "balance-b",
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.update(customerEntitlements)
|
||||
.set({
|
||||
balance: -20,
|
||||
})
|
||||
.where(eq(customerEntitlements.external_id, "balance-a"));
|
||||
|
||||
await deleteCachedFullCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await autumnV2.balances.delete({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
balance_id: "balance-a",
|
||||
recalculate_balances: true,
|
||||
});
|
||||
|
||||
const check = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
expect(check.balance?.breakdown).toHaveLength(1);
|
||||
expect(check.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(check.balance?.current_balance).toBe(200);
|
||||
expect(check.balance?.granted_balance).toBe(200);
|
||||
expect(check.balance?.usage).toBe(0);
|
||||
|
||||
const checkDb = await autumnV2.check<CheckResponseV2>({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
skip_cache: true,
|
||||
});
|
||||
expect(checkDb.balance?.breakdown).toHaveLength(1);
|
||||
expect(checkDb.balance?.breakdown?.[0].id).toBe("balance-b");
|
||||
expect(checkDb.balance?.current_balance).toBe(200);
|
||||
expect(checkDb.balance?.granted_balance).toBe(200);
|
||||
expect(checkDb.balance?.usage).toBe(0);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DELETE-BALANCE-3: Cannot delete a paid balance (one attached to a
|
||||
// paid product with a price). Should return an error.
|
||||
|
||||
@@ -13,6 +13,10 @@ export const DeleteBalanceParamsV0Schema = z.object({
|
||||
balance_id: z.string().optional().meta({
|
||||
description: "The ID of the balance to delete.",
|
||||
}),
|
||||
recalculate_balances: z.boolean().optional().meta({
|
||||
description:
|
||||
"If true, deduct the deleted balance's remaining amount from the customer's other balances for the same feature after deletion.",
|
||||
}),
|
||||
|
||||
interval: z.enum(ResetInterval).optional().meta({
|
||||
description:
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SheetType =
|
||||
| "subscription-cancel"
|
||||
| "subscription-uncancel"
|
||||
| "balance-edit"
|
||||
| "balance-delete"
|
||||
| "invoice-detail"
|
||||
| null;
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
cusEntsToBalance,
|
||||
type Entity,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
numberWithCommas,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
MinusCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { PanelButton } from "@/components/v2/buttons/PanelButton";
|
||||
import {
|
||||
LayoutGroup,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
|
||||
import { useCustomerContext } from "../../customer/CustomerContext";
|
||||
import {
|
||||
getCustomerBalanceRemaining,
|
||||
getDeleteBalanceParams,
|
||||
} from "../table/customer-balance/customerBalanceUtils";
|
||||
|
||||
type DeleteMode = "keep" | "deduct";
|
||||
|
||||
export function BalanceDeleteSheet() {
|
||||
const sheetData = useSheetStore((s) => s.data);
|
||||
const closeSheet = useSheetStore((s) => s.closeSheet);
|
||||
const closeBalanceSheet = useCustomerBalanceSheetStore((s) => s.closeSheet);
|
||||
const selectedCusEntId = useCustomerBalanceSheetStore(
|
||||
(s) => s.selectedCusEntId,
|
||||
);
|
||||
const { customer, refetch } = useCusQuery();
|
||||
const { entityId } = useCustomerContext();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteMode, setDeleteMode] = useState<DeleteMode>("keep");
|
||||
|
||||
const balance = sheetData?.balance as
|
||||
| FullCusEntWithFullCusProduct
|
||||
| undefined;
|
||||
|
||||
if (!balance || !customer) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader
|
||||
title="Delete Balance"
|
||||
description="Loading balance details..."
|
||||
/>
|
||||
<div className="p-4 text-sm text-t3">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const remainingBalance = getCustomerBalanceRemaining({
|
||||
balance,
|
||||
entityId,
|
||||
});
|
||||
const balanceId = balance.external_id ?? null;
|
||||
|
||||
const selectedEntity =
|
||||
customer.entities?.find(
|
||||
(entity: Entity) =>
|
||||
entity.id === entityId || entity.internal_id === entityId,
|
||||
) ?? undefined;
|
||||
const otherFeatureBalances = fullCustomerToCustomerEntitlements({
|
||||
fullCustomer: customer,
|
||||
featureId: balance.entitlement.feature.id,
|
||||
entity: selectedEntity,
|
||||
}).filter((customerEntitlement) => customerEntitlement.id !== balance.id);
|
||||
const otherRemainingBalance = cusEntsToBalance({
|
||||
cusEnts: otherFeatureBalances,
|
||||
entityId: entityId ?? undefined,
|
||||
withRollovers: true,
|
||||
});
|
||||
const canDeductFromOtherBalances =
|
||||
otherRemainingBalance > 0 && remainingBalance > 0;
|
||||
|
||||
const handleClose = () => {
|
||||
setDeleteMode("keep");
|
||||
closeSheet();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const customerId = customer.id || customer.internal_id;
|
||||
if (!customerId) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axiosInstance.post("/v1/balances.delete", {
|
||||
...getDeleteBalanceParams({
|
||||
balance,
|
||||
customerId,
|
||||
entityId,
|
||||
}),
|
||||
recalculate_balances:
|
||||
canDeductFromOtherBalances && deleteMode === "deduct",
|
||||
});
|
||||
|
||||
if (selectedCusEntId === balance.id) {
|
||||
closeBalanceSheet();
|
||||
}
|
||||
|
||||
await refetch();
|
||||
handleClose();
|
||||
toast.success("Balance deleted");
|
||||
} catch (error) {
|
||||
toast.error(getBackendErr(error, "Failed to delete balance"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<SheetHeader
|
||||
title="Delete Balance"
|
||||
description="Permanently delete this balance from the customer."
|
||||
/>
|
||||
|
||||
{!canDeductFromOtherBalances && (
|
||||
<SheetSection withSeparator={false}>
|
||||
<p className="text-sm text-t3">
|
||||
{balanceId ? (
|
||||
<>
|
||||
Deleting the{" "}
|
||||
<span className="text-tiny-id bg-muted px-1.5 py-0.5 rounded-md text-t2">
|
||||
{balanceId}
|
||||
</span>{" "}
|
||||
balance
|
||||
</>
|
||||
) : (
|
||||
"Deleting this balance"
|
||||
)}
|
||||
{" with "}
|
||||
{numberWithCommas(remainingBalance)} remaining{" "}
|
||||
{balance.entitlement.feature.name}.
|
||||
</p>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{canDeductFromOtherBalances && (
|
||||
<SheetSection
|
||||
title="Usage Handling"
|
||||
description={
|
||||
<>
|
||||
{balanceId ? (
|
||||
<>
|
||||
Deleting the{" "}
|
||||
<span className="text-tiny-id bg-muted px-1.5 py-0.5 rounded-md text-t2">
|
||||
{balanceId}
|
||||
</span>{" "}
|
||||
balance
|
||||
</>
|
||||
) : (
|
||||
"Deleting this balance"
|
||||
)}
|
||||
{" with "}
|
||||
{numberWithCommas(remainingBalance)} remaining{" "}
|
||||
{balance.entitlement.feature.name}.
|
||||
</>
|
||||
}
|
||||
withSeparator={false}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PanelButton
|
||||
isSelected={deleteMode === "keep"}
|
||||
onClick={() => setDeleteMode("keep")}
|
||||
icon={<ShieldCheckIcon size={18} weight="duotone" />}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-body-highlight mb-1">
|
||||
Keep other balances unchanged
|
||||
</div>
|
||||
<div className="text-body-secondary leading-tight">
|
||||
Delete this balance only. The remaining{" "}
|
||||
{numberWithCommas(remainingBalance)} will not be deducted
|
||||
elsewhere.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PanelButton
|
||||
isSelected={deleteMode === "deduct"}
|
||||
onClick={() => setDeleteMode("deduct")}
|
||||
icon={<MinusCircleIcon size={18} weight="duotone" />}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-body-highlight mb-1">
|
||||
Deduct usage from other balances
|
||||
</div>
|
||||
<div className="text-body-secondary leading-tight">
|
||||
Delete this balance and remove{" "}
|
||||
{numberWithCommas(remainingBalance)} from the customer's
|
||||
other balances for this feature.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<InfoBox variant="warning" classNames={{ infoBox: "w-full" }}>
|
||||
This action cannot be undone.
|
||||
</InfoBox>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={handleDelete}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
@@ -52,8 +52,15 @@ export function CustomerBalanceTable({
|
||||
fullCustomer: customer,
|
||||
entityId,
|
||||
entities: customer?.entities || [],
|
||||
onDeleteClick: (balance) =>
|
||||
setSheet({
|
||||
type: "balance-delete",
|
||||
data: {
|
||||
balance,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[customer, entityId],
|
||||
[customer, entityId, setSheet],
|
||||
);
|
||||
|
||||
const table = useCustomerTable<CustomerBalanceRowData>({
|
||||
|
||||
@@ -8,12 +8,19 @@ import {
|
||||
cusEntsToGrantedBalance,
|
||||
cusEntsToPrepaidQuantity,
|
||||
cusEntToPrepaidQuantity,
|
||||
EntInterval,
|
||||
nullish,
|
||||
} from "@autumn/shared";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import { Trash } from "lucide-react";
|
||||
import { AdminHover } from "@/components/general/AdminHover";
|
||||
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils";
|
||||
import { getCusEntHoverTexts } from "@/views/admin/adminUtils";
|
||||
@@ -21,43 +28,10 @@ import { useFeatureUsageBalance } from "@/views/customers2/hooks/useFeatureUsage
|
||||
import { CustomerFeatureUsageBar } from "../customer-feature-usage/CustomerFeatureUsageBar";
|
||||
import { FeatureBalanceDisplay } from "../customer-feature-usage/FeatureBalanceDisplay";
|
||||
import type { CustomerBalanceRowData } from "./CustomerBalanceTable";
|
||||
|
||||
/** Builds a descriptive label for a sub-row (plan + interval + entity) */
|
||||
function getSubRowLabel({
|
||||
ent,
|
||||
entities,
|
||||
}: {
|
||||
ent: FullCusEntWithFullCusProduct;
|
||||
entities: Entity[];
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Plan name
|
||||
parts.push(ent.customer_product?.product.name || "No plan");
|
||||
|
||||
// Interval
|
||||
const { interval, interval_count } = ent.entitlement;
|
||||
if (!interval || interval === EntInterval.Lifetime) {
|
||||
parts.push("Lifetime");
|
||||
} else {
|
||||
const count = interval_count || 1;
|
||||
parts.push(count > 1 ? `${count} ${interval}s` : interval);
|
||||
}
|
||||
|
||||
// Entity (if scoped)
|
||||
const entity = entities.find((e) => {
|
||||
if (ent.internal_entity_id) return e.internal_id === ent.internal_entity_id;
|
||||
return (
|
||||
e.internal_id === ent.customer_product?.internal_entity_id ||
|
||||
e.id === ent.customer_product?.entity_id
|
||||
);
|
||||
});
|
||||
if (entity) {
|
||||
parts.push(entity.name || entity.id);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
import {
|
||||
canDeleteCustomerBalance,
|
||||
getCustomerBalanceSourceLabel,
|
||||
} from "./customerBalanceUtils";
|
||||
|
||||
/** Computes balance values from a single entitlement (for sub-rows) */
|
||||
function getIndividualEntValues({
|
||||
@@ -313,16 +287,61 @@ function BarCell({
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceActionsCell({
|
||||
row,
|
||||
onDeleteClick,
|
||||
}: {
|
||||
row: Row<CustomerBalanceRowData>;
|
||||
onDeleteClick?: (balance: FullCusEntWithFullCusProduct) => void;
|
||||
}) {
|
||||
const canDelete =
|
||||
!row.getCanExpand() &&
|
||||
canDeleteCustomerBalance({
|
||||
balance: row.original,
|
||||
});
|
||||
|
||||
if (!canDelete || !onDeleteClick) return null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton onClick={(event) => event.stopPropagation()} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="text-t2"
|
||||
align="end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDeleteClick(row.original);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2 text-sm">
|
||||
Delete
|
||||
<Trash size={12} className="text-t3" />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Column definitions ---
|
||||
|
||||
export const CustomerBalanceTableColumns = ({
|
||||
fullCustomer,
|
||||
entityId,
|
||||
entities = [],
|
||||
onDeleteClick,
|
||||
}: {
|
||||
fullCustomer: FullCustomer | null | undefined;
|
||||
entityId: string | null;
|
||||
entities?: unknown[];
|
||||
entities?: Entity[];
|
||||
onDeleteClick?: (balance: FullCusEntWithFullCusProduct) => void;
|
||||
}) => [
|
||||
{
|
||||
header: "Feature",
|
||||
@@ -339,11 +358,11 @@ export const CustomerBalanceTableColumns = ({
|
||||
<AdminHover
|
||||
texts={getCusEntHoverTexts({
|
||||
cusEnt: ent,
|
||||
entities: entities as Entity[],
|
||||
entities,
|
||||
})}
|
||||
>
|
||||
<span className="text-t2 truncate">
|
||||
{getSubRowLabel({ ent, entities: entities as Entity[] })}
|
||||
{getCustomerBalanceSourceLabel({ balance: ent, entities })}
|
||||
</span>
|
||||
</AdminHover>
|
||||
</div>
|
||||
@@ -368,7 +387,7 @@ export const CustomerBalanceTableColumns = ({
|
||||
<AdminHover
|
||||
texts={getCusEntHoverTexts({
|
||||
cusEnt: ent,
|
||||
entities: entities as Entity[],
|
||||
entities,
|
||||
})}
|
||||
>
|
||||
<span className="font-medium text-t1 truncate">
|
||||
@@ -408,4 +427,12 @@ export const CustomerBalanceTableColumns = ({
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
size: 44,
|
||||
cell: ({ row }: { row: Row<CustomerBalanceRowData> }) => (
|
||||
<BalanceActionsCell row={row} onDeleteClick={onDeleteClick} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
cusEntsToBalance,
|
||||
type DeleteBalanceParamsV0,
|
||||
EntInterval,
|
||||
type Entity,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
isPaidCustomerEntitlement,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const getCustomerBalanceId = ({
|
||||
balance,
|
||||
}: {
|
||||
balance: FullCusEntWithFullCusProduct;
|
||||
}) => balance.external_id ?? balance.id;
|
||||
|
||||
export const canDeleteCustomerBalance = ({
|
||||
balance,
|
||||
}: {
|
||||
balance: FullCusEntWithFullCusProduct;
|
||||
}) => !isPaidCustomerEntitlement(balance);
|
||||
|
||||
export function getCustomerBalanceSourceLabel({
|
||||
balance,
|
||||
entities,
|
||||
}: {
|
||||
balance: FullCusEntWithFullCusProduct;
|
||||
entities: Entity[];
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(balance.customer_product?.product.name || "No plan");
|
||||
|
||||
const { interval, interval_count } = balance.entitlement;
|
||||
if (!interval || interval === EntInterval.Lifetime) {
|
||||
parts.push("Lifetime");
|
||||
} else {
|
||||
const count = interval_count || 1;
|
||||
parts.push(count > 1 ? `${count} ${interval}s` : interval);
|
||||
}
|
||||
|
||||
const entity = entities.find((candidate) => {
|
||||
if (balance.internal_entity_id) {
|
||||
return candidate.internal_id === balance.internal_entity_id;
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.internal_id === balance.customer_product?.internal_entity_id ||
|
||||
candidate.id === balance.customer_product?.entity_id
|
||||
);
|
||||
});
|
||||
|
||||
if (entity) {
|
||||
parts.push(entity.name || entity.id);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export const getCustomerBalanceRemaining = ({
|
||||
balance,
|
||||
entityId,
|
||||
}: {
|
||||
balance: FullCusEntWithFullCusProduct;
|
||||
entityId: string | null;
|
||||
}) =>
|
||||
cusEntsToBalance({
|
||||
cusEnts: [balance],
|
||||
entityId: entityId ?? undefined,
|
||||
withRollovers: true,
|
||||
});
|
||||
|
||||
export const getDeleteBalanceParams = ({
|
||||
balance,
|
||||
customerId,
|
||||
entityId,
|
||||
recalculateBalances,
|
||||
}: {
|
||||
balance: FullCusEntWithFullCusProduct;
|
||||
customerId: string;
|
||||
entityId: string | null;
|
||||
recalculateBalances?: boolean;
|
||||
}): DeleteBalanceParamsV0 => ({
|
||||
customer_id: customerId,
|
||||
feature_id: balance.entitlement.feature.id,
|
||||
entity_id: entityId ?? undefined,
|
||||
balance_id: getCustomerBalanceId({ balance }),
|
||||
recalculate_balances: recalculateBalances || undefined,
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { SubscriptionUncancelSheet } from "@/views/customers2/components/sheets/
|
||||
import { SubscriptionUpdateSheet2 } from "@/views/customers2/components/sheets/SubscriptionUpdateSheet2";
|
||||
import { AttachProductSheet } from "../components/sheets/AttachProductSheet";
|
||||
import { AttachProductSheetV2 } from "../components/sheets/AttachProductSheetV2";
|
||||
import { BalanceDeleteSheet } from "../components/sheets/BalanceDeleteSheet";
|
||||
import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet";
|
||||
import { InvoiceDetailSheet } from "../components/sheets/InvoiceDetailSheet";
|
||||
import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet";
|
||||
@@ -50,6 +51,8 @@ export function CustomerSheets() {
|
||||
return <SubscriptionUncancelSheet />;
|
||||
case "balance-edit":
|
||||
return <BalanceEditSheet />;
|
||||
case "balance-delete":
|
||||
return <BalanceDeleteSheet />;
|
||||
case "invoice-detail": {
|
||||
const invoice = sheetData?.invoice as Invoice | undefined;
|
||||
const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? [];
|
||||
|
||||
Reference in New Issue
Block a user