feat: add delete balances ui

This commit is contained in:
Charlie Lamb
2026-03-25 14:37:05 +00:00
parent 142048a95f
commit 3dee89e12a
10 changed files with 761 additions and 45 deletions

39
server/src/external/redis/redisScope.ts vendored Normal file
View 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;

View File

@@ -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",
},
});
};

View File

@@ -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.

View File

@@ -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:

View File

@@ -17,6 +17,7 @@ export type SheetType =
| "subscription-cancel"
| "subscription-uncancel"
| "balance-edit"
| "balance-delete"
| "invoice-detail"
| null;

View File

@@ -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>
);
}

View File

@@ -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>({

View File

@@ -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} />
),
},
];

View File

@@ -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,
});

View File

@@ -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[]) ?? [];