diff --git a/server/src/external/redis/redisScope.ts b/server/src/external/redis/redisScope.ts new file mode 100644 index 000000000..d06412613 --- /dev/null +++ b/server/src/external/redis/redisScope.ts @@ -0,0 +1,39 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +type RedisScope = { + orgId?: string; +}; + +const redisScope = new AsyncLocalStorage(); + +export const runWithRedisScope = async ({ + orgId, + fn, +}: { + orgId?: string; + fn: () => Promise | T; +}): Promise => { + 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; diff --git a/server/src/internal/balances/deleteBalance/deleteBalance.ts b/server/src/internal/balances/deleteBalance/deleteBalance.ts index 1eddf1296..611158892 100644 --- a/server/src/internal/balances/deleteBalance/deleteBalance.ts +++ b/server/src/internal/balances/deleteBalance/deleteBalance.ts @@ -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", + }, + }); }; diff --git a/server/tests/integration/balances/delete/delete-balance.test.ts b/server/tests/integration/balances/delete/delete-balance.test.ts index ffde8ea29..03da67603 100644 --- a/server/tests/integration/balances/delete/delete-balance.test.ts +++ b/server/tests/integration/balances/delete/delete-balance.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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. diff --git a/shared/api/balances/delete/deleteBalanceParams.ts b/shared/api/balances/delete/deleteBalanceParams.ts index 6cc47046f..c1f94feb8 100644 --- a/shared/api/balances/delete/deleteBalanceParams.ts +++ b/shared/api/balances/delete/deleteBalanceParams.ts @@ -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: diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index 7731da139..16cbaf745 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -17,6 +17,7 @@ export type SheetType = | "subscription-cancel" | "subscription-uncancel" | "balance-edit" + | "balance-delete" | "invoice-detail" | null; diff --git a/vite/src/views/customers2/components/sheets/BalanceDeleteSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceDeleteSheet.tsx new file mode 100644 index 000000000..aea33d939 --- /dev/null +++ b/vite/src/views/customers2/components/sheets/BalanceDeleteSheet.tsx @@ -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("keep"); + + const balance = sheetData?.balance as + | FullCusEntWithFullCusProduct + | undefined; + + if (!balance || !customer) { + return ( +
+ +
Loading...
+
+ ); + } + + 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 ( + +
+ + + {!canDeductFromOtherBalances && ( + +

+ {balanceId ? ( + <> + Deleting the{" "} + + {balanceId} + {" "} + balance + + ) : ( + "Deleting this balance" + )} + {" with "} + {numberWithCommas(remainingBalance)} remaining{" "} + {balance.entitlement.feature.name}. +

+
+ )} + + {canDeductFromOtherBalances && ( + + {balanceId ? ( + <> + Deleting the{" "} + + {balanceId} + {" "} + balance + + ) : ( + "Deleting this balance" + )} + {" with "} + {numberWithCommas(remainingBalance)} remaining{" "} + {balance.entitlement.feature.name}. + + } + withSeparator={false} + > +
+
+ setDeleteMode("keep")} + icon={} + /> +
+
+ Keep other balances unchanged +
+
+ Delete this balance only. The remaining{" "} + {numberWithCommas(remainingBalance)} will not be deducted + elsewhere. +
+
+
+ +
+ setDeleteMode("deduct")} + icon={} + /> +
+
+ Deduct usage from other balances +
+
+ Delete this balance and remove{" "} + {numberWithCommas(remainingBalance)} from the customer's + other balances for this feature. +
+
+
+
+
+ )} + +
+ + This action cannot be undone. + +
+ + + + + +
+
+ ); +} diff --git a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx index e2e2157ff..e3edf14d6 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx @@ -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({ diff --git a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx index 96995d8be..67fe9c4a6 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx @@ -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; + onDeleteClick?: (balance: FullCusEntWithFullCusProduct) => void; +}) { + const canDelete = + !row.getCanExpand() && + canDeleteCustomerBalance({ + balance: row.original, + }); + + if (!canDelete || !onDeleteClick) return null; + + return ( +
+ + + event.stopPropagation()} /> + + event.stopPropagation()} + > + { + event.stopPropagation(); + onDeleteClick(row.original); + }} + > +
+ Delete + +
+
+
+
+
+ ); +} + // --- 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 = ({ - {getSubRowLabel({ ent, entities: entities as Entity[] })} + {getCustomerBalanceSourceLabel({ balance: ent, entities })} @@ -368,7 +387,7 @@ export const CustomerBalanceTableColumns = ({ @@ -408,4 +427,12 @@ export const CustomerBalanceTableColumns = ({ /> ), }, + { + id: "actions", + header: "", + size: 44, + cell: ({ row }: { row: Row }) => ( + + ), + }, ]; diff --git a/vite/src/views/customers2/components/table/customer-balance/customerBalanceUtils.ts b/vite/src/views/customers2/components/table/customer-balance/customerBalanceUtils.ts new file mode 100644 index 000000000..d9b0d74a8 --- /dev/null +++ b/vite/src/views/customers2/components/table/customer-balance/customerBalanceUtils.ts @@ -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, +}); diff --git a/vite/src/views/customers2/customer/CustomerSheets.tsx b/vite/src/views/customers2/customer/CustomerSheets.tsx index dff565445..0443053e2 100644 --- a/vite/src/views/customers2/customer/CustomerSheets.tsx +++ b/vite/src/views/customers2/customer/CustomerSheets.tsx @@ -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 ; case "balance-edit": return ; + case "balance-delete": + return ; case "invoice-detail": { const invoice = sheetData?.invoice as Invoice | undefined; const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? [];