From 451b87d9e234ed2451acea1086424e20f653aa0b Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Sat, 3 Jan 2026 16:44:54 +0000 Subject: [PATCH 01/59] frontend fixes and analytics 3BC fixes --- .../analytics/internalAnalyticsRouter.ts | 6 +- .../events/EventsAggregationService.ts | 34 ++++- shared/api/events/components/binsizeEnum.ts | 2 +- vite/src/App.tsx | 4 +- vite/src/app/layout.tsx | 4 +- .../general/modal-components/InfoTooltip.tsx | 4 +- .../components/v2/buttons/CheckboxButton.tsx | 22 ++- vite/src/hooks/common/useTab.tsx | 6 +- vite/src/utils/posthogTracking.ts | 2 +- .../customer/analytics/AnalyticsView.tsx | 6 + .../analytics/components/CustomerComboBox.tsx | 6 +- .../analytics/components/QueryTopbar.tsx | 136 +++++++++++++++--- .../analytics/hooks/useAnalyticsData.tsx | 3 + .../components/table/EmptyState.tsx | 2 +- .../CustomerUsageAnalyticsFullButton.tsx | 2 +- vite/src/views/main-sidebar/MainSidebar.tsx | 4 +- 16 files changed, 193 insertions(+), 50 deletions(-) diff --git a/server/src/internal/analytics/internalAnalyticsRouter.ts b/server/src/internal/analytics/internalAnalyticsRouter.ts index ac1ac9bea..fc5028ed8 100644 --- a/server/src/internal/analytics/internalAnalyticsRouter.ts +++ b/server/src/internal/analytics/internalAnalyticsRouter.ts @@ -113,7 +113,7 @@ analyticsRouter.post("/events", async (req: any, res: any) => handler: async () => { AnalyticsService.handleEarlyExit(); const { db, org, env, features } = req; - let { interval, event_names, customer_id, group_by } = req.body; + let { interval, event_names, customer_id, group_by, bin_size } = req.body; let topEvents: { featureIds: string[]; eventNames: string[] } | undefined; @@ -172,7 +172,8 @@ analyticsRouter.post("/events", async (req: any, res: any) => // aggregateAll, // }); - const binSize = interval === "24h" ? "hour" : "day"; + // Use provided bin_size, or default based on interval + const binSize = bin_size || (interval === "24h" ? "hour" : "day"); const events = await EventsAggregationService.getTimeseriesEvents({ ctx: req, @@ -183,6 +184,7 @@ analyticsRouter.post("/events", async (req: any, res: any) => bin_size: binSize, aggregateAll, group_by: group_by, + customer, }, }); diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts index 58bf0a3a8..8bfc198b5 100644 --- a/server/src/internal/events/EventsAggregationService.ts +++ b/server/src/internal/events/EventsAggregationService.ts @@ -18,9 +18,11 @@ import { add, differenceInDays, differenceInHours, + differenceInMonths, format, startOfDay, startOfHour, + startOfMonth, sub, } from "date-fns"; import { Decimal } from "decimal.js"; @@ -141,6 +143,20 @@ export class EventsAggregationService { }; } + if (binSize === "month") { + const truncStart = startOfMonth(startDate); + const truncEnd = startOfMonth(endDate); + const endPlusOne = add(truncEnd, { months: 1 }); + const months = differenceInMonths(endPlusOne, truncStart); + + return { + binCount: months, + binEndDate: format(endPlusOne, EventsAggregationService.dateFormat), + filterStartDate, + filterEndDate, + }; + } + const truncStart = startOfDay(startDate); const truncEnd = startOfDay(endDate); const endPlusOne = add(truncEnd, { days: 1 }); @@ -337,8 +353,14 @@ order by dr.period${groupBy.orderBy}; const currentDayOffset = 1; const calculateBinCount = (days: number): number => { - const count = binSize === "hour" ? days * 24 : days; - return count + currentDayOffset; + if (binSize === "hour") { + return days * 24 + currentDayOffset; + } + if (binSize === "month") { + // Convert days to months (approximate: 30 days per month) + return Math.ceil(days / 30) + currentDayOffset; + } + return days + currentDayOffset; }; const standardIntervalBinCount = @@ -352,12 +374,14 @@ order by dr.period${groupBy.orderBy}; !params.custom_range && getBCResults?.gap !== undefined; - const binMultiplier = binSize === "hour" ? 24 : 1; + // Multiplier to convert from days to the appropriate bin size unit + const binMultiplier = + binSize === "hour" ? 24 : binSize === "month" ? 1 / 30 : 1; const finalBinCount = binCount ?? (isBillingCycle - ? standardIntervalBinCount * binMultiplier + ? Math.ceil(standardIntervalBinCount * binMultiplier) : calculateBinCount(standardIntervalBinCount)); // Use date_range_bc_view query for billing cycles or custom ranges @@ -374,7 +398,7 @@ order by dr.period${groupBy.orderBy}; // - Standard intervals: offset = bin_count - 1 (to include current period) let intervalOffset: number; if (isBillingCycle) { - intervalOffset = getBCResults.gap * binMultiplier; + intervalOffset = Math.ceil(getBCResults.gap * binMultiplier); } else if (useBillingCycleQuery) { intervalOffset = finalBinCount; } else { diff --git a/shared/api/events/components/binsizeEnum.ts b/shared/api/events/components/binsizeEnum.ts index 08f0a9d2f..8e0eba8cb 100644 --- a/shared/api/events/components/binsizeEnum.ts +++ b/shared/api/events/components/binsizeEnum.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -export const BinSizeEnum = z.enum(["day", "hour"]).default("day"); +export const BinSizeEnum = z.enum(["day", "hour", "month"]).default("day"); export type BinSizeEnum = z.infer; diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 516ca76be..123470cda 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -116,8 +116,8 @@ export default function App() { /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index 8ef490506..0c89f1542 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -93,14 +93,14 @@ export function MainLayout() { includeCredentials={true} > - +
{/* */} - +
); diff --git a/vite/src/components/general/modal-components/InfoTooltip.tsx b/vite/src/components/general/modal-components/InfoTooltip.tsx index 89e7b9c37..9981d5409 100644 --- a/vite/src/components/general/modal-components/InfoTooltip.tsx +++ b/vite/src/components/general/modal-components/InfoTooltip.tsx @@ -22,9 +22,9 @@ export const InfoTooltip = ({ tabIndex={-1} onFocus={(e) => e.preventDefault()} > - + {children} diff --git a/vite/src/components/v2/buttons/CheckboxButton.tsx b/vite/src/components/v2/buttons/CheckboxButton.tsx index 1d70abd20..aa0e87d83 100644 --- a/vite/src/components/v2/buttons/CheckboxButton.tsx +++ b/vite/src/components/v2/buttons/CheckboxButton.tsx @@ -1,7 +1,6 @@ import type * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as React from "react"; import { Checkbox } from "@/components/ui/checkbox"; -import { Button } from "@/components/v2/buttons/Button"; import { cn } from "@/lib/utils"; export const CheckboxButton = React.forwardRef< @@ -14,24 +13,33 @@ export const CheckboxButton = React.forwardRef< }; return ( - + ); }); diff --git a/vite/src/hooks/common/useTab.tsx b/vite/src/hooks/common/useTab.tsx index 74044d014..d2284e5c7 100644 --- a/vite/src/hooks/common/useTab.tsx +++ b/vite/src/hooks/common/useTab.tsx @@ -7,10 +7,10 @@ export const useTab = () => { } if ( - pathname.startsWith("/analytics") || - pathname.startsWith("/sandbox/analytics") + pathname.startsWith("/events") || + pathname.startsWith("/sandbox/events") ) { - return "analytics"; + return "events"; } if ( diff --git a/vite/src/utils/posthogTracking.ts b/vite/src/utils/posthogTracking.ts index 1b77ee986..c26b535f6 100644 --- a/vite/src/utils/posthogTracking.ts +++ b/vite/src/utils/posthogTracking.ts @@ -29,7 +29,7 @@ function identifyUserInPostHog( ) { // Skip tracking in development unless explicitly enabled if (process.env.NODE_ENV === "development" && !TRACK_IN_DEVELOPMENT) { - console.log(`[DEV] Would identify user: ${distinctId}`, properties); + // console.log(`[DEV] Would identify user: ${distinctId}`, properties); return; } diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index 83b2f9139..0223f19b9 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -177,6 +177,12 @@ export const AnalyticsView = () => { newParams.set("interval", interval); navigate(`${location.pathname}?${newParams.toString()}`); }, + selectedBinSize: searchParams.get("bin_size") || "day", + setSelectedBinSize: (binSize: string) => { + const newParams = new URLSearchParams(searchParams); + newParams.set("bin_size", binSize); + navigate(`${location.pathname}?${newParams.toString()}`); + }, setEventNames, featureIds, diff --git a/vite/src/views/customers/customer/analytics/components/CustomerComboBox.tsx b/vite/src/views/customers/customer/analytics/components/CustomerComboBox.tsx index b6807bb3c..91034054c 100644 --- a/vite/src/views/customers/customer/analytics/components/CustomerComboBox.tsx +++ b/vite/src/views/customers/customer/analytics/components/CustomerComboBox.tsx @@ -128,8 +128,8 @@ export function CustomerComboBox({ params.delete("customer_id"); const queryString = params.toString(); const path = queryString - ? `/analytics?${queryString}` - : "/analytics"; + ? `/events?${queryString}` + : "/events"; navigateTo(path, navigate, env); setOpen(false); setHasCleared(false); @@ -154,7 +154,7 @@ export function CustomerComboBox({ "customer_id", c.id || c.internal_id || "", ); - const path = `/analytics?${params.toString()}`; + const path = `/events?${params.toString()}`; navigateTo(path, navigate, env); setOpen(false); }} diff --git a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx index 419ee8363..1e72f64ab 100644 --- a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx +++ b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx @@ -1,45 +1,109 @@ import { CaretDownIcon } from "@phosphor-icons/react"; import { Check } from "lucide-react"; import { useLocation, useNavigate } from "react-router"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { IconButton } from "@/components/v2/buttons/IconButton"; +} from "@/components/v2/dropdowns/DropdownMenu"; import { useAnalyticsContext } from "../AnalyticsContext"; import { CustomerComboBox } from "./CustomerComboBox"; import { SelectFeatureDropdown } from "./SelectFeatureDropdown"; import { SelectGroupByDropdown } from "./SelectGroupByDropdown"; -export const INTERVALS: Record = { +// Simple intervals without bin size options +export const SIMPLE_INTERVALS: Record = { "24h": "Last 24 hours", "7d": "Last 7 days", "30d": "Last 30 days", - "90d": "Last 90 days", "1bc": "Current billing cycle", +}; + +// Intervals that support bin size selection (day/month) +export const BIN_SIZE_INTERVALS: Record = { + "90d": "Last 90 days", "3bc": "Latest 3 billing cycles", }; +export const ALL_INTERVALS: Record = { + ...SIMPLE_INTERVALS, + ...BIN_SIZE_INTERVALS, +}; + +const BIN_SIZE_LABELS: Record = { + day: "by day", + month: "by month", +}; + +const getDisplayLabel = ({ + interval, + binSize, +}: { + interval: string; + binSize: string; +}) => { + if (BIN_SIZE_INTERVALS[interval] && binSize === "month") { + return `${ALL_INTERVALS[interval]} (by month)`; + } + return ALL_INTERVALS[interval]; +}; + export const QueryTopbar = () => { const { customer, selectedInterval, setSelectedInterval, + selectedBinSize, + setSelectedBinSize, bcExclusionFlag, propertyKeys, } = useAnalyticsContext(); const navigate = useNavigate(); const location = useLocation(); - const updateQueryParams = (key: string, value: string) => { + const updateQueryParams = ({ + interval, + binSize, + }: { + interval?: string; + binSize?: string; + }) => { const params = new URLSearchParams(location.search); - params.set(key, value); + if (interval !== undefined) { + params.set("interval", interval); + } + if (binSize !== undefined) { + params.set("bin_size", binSize); + } navigate(`${location.pathname}?${params.toString()}`); }; + const handleSimpleIntervalSelect = (interval: string) => { + setSelectedInterval(interval); + setSelectedBinSize("day"); + updateQueryParams({ interval, binSize: "day" }); + }; + + const handleBinSizeIntervalSelect = ({ + interval, + binSize, + }: { + interval: string; + binSize: string; + }) => { + setSelectedInterval(interval); + setSelectedBinSize(binSize); + updateQueryParams({ interval, binSize }); + }; + + const shouldShowBillingCycleOptions = !bcExclusionFlag && customer; + return (
{ }} /> - + } iconOrientation="right" - // iconPosition="right" > - {INTERVALS[selectedInterval]} + {getDisplayLabel({ + interval: selectedInterval, + binSize: selectedBinSize, + })} - - {Object.keys(INTERVALS) + + {/* Simple intervals without submenus */} + {Object.keys(SIMPLE_INTERVALS) .filter((interval) => { - if (bcExclusionFlag || !customer) { - return interval !== "1bc" && interval !== "3bc"; + if (!shouldShowBillingCycleOptions) { + return interval !== "1bc"; } return true; }) .map((interval) => ( { - setSelectedInterval(interval); - updateQueryParams("interval", interval); - }} + onClick={() => handleSimpleIntervalSelect(interval)} className="flex items-center justify-between" > - {INTERVALS[interval]} + {SIMPLE_INTERVALS[interval]} {selectedInterval === interval && ( )} ))} + + {/* Intervals with bin size submenus */} + {Object.keys(BIN_SIZE_INTERVALS) + .filter((interval) => { + if (!shouldShowBillingCycleOptions) { + return interval !== "3bc"; + } + return true; + }) + .map((interval) => ( + + + {BIN_SIZE_INTERVALS[interval]} + {selectedInterval === interval && ( + + )} + + + {Object.entries(BIN_SIZE_LABELS).map(([binSize, label]) => ( + + handleBinSizeIntervalSelect({ interval, binSize }) + } + className="flex items-center justify-between" + > + {label} + {selectedInterval === interval && + selectedBinSize === binSize && ( + + )} + + ))} + + + ))} { return (
-

{text}

+ {text}
); }; diff --git a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsFullButton.tsx b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsFullButton.tsx index e629b7820..5bee37abe 100644 --- a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsFullButton.tsx +++ b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsFullButton.tsx @@ -15,7 +15,7 @@ export function CustomerUsageAnalyticsFullButton() { className="flex items-center gap-1" onClick={() => { pushPage({ - path: "/analytics", + path: "/events", queryParams: { customer_id: customer.id }, navigate, }); diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 5a80c39ee..728cfa573 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -175,9 +175,9 @@ export const MainSidebar = () => { env={env} /> } - title="Analytics" + title="Events" env={env} /> Date: Mon, 22 Dec 2025 20:53:47 +0000 Subject: [PATCH 02/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balances/handlers/handleCreateBalance.ts | 140 ++++++++++++++++++ .../balances/handlers/handleListBalances.ts | 81 ++++++++++ .../customer-feature-usage/useRawBalances.ts | 34 +++++ 3 files changed, 255 insertions(+) create mode 100644 server/src/internal/balances/handlers/handleCreateBalance.ts create mode 100644 server/src/internal/balances/handlers/handleListBalances.ts create mode 100644 vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts new file mode 100644 index 000000000..1aa59bd93 --- /dev/null +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -0,0 +1,140 @@ +import { + AllowanceType, + type CustomerEntitlement, + CustomerNotFoundError, + EntInterval, + FeatureSchema, + FeatureType +} from "@shared/index"; +import { initEntitlement } from "@tests/utils/init"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; +import { CusService } from "@/internal/customers/CusService"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; + +const CreateBalanceSchema = z.object({ + feature_id: z.string(), + granted_balance: z.string().optional(), + unlimited: z.boolean().optional(), + reset: z + .object({ + interval: z.enum(EntInterval), + interval_count: z.number().optional(), + }) + .optional(), + customer_id: z.string(), +}); + +const CreateBalanceForValidation = CreateBalanceSchema.extend({ + feature: FeatureSchema, +}).refine((data) => { + if (!data.feature) { + return false; + } + + if (data.feature.type === FeatureType.Boolean) { + if (data.granted_balance || data.unlimited || data.reset?.interval) { + return false; + } + } + + if (data.feature.type === FeatureType.Metered) { + if (!data.granted_balance) { + return false; + } + } + + return true; +}); + +export const handleCreateBalance = createRoute({ + body: CreateBalanceSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { feature_id, customer_id, granted_balance, unlimited, reset } = + c.req.valid("json"); + + const feature = ctx.features.find((f) => f.id === feature_id); + + const validatedData = CreateBalanceForValidation.parse({ + feature: feature, + granted_balance, + unlimited, + reset, + customer_id, + }); + + const fullCus = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customer_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!fullCus) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + // const entitlements = fullCus.customer_products.flatMap( + // (cp) => cp.customer_entitlements, + // ); + + // const entitlement = entitlements.find((e) => e.feature_id === feature_id); + // if (entitlement) { + // throw new RecaseError({ + // message: `Entitlement ${feature_id} already exists for customer ${customer_id}`, + // code: "error_code_already_exists", + // statusCode: StatusCodes.BAD_REQUEST, + // }); + // } + + const ent = initEntitlement({ + feature: feature, + allowance: granted_balance ? parseFloat(granted_balance) : undefined, + interval: reset?.interval ? (reset.interval as EntInterval) : undefined, + allowanceType: unlimited ? AllowanceType.Unlimited : AllowanceType.Fixed, + }); + + await EntitlementService.insert({ + db: ctx.db, + data: [ent], + }); + + const entitlementWithFeature = { + ...ent, + feature, + feature_id: feature.id, + }; + + const cusEnt = initCusEntitlement({ + entitlement: entitlementWithFeature, + customer: fullCus, + cusProductId: null, + freeTrial: null, + nextResetAt: + initNextResetAt({ + entitlement: entitlementWithFeature, + nextResetAt: undefined, + trialEndsAt: undefined, + freeTrial: null, + anchorToUnix: undefined, + now: Date.now(), + }) ?? Date.now(), + entities: [], + carryExistingUsages: false, + replaceables: [], + now: Date.now(), + productOptions: undefined, + }) satisfies CustomerEntitlement; + + await CusEntService.insert({ + db: ctx.db, + data: [cusEnt as CustomerEntitlement], + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/balances/handlers/handleListBalances.ts b/server/src/internal/balances/handlers/handleListBalances.ts new file mode 100644 index 000000000..f7fdf7869 --- /dev/null +++ b/server/src/internal/balances/handlers/handleListBalances.ts @@ -0,0 +1,81 @@ +import { + customerEntitlements, + customerProducts, + entitlements, + features, +} from "@autumn/shared"; +import { CustomerNotFoundError } from "@shared/index"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CusService } from "@/internal/customers/CusService"; + +const ListBalancesSchema = z.object({ + customer_id: z.string(), +}); + +export const handleListBalances = createRoute({ + query: ListBalancesSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { customer_id } = c.req.valid("query"); + + const fullCus = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customer_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!fullCus) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + // Get customer entitlements where the entitlement has no internal_product_id + const rawBalances = await ctx.db + .select({ + customer_entitlement: customerEntitlements, + entitlement: entitlements, + feature: features, + customer_product: customerProducts, + }) + .from(customerEntitlements) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin( + features, + eq(entitlements.internal_feature_id, features.internal_id), + ) + .leftJoin( + customerProducts, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .where( + and( + eq(customerEntitlements.internal_customer_id, fullCus.internal_id), + isNull(entitlements.internal_product_id), + ), + ); + + const formattedBalances = rawBalances.map((row) => ({ + ...row.customer_entitlement, + entitlement: { + ...row.entitlement, + feature: row.feature, + }, + customer_product: row.customer_product + ? { + ...row.customer_product, + product: null, + customer_entitlements: [], + customer_prices: [], + free_trial: null, + } + : null, + })); + + return c.json({ balances: formattedBalances }); + }, +}); diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts b/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts new file mode 100644 index 000000000..feff05ef2 --- /dev/null +++ b/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { throwBackendError } from "@/utils/genUtils"; + +export const useRawBalances = ({ enabled = true }: { enabled?: boolean } = {}) => { + const { customer_id } = useParams(); + const axiosInstance = useAxiosInstance(); + + const fetcher = async () => { + try { + const { data } = await axiosInstance.get(`/v1/balances/list`, { + params: { customer_id }, + }); + return data; + } catch (error) { + throwBackendError(error); + } + }; + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["rawBalances", customer_id], + queryFn: fetcher, + enabled: enabled && !!customer_id, + retry: false, + }); + + return { + rawBalances: data?.balances ?? [], + isLoading, + error, + refetch, + }; +}; From 8b537207f4f92f2366e2228e643fdbca17256c65 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:07:34 +0000 Subject: [PATCH 03/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20wip=20create=20bal?= =?UTF-8?q?ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/external/autumn/autumnCli.ts | 19 +++ .../src/internal/balances/balancesRouter.ts | 4 + .../prepareNewBalanceForInsertion.ts | 90 +++++++++++ .../createNewBalance/validationUtils.ts | 44 ++++++ .../balances/handlers/handleCreateBalance.ts | 142 ++++++----------- .../customers/add-product/initCusEnt.ts | 6 +- .../productItemUtils/itemToPriceAndEnt.ts | 16 +- server/tests/_temp/temp1.test.ts | 147 ++++++++++++++---- server/tests/_temp/temp2.test.ts | 80 +++------- .../cusEntModels/cusEntModels.ts | 2 +- .../cusEntModels/cusEntTable.ts | 2 +- .../productModels/entModels/entModels.ts | 2 +- .../productModels/entModels/entTable.ts | 2 +- .../CustomerFeatureUsageTable.tsx | 40 ++++- 14 files changed, 392 insertions(+), 204 deletions(-) create mode 100644 server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts create mode 100644 server/src/internal/balances/createNewBalance/validationUtils.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 10f939954..c82b57333 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -676,6 +676,25 @@ export class AutumnInt { }; balances = { + create: async (params: { + customer_id: string; + feature_id: string; + granted_balance?: string; + unlimited?: boolean; + reset?: { + interval: string; + interval_count?: number; + }; + }) => { + const data = await this.post(`/balances/create`, params); + return data; + }, + list: async (params: { customer_id: string }) => { + const data = await this.get( + `/balances/list?customer_id=${params.customer_id}`, + ); + return data; + }, update: async (params: BalancesUpdateParams) => { const data = await this.post(`/balances/update`, params); return data; diff --git a/server/src/internal/balances/balancesRouter.ts b/server/src/internal/balances/balancesRouter.ts index db5629fb2..26b3c31d0 100644 --- a/server/src/internal/balances/balancesRouter.ts +++ b/server/src/internal/balances/balancesRouter.ts @@ -1,6 +1,8 @@ import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleCheck } from "../api/check/handleCheck.js"; +import { handleCreateBalance } from "./handlers/handleCreateBalance.js"; +import { handleListBalances } from "./handlers/handleListBalances.js"; import { handleTrack } from "./handlers/handleTrack.js"; import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js"; import { handleSetUsage } from "./setUsage/handleSetUsage.js"; @@ -8,6 +10,8 @@ import { handleSetUsage } from "./setUsage/handleSetUsage.js"; // Create a Hono app for products export const balancesRouter = new Hono(); +balancesRouter.post("/balances/create", ...handleCreateBalance); +balancesRouter.get("/balances/list", ...handleListBalances); balancesRouter.post("/balances/update", ...handleUpdateBalance); // Track diff --git a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts new file mode 100644 index 000000000..5577fb210 --- /dev/null +++ b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts @@ -0,0 +1,90 @@ +import { + type CustomerEntitlement, + type Feature, + type FullCustomer, + planFeaturesToItems, + type ResetInterval, +} from "@shared/index"; +import type z from "zod/v4"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; +import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; +import { toFeature } from "@/internal/products/product-items/productItemUtils/itemToPriceAndEnt"; +import type { CreateBalanceSchema } from "./validationUtils"; + +export const prepareNewBalanceForInsertion = async ({ + ctx, + feature, + granted_balance, + unlimited, + reset, + fullCus, + feature_id, +}: { + ctx: AutumnContext; + feature: Feature; + granted_balance: string | undefined; + unlimited: boolean | undefined; + reset: z.infer["reset"]; + fullCus: FullCustomer; + feature_id: string; +}) => { + const inputAsItem = planFeaturesToItems({ + features: [feature], + planFeatures: [ + { + feature_id, + granted_balance: granted_balance + ? parseFloat(granted_balance) + : undefined, + unlimited, + reset: reset + ? { + interval: reset.interval as ResetInterval, + interval_count: reset.interval_count, + reset_when_enabled: true, + } + : undefined, + }, + ], + }); + + const { ent: newEntitlement } = toFeature({ + item: inputAsItem[0], + orgId: ctx.org.id, + isCustom: true, + internalFeatureId: feature.internal_id!, + }); + + const newEntitlementWithFeature = { + ...newEntitlement, + feature, + feature_id: feature.id, + }; + + const newCustomerEntitlement = initCusEntitlement({ + entitlement: newEntitlementWithFeature, + customer: fullCus, + cusProductId: null, + freeTrial: null, + nextResetAt: + initNextResetAt({ + entitlement: newEntitlementWithFeature, + nextResetAt: undefined, + trialEndsAt: undefined, + freeTrial: null, + anchorToUnix: undefined, + now: Date.now(), + }) ?? Date.now(), + entities: [], + carryExistingUsages: false, + replaceables: [], + now: Date.now(), + productOptions: undefined, + }) satisfies CustomerEntitlement; + + return { + newEntitlement, + newCustomerEntitlement, + }; +}; diff --git a/server/src/internal/balances/createNewBalance/validationUtils.ts b/server/src/internal/balances/createNewBalance/validationUtils.ts new file mode 100644 index 000000000..f95a1e86f --- /dev/null +++ b/server/src/internal/balances/createNewBalance/validationUtils.ts @@ -0,0 +1,44 @@ +import { FeatureSchema, FeatureType, ResetInterval } from "@shared/index"; + +import z from "zod/v4"; + +export const CreateBalanceSchema = z.object({ + feature_id: z.string(), + granted_balance: z.string().optional(), + unlimited: z.boolean().optional(), + reset: z + .object({ + interval: z.enum(ResetInterval), + interval_count: z.number().optional(), + }) + .optional(), + customer_id: z.string(), +}); + +export const CreateBalanceForValidation = CreateBalanceSchema.extend({ + feature: FeatureSchema, +}).refine((data) => { + if (!data.feature) { + return false; + } + + if (data.feature.type === FeatureType.Boolean) { + if (data.granted_balance || data.unlimited || data.reset?.interval) { + return false; + } + } + + if (data.feature.type === FeatureType.Metered) { + if (!data.granted_balance && !data.unlimited) { + return false; + } + if (data.granted_balance && data.unlimited) { + return false; + } + if (data.unlimited && data.reset?.interval) { + return false; + } + } + + return true; +}); diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 1aa59bd93..6211d9b22 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,54 +1,21 @@ import { - AllowanceType, - type CustomerEntitlement, CustomerNotFoundError, - EntInterval, - FeatureSchema, - FeatureType + customerEntitlements, + ErrCode, + FeatureNotFoundError, + FeatureType, RecaseError } from "@shared/index"; -import { initEntitlement } from "@tests/utils/init"; -import { z } from "zod/v4"; +import { and, eq } from "drizzle-orm"; +import { StatusCodes } from "http-status-codes"; import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; - -const CreateBalanceSchema = z.object({ - feature_id: z.string(), - granted_balance: z.string().optional(), - unlimited: z.boolean().optional(), - reset: z - .object({ - interval: z.enum(EntInterval), - interval_count: z.number().optional(), - }) - .optional(), - customer_id: z.string(), -}); - -const CreateBalanceForValidation = CreateBalanceSchema.extend({ - feature: FeatureSchema, -}).refine((data) => { - if (!data.feature) { - return false; - } - - if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval) { - return false; - } - } - - if (data.feature.type === FeatureType.Metered) { - if (!data.granted_balance) { - return false; - } - } - - return true; -}); +import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion"; +import { + CreateBalanceForValidation, + CreateBalanceSchema, +} from "../createNewBalance/validationUtils"; export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, @@ -58,81 +25,72 @@ export const handleCreateBalance = createRoute({ c.req.valid("json"); const feature = ctx.features.find((f) => f.id === feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } - const validatedData = CreateBalanceForValidation.parse({ + // This should throw an error if the data is invalid + CreateBalanceForValidation.parse({ feature: feature, granted_balance, unlimited, reset, customer_id, + feature_id, }); - const fullCus = await CusService.getFull({ + const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, orgId: ctx.org.id, env: ctx.env, }); - if (!fullCus) { + if (!fullCustomer) { throw new CustomerNotFoundError({ customerId: customer_id }); } - // const entitlements = fullCus.customer_products.flatMap( - // (cp) => cp.customer_entitlements, - // ); + const existingEntitlement = + await ctx.db.query.customerEntitlements.findFirst({ + where: and( + eq(customerEntitlements.internal_customer_id, fullCustomer.internal_id), + eq(customerEntitlements.internal_feature_id, feature.internal_id!), + ), + with: { + feature: true, + }, + }); - // const entitlement = entitlements.find((e) => e.feature_id === feature_id); - // if (entitlement) { - // throw new RecaseError({ - // message: `Entitlement ${feature_id} already exists for customer ${customer_id}`, - // code: "error_code_already_exists", - // statusCode: StatusCodes.BAD_REQUEST, - // }); - // } + if ( + existingEntitlement && + existingEntitlement.feature.type === FeatureType.Boolean + ) { + throw new RecaseError({ + message: `A boolean entitlement ${feature.id} already exists for customer ${customer_id}`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } - const ent = initEntitlement({ - feature: feature, - allowance: granted_balance ? parseFloat(granted_balance) : undefined, - interval: reset?.interval ? (reset.interval as EntInterval) : undefined, - allowanceType: unlimited ? AllowanceType.Unlimited : AllowanceType.Fixed, + const { newEntitlement, newCustomerEntitlement } = await prepareNewBalanceForInsertion({ + ctx, + feature, + granted_balance, + unlimited, + reset, + fullCus: fullCustomer, + feature_id, }); + await EntitlementService.insert({ db: ctx.db, - data: [ent], + data: [newEntitlement], }); - const entitlementWithFeature = { - ...ent, - feature, - feature_id: feature.id, - }; - - const cusEnt = initCusEntitlement({ - entitlement: entitlementWithFeature, - customer: fullCus, - cusProductId: null, - freeTrial: null, - nextResetAt: - initNextResetAt({ - entitlement: entitlementWithFeature, - nextResetAt: undefined, - trialEndsAt: undefined, - freeTrial: null, - anchorToUnix: undefined, - now: Date.now(), - }) ?? Date.now(), - entities: [], - carryExistingUsages: false, - replaceables: [], - now: Date.now(), - productOptions: undefined, - }) satisfies CustomerEntitlement; - await CusEntService.insert({ db: ctx.db, - data: [cusEnt as CustomerEntitlement], + data: [newCustomerEntitlement], }); return c.json({ success: true }); diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index 1d604909e..604528bf3 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -120,7 +120,7 @@ export const initCusEntitlement = ({ }: { entitlement: EntitlementWithFeature; customer: Customer; - cusProductId: string; + cusProductId: string | null; freeTrial: FreeTrial | null; options?: FeatureOptions; nextResetAt?: number; @@ -183,7 +183,7 @@ export const initCusEntitlement = ({ id: generateId("cus_ent"), internal_customer_id: customer.internal_id, internal_feature_id: entitlement.internal_feature_id, - feature_id: entitlement.feature_id, + feature_id: (entitlement.feature_id ?? entitlement.feature.id) as string, customer_id: customer.id, // Foreign keys @@ -196,6 +196,8 @@ export const initCusEntitlement = ({ ? null : entitlement.allowance_type === AllowanceType.Unlimited, balance: newBalance || 0, + additional_balance: 0, + adjustment: 0, entities: newEntities, usage_allowed: usageAllowed, next_reset_at: nextResetAtValue, diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 121d73899..2ac4966d3 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -114,7 +114,7 @@ export const toFeature = ({ item: ProductItem; orgId: string; internalFeatureId: string; - internalProductId: string; + internalProductId?: string; isCustom: boolean; newVersion?: boolean; feature?: Feature; @@ -129,7 +129,7 @@ export const toFeature = ({ org_id: orgId, created_at: item.created_at || Date.now(), is_custom: isCustom, - internal_product_id: internalProductId, + internal_product_id: internalProductId || null, internal_feature_id: internalFeatureId, feature_id: item.feature_id!, @@ -236,11 +236,11 @@ export const toFeatureAndPrice = ({ feature_id: item.feature_id!, usage_tiers: notNullish(item.price) ? [ - { - amount: item.price, - to: TierInfinite, - }, - ] + { + amount: item.price, + to: TierInfinite, + }, + ] : (item.tiers as any), interval: itemToBillingInterval({ item }) as BillingInterval, interval_count: item.interval_count || 1, @@ -257,7 +257,7 @@ export const toFeatureAndPrice = ({ if (shouldProrate(onDecrease) || onDecrease === OnDecrease.Prorate) { onDecrease = onIncrease === OnIncrease.ProrateImmediately || - onIncrease === OnIncrease.BillImmediately + onIncrease === OnIncrease.BillImmediately ? OnDecrease.ProrateImmediately : OnDecrease.ProrateNextCycle; } diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index bca02eaab..35e724a8e 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -1,6 +1,12 @@ -import { beforeAll, describe } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + EntInterval, + ErrCode, + type FullCustomerEntitlement, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; @@ -12,7 +18,6 @@ import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProduc const free = constructProduct({ type: "free", isDefault: false, - items: [ constructFeatureItem({ featureId: TestFeature.Messages, @@ -41,13 +46,7 @@ export const premium = constructProduct({ ], }); -const entity = { - id: "entity1", - name: "Entity 1", - feature_id: TestFeature.Messages, -}; - -describe(`${chalk.yellowBright("temp1: Testing pro product")}`, () => { +describe(`${chalk.yellowBright("temp1: Testing balances.create endpoint")}`, () => { const customerId = "temp1"; const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); @@ -55,35 +54,125 @@ describe(`${chalk.yellowBright("temp1: Testing pro product")}`, () => { await initCustomerV3({ ctx, customerId, - withTestClock: true, - attachPm: "success", + withTestClock: false, }); await initProductsV0({ ctx, products: [free, pro, premium], prefix: customerId, - // customerId, + }); + }); + + test("should create balance with granted_balance", async () => { + const grantedBalance = "500"; + + await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: grantedBalance, }); - await autumn.entities.create(customerId, [entity]); + const { balances: rawBalances } = await autumn.balances.list({ + customer_id: customerId, + }); - // await autumn.attach({ - // customer_id: customerId, - // product_id: pro.id, - // entity_id: entity.id, - // }); + expect(rawBalances).toBeDefined(); + expect(rawBalances.length).toBeGreaterThan(0); + const createdBalance = rawBalances.find( + (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Messages, + ); + expect(createdBalance).toBeDefined(); + expect(createdBalance.balance).toBe(500); + expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Messages); + }); - // await autumn.attach({ - // customer_id: customerId, - // product_id: free.id, - // entity_id: entity.id, - // }); + test("should create unlimited balance", async () => { + await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Users, + unlimited: true, + }); - // await autumn.attach({ - // customer_id: customerId, - // product_id: premium.id, - // entity_id: entity.id, - // }); + const { balances: rawBalances } = await autumn.balances.list({ + customer_id: customerId, + }); + + const createdBalance = rawBalances.find( + (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Users, + ); + expect(createdBalance).toBeDefined(); + expect(createdBalance.unlimited).toBe(true); + expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Users); + }); + + test("should create balance with reset interval", async () => { + // Use Action1 which is a single-use feature that can have monthly reset + await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Action1, + granted_balance: "1000", + reset: { + interval: EntInterval.Month, + interval_count: 1, + }, + }); + + const { balances: rawBalances } = await autumn.balances.list({ + customer_id: customerId, + }); + + const createdBalance = rawBalances.find( + (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Action1, + ); + expect(createdBalance).toBeDefined(); + expect(createdBalance.balance).toBe(1000); + expect(createdBalance.entitlement.interval).toBe(EntInterval.Month); + expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Action1); + }); + + test("should throw error if entitlement already exists", async () => { + // Create balance first + await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + }); + + // Try to create again - should fail + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => { + return await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + }); + }, + }); + }); + + test("should throw error if feature not found", async () => { + await expectAutumnError({ + errCode: ErrCode.FeatureNotFound, + func: async () => { + await autumn.balances.create({ + customer_id: customerId, + feature_id: "non-existent-feature", + granted_balance: "100", + }); + }, + }); + }); + + test("should throw error if customer not found", async () => { + await expectAutumnError({ + errCode: ErrCode.CustomerNotFound, + func: async () => { + await autumn.balances.create({ + customer_id: "non-existent-customer", + feature_id: TestFeature.Messages, + granted_balance: "100", + }); + }, + }); }); }); diff --git a/server/tests/_temp/temp2.test.ts b/server/tests/_temp/temp2.test.ts index 15947fc06..b4aeff164 100644 --- a/server/tests/_temp/temp2.test.ts +++ b/server/tests/_temp/temp2.test.ts @@ -1,41 +1,36 @@ import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; +import { ApiVersion } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { attachFailedPaymentMethod } from "../../src/external/stripe/stripeCusUtils.js"; -import { CusService } from "../../src/internal/customers/CusService.js"; -// UNCOMMENT FROM HERE const pro = constructProduct({ type: "pro", - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 10, - includedUsage: 0, + constructArrearItem({ + featureId: TestFeature.Messages, + includedUsage: 5, + price: 0.1, + billingUnits: 1, }), ], }); -describe(`${chalk.yellowBright("temp: Testing pay per use")}`, () => { +describe(`${chalk.yellowBright("temp2: Testing pay-per-use with raw balance")}`, () => { const customerId = "temp2"; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - let testClockId: string; beforeAll(async () => { - const result = await initCustomerV3({ + await initCustomerV3({ ctx, customerId, - customerData: {}, + withTestClock: false, attachPm: "success", - withTestClock: true, }); await initProductsV0({ @@ -43,59 +38,20 @@ describe(`${chalk.yellowBright("temp: Testing pay per use")}`, () => { products: [pro], prefix: customerId, }); - - testClockId = result.testClockId!; }); - test("should attach pro product", async () => { + test("should attach product and create raw balance", async () => { + // Attach product with pay-per-use feature (5 messages included) await autumn.attach({ customer_id: customerId, product_id: pro.id, }); - // await advanceToNextInvoice({ - // stripeCli: ctx.stripeCli, - // testClockId, - // }); - - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, + // Create a raw balance of 5 messages + await autumn.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: "5", }); - - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: customer!, - }); - - // await autumn.track({ - // customer_id: customerId, - // feature_id: TestFeature.Users, - // value: 1, - // }); - - await autumn.entities.create(customerId, [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - ]); }); - - // test("should cancel one add on", async () => { - // await autumn.cancel({ - // customer_id: customerId, - // product_id: addOn.id, - // }); - - // await expectSubToBeCorrect({ - // customerId, - // db, - // org, - // env, - // }); - // }); }); diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index ffbcabdd4..3e537c297 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -25,7 +25,7 @@ export const CustomerEntitlementSchema = z.object({ customer_id: z.string().nullish(), // for debugging purposes feature_id: z.string(), // for debugging purposes - customer_product_id: z.string(), + customer_product_id: z.string().nullable(), entitlement_id: z.string().nullable(), created_at: z.number(), diff --git a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts index 6d1188629..d6a494990 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts @@ -17,7 +17,7 @@ export const customerEntitlements = pgTable( "customer_entitlements", { id: text().primaryKey().notNull(), - customer_product_id: text().notNull(), + customer_product_id: text(), entitlement_id: text().notNull(), internal_customer_id: text().notNull(), internal_feature_id: text().notNull(), diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index 0966be7d6..c9e297fab 100644 --- a/shared/models/productModels/entModels/entModels.ts +++ b/shared/models/productModels/entModels/entModels.ts @@ -14,7 +14,7 @@ export const EntitlementSchema = z.object({ id: z.string(), created_at: z.number(), internal_feature_id: z.string(), - internal_product_id: z.string(), + internal_product_id: z.string().nullable(), is_custom: z.boolean().default(false), allowance_type: z.nativeEnum(AllowanceType).optional().nullable(), diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 16a358da6..8cbf46ad8 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -19,7 +19,7 @@ export const entitlements = pgTable( id: text().primaryKey().notNull(), created_at: numeric({ mode: "number" }).notNull(), internal_feature_id: text().notNull(), - internal_product_id: text().notNull(), + internal_product_id: text(), is_custom: boolean().default(false), allowance_type: text(), diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx index 080811cf7..a61bf808f 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx @@ -1,4 +1,4 @@ -import type { Entity } from "@autumn/shared"; +import type { Entity, FullCusEntWithFullCusProduct } from "@autumn/shared"; import { FeatureType, type FullCusProduct } from "@autumn/shared"; import { BatteryHighIcon } from "@phosphor-icons/react"; import { type ExpandedState, getExpandedRowModel } from "@tanstack/react-table"; @@ -19,9 +19,11 @@ import { flattenCustomerEntitlements, processNonBooleanEntitlements, } from "./customerFeatureUsageUtils"; +import { useRawBalances } from "./useRawBalances"; export function CustomerFeatureUsageTable() { const { customer, features, isLoading } = useCusQuery(); + const { rawBalances } = useRawBalances(); const { entityId } = useEntity(); @@ -48,13 +50,37 @@ export function CustomerFeatureUsageTable() { ); }, [customer?.customer_products, customer?.entities, entityId]); - const cusEnts = useMemo( - () => - flattenCustomerEntitlements({ - customerProducts: filteredCustomerProducts, + const cusEnts = useMemo(() => { + const productEnts = flattenCustomerEntitlements({ + customerProducts: filteredCustomerProducts, + }); + + // Add raw balances (entitlements without internal_product_id) + // They need to have a customer_product structure to match FullCusEntWithFullCusProduct + const rawEnts = (rawBalances || []).map( + (raw: FullCusEntWithFullCusProduct) => ({ + ...raw, + rollovers: raw.rollovers || [], + replaceables: raw.replaceables || [], + customer_product: raw.customer_product || { + id: `raw-${raw.id}`, + internal_customer_id: customer?.internal_id, + customer_id: customer?.id, + internal_product_id: null, + product_id: null, + status: "active", + created_at: raw.created_at, + quantity: 1, + product: null, + customer_entitlements: [], + customer_prices: [], + free_trial: null, + }, }), - [filteredCustomerProducts], - ); + ); + + return [...productEnts, ...rawEnts]; + }, [filteredCustomerProducts, rawBalances, customer]); const featuresMap = useMemo( () => createFeaturesMap({ features: features ?? [] }), From eb1330501e428c344aa18512cf446444d9717a41 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:19:48 +0000 Subject: [PATCH 04/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createNewBalance/validationUtils.ts | 41 ++++++++++++++++++- .../balances/handlers/handleCreateBalance.ts | 34 ++++----------- .../cusEnts/CusEntitlementService.ts | 9 +++- server/tests/_temp/temp1.test.ts | 2 +- 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/server/src/internal/balances/createNewBalance/validationUtils.ts b/server/src/internal/balances/createNewBalance/validationUtils.ts index f95a1e86f..23143f669 100644 --- a/server/src/internal/balances/createNewBalance/validationUtils.ts +++ b/server/src/internal/balances/createNewBalance/validationUtils.ts @@ -1,6 +1,15 @@ -import { FeatureSchema, FeatureType, ResetInterval } from "@shared/index"; - +import { + ErrCode, + type Feature, + FeatureSchema, + FeatureType, + RecaseError, + ResetInterval, +} from "@shared/index"; +import { StatusCodes } from "http-status-codes"; import z from "zod/v4"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; export const CreateBalanceSchema = z.object({ feature_id: z.string(), @@ -42,3 +51,31 @@ export const CreateBalanceForValidation = CreateBalanceSchema.extend({ return true; }); + +export const validateBooleanEntitlementConflict = async ({ + ctx, + feature, + internalCustomerId, +}: { + ctx: AutumnContext; + feature: Feature; + internalCustomerId: string; +}) => { + if (feature.type === FeatureType.Boolean) { + const existingBooleanEntitlement = await CusEntService.getByFeature({ + db: ctx.db, + internalFeatureId: feature.internal_id!, + internalCustomerId, + }); + + console.log("existingBooleanEntitlement: ", existingBooleanEntitlement); + + if (existingBooleanEntitlement.length > 0) { + throw new RecaseError({ + message: `A boolean entitlement ${feature.id} already exists for customer ${internalCustomerId}`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } +}; diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 6211d9b22..51b310a39 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,12 +1,6 @@ import { - CustomerNotFoundError, - customerEntitlements, - ErrCode, - FeatureNotFoundError, - FeatureType, RecaseError + CustomerNotFoundError, FeatureNotFoundError } from "@shared/index"; -import { and, eq } from "drizzle-orm"; -import { StatusCodes } from "http-status-codes"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; @@ -15,6 +9,7 @@ import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBal import { CreateBalanceForValidation, CreateBalanceSchema, + validateBooleanEntitlementConflict, } from "../createNewBalance/validationUtils"; export const handleCreateBalance = createRoute({ @@ -50,27 +45,12 @@ export const handleCreateBalance = createRoute({ throw new CustomerNotFoundError({ customerId: customer_id }); } - const existingEntitlement = - await ctx.db.query.customerEntitlements.findFirst({ - where: and( - eq(customerEntitlements.internal_customer_id, fullCustomer.internal_id), - eq(customerEntitlements.internal_feature_id, feature.internal_id!), - ), - with: { - feature: true, - }, - }); + await validateBooleanEntitlementConflict({ + ctx, + feature, + internalCustomerId: fullCustomer.internal_id, + }) - if ( - existingEntitlement && - existingEntitlement.feature.type === FeatureType.Boolean - ) { - throw new RecaseError({ - message: `A boolean entitlement ${feature.id} already exists for customer ${customer_id}`, - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } const { newEntitlement, newCustomerEntitlement } = await prepareNewBalanceForInsertion({ ctx, diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 7e902ce00..7afb5d38a 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -45,14 +45,21 @@ export class CusEntService { static async getByFeature({ db, internalFeatureId, + internalCustomerId, }: { db: DrizzleCli; internalFeatureId: string; + internalCustomerId?: string; }) { const data = await db .select() .from(customerEntitlements) - .where(eq(customerEntitlements.internal_feature_id, internalFeatureId)) + .where( + internalCustomerId ? and( + eq(customerEntitlements.internal_feature_id, internalFeatureId), + eq(customerEntitlements.internal_customer_id, internalCustomerId) + ) : eq(customerEntitlements.internal_feature_id, internalFeatureId), + ) .limit(10); return data as FullCustomerEntitlement[]; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index 35e724a8e..3e977d275 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -47,7 +47,7 @@ export const premium = constructProduct({ }); describe(`${chalk.yellowBright("temp1: Testing balances.create endpoint")}`, () => { - const customerId = "temp1"; + const customerId = `temp1-${Math.random().toString(36).substring(2, 15)}`; const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { From 98943170c107132de396aea765cac51a2d7d6ac4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:28:12 +0000 Subject: [PATCH 05/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20wip=20balances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createNewBalance/prepareNewBalanceForInsertion.ts | 6 +----- ...{validationUtils.ts => validationUtilsForNewBalances.ts} | 2 -- .../src/internal/balances/handlers/handleCreateBalance.ts | 2 +- .../insertCusProduct/initCusEnt/initNextResetAt.ts | 4 ++-- 4 files changed, 4 insertions(+), 10 deletions(-) rename server/src/internal/balances/createNewBalance/{validationUtils.ts => validationUtilsForNewBalances.ts} (96%) diff --git a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts index 5577fb210..2fe0e14f8 100644 --- a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts @@ -10,7 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; import { toFeature } from "@/internal/products/product-items/productItemUtils/itemToPriceAndEnt"; -import type { CreateBalanceSchema } from "./validationUtils"; +import type { CreateBalanceSchema } from "./validationUtilsForNewBalances"; export const prepareNewBalanceForInsertion = async ({ ctx, @@ -70,10 +70,6 @@ export const prepareNewBalanceForInsertion = async ({ nextResetAt: initNextResetAt({ entitlement: newEntitlementWithFeature, - nextResetAt: undefined, - trialEndsAt: undefined, - freeTrial: null, - anchorToUnix: undefined, now: Date.now(), }) ?? Date.now(), entities: [], diff --git a/server/src/internal/balances/createNewBalance/validationUtils.ts b/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts similarity index 96% rename from server/src/internal/balances/createNewBalance/validationUtils.ts rename to server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts index 23143f669..698cd43a2 100644 --- a/server/src/internal/balances/createNewBalance/validationUtils.ts +++ b/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts @@ -68,8 +68,6 @@ export const validateBooleanEntitlementConflict = async ({ internalCustomerId, }); - console.log("existingBooleanEntitlement: ", existingBooleanEntitlement); - if (existingBooleanEntitlement.length > 0) { throw new RecaseError({ message: `A boolean entitlement ${feature.id} already exists for customer ${internalCustomerId}`, diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 51b310a39..da2e0323c 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -10,7 +10,7 @@ import { CreateBalanceForValidation, CreateBalanceSchema, validateBooleanEntitlementConflict, -} from "../createNewBalance/validationUtils"; +} from "../createNewBalance/validationUtilsForNewBalances"; export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, diff --git a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts index d944862bc..665ed0c43 100644 --- a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts +++ b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts @@ -23,7 +23,7 @@ export const initNextResetAt = ({ entitlement: EntitlementWithFeature; nextResetAt?: number; trialEndsAt?: number; - freeTrial: FreeTrial | null; + freeTrial?: FreeTrial | null; anchorToUnix?: number; now: number; }) => { @@ -47,7 +47,7 @@ export const initNextResetAt = ({ ? freeTrialToStripeTimestamp({ freeTrial, now }) : null; - const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial); + const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial ?? null); // console.log( // "Trial end timestamp: ", From e9be3e21ae70cc3688baea5f31fa4c45a45df836 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:59:59 +0000 Subject: [PATCH 06/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20extra=20customer?= =?UTF-8?q?=20entiltments=20for=20CHECK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balances/handlers/handleCreateBalance.ts | 29 +++-- server/src/internal/customers/CusService.ts | 3 + .../apiCusCacheUtils/getCachedApiCustomer.ts | 1 + .../getApiBalance/apiBalanceUtils.ts | 90 ++++++++++++- .../getApiBalance/getApiBalance.ts | 82 +++--------- .../getApiBalance/getApiBalances.ts | 23 +++- .../customers/cusUtils/getOrCreateCustomer.ts | 4 + .../src/internal/customers/getFullCusQuery.ts | 61 +++++++++ .../internal/customers/internalCusRouter.ts | 19 +-- .../balances/check/basic/check-loose1.test.ts | 95 ++++++++++++++ .../balances/check/basic/check-loose2.test.ts | 75 +++++++++++ .../balances/check/basic/check-loose3.test.ts | 120 ++++++++++++++++++ .../balances/check/basic/check-loose4.test.ts | 75 +++++++++++ shared/models/cusModels/fullCusModel.ts | 2 + .../cusEntModels/cusEntWithProduct.ts | 8 ++ .../balanceUtils/cusEntToPurchasedBalance.ts | 6 +- .../balanceUtils/cusEntsToBalance.ts | 4 +- .../balanceUtils/cusEntsToPrepaidQuantity.ts | 20 +-- .../cusEntsToAdjustment.ts | 4 +- .../grantedBalanceUtils/cusEntsToAllowance.ts | 8 +- .../utils/cusEntUtils/convertCusEntUtils.ts | 32 ++++- .../cusEntsToMaxPurchase.ts | 3 +- shared/utils/cusEntUtils/cusEntUtils.ts | 6 +- shared/utils/productUtils/convertUtils.ts | 6 +- .../customer-balance/CustomerBalanceTable.tsx | 10 +- .../CustomerBalanceTableColumns.tsx | 14 +- .../CustomerBooleanBalanceTable.tsx | 8 +- .../CustomerBooleanBalanceTableColumns.tsx | 8 +- .../CustomerFeatureUsageColumns.tsx | 14 +- .../CustomerFeatureUsageTable.tsx | 44 +++---- .../customerFeatureUsageTableFilters.ts | 25 ++-- .../customerFeatureUsageTypes.ts | 10 +- .../customerFeatureUsageUtils.ts | 46 +++---- .../customer-feature-usage/useRawBalances.ts | 54 ++++---- 34 files changed, 771 insertions(+), 238 deletions(-) create mode 100644 server/tests/balances/check/basic/check-loose1.test.ts create mode 100644 server/tests/balances/check/basic/check-loose2.test.ts create mode 100644 server/tests/balances/check/basic/check-loose3.test.ts create mode 100644 server/tests/balances/check/basic/check-loose4.test.ts diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index da2e0323c..8dfbf2339 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -4,6 +4,7 @@ import { import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion"; import { @@ -24,16 +25,6 @@ export const handleCreateBalance = createRoute({ throw new FeatureNotFoundError({ featureId: feature_id }); } - // This should throw an error if the data is invalid - CreateBalanceForValidation.parse({ - feature: feature, - granted_balance, - unlimited, - reset, - customer_id, - feature_id, - }); - const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, @@ -45,13 +36,22 @@ export const handleCreateBalance = createRoute({ throw new CustomerNotFoundError({ customerId: customer_id }); } + // This should throw an error if the data is invalid + CreateBalanceForValidation.parse({ + feature: feature, + granted_balance, + unlimited, + reset, + customer_id, + feature_id, + }); + await validateBooleanEntitlementConflict({ ctx, feature, internalCustomerId: fullCustomer.internal_id, }) - const { newEntitlement, newCustomerEntitlement } = await prepareNewBalanceForInsertion({ ctx, feature, @@ -73,6 +73,13 @@ export const handleCreateBalance = createRoute({ data: [newCustomerEntitlement], }); + await deleteCachedApiCustomer({ + orgId: ctx.org.id, + env: ctx.env, + customerId: customer_id, + source: "handleCreateBalance", + }); + return c.json({ success: true }); }, }); diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 05ec0dfcc..151ad1250 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -34,6 +34,7 @@ export class CusService { withSubs = false, allowNotFound = false, withEvents = false, + withExtraCustomerEntitlements = false, }: { db: DrizzleCli; idOrInternalId: string; @@ -46,6 +47,7 @@ export class CusService { withSubs?: boolean; allowNotFound?: boolean; withEvents?: boolean; + withExtraCustomerEntitlements?: boolean; }): Promise { const includeInvoices = expand?.includes(CusExpand.Invoices) || false; const withTrialsUsed = expand?.includes(CusExpand.TrialsUsed) || false; @@ -72,6 +74,7 @@ export class CusService { withTrialsUsed, withSubs, withEvents, + withExtraCustomerEntitlements, entityId, ); diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index f4572be20..fb86c86b8 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -111,6 +111,7 @@ export const getCachedApiCustomer = async ({ env: env as AppEnv, withEntities: true, withSubs: true, + withExtraCustomerEntitlements: true, expand: [CusExpand.Invoices], }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index a5f9195cf..f8c9c4a2d 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -1,15 +1,101 @@ import { type ApiBalance, + type ApiBalanceReset, + type ApiBalanceRollover, type ApiFeatureV1, cusEntsToPlanId, + entIntvToResetIntv, + type Feature, type FullCusEntWithFullCusProduct, + type FullCusEntWithOptionalProduct, + getRolloverFields, + isContUseFeature, + notNullish, + toIntervalCountResponse, } from "@autumn/shared"; +export const cusEntsToNextResetAt = ({ + cusEnts, +}: { + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; +}) => { + const result = cusEnts.reduce((acc, curr) => { + if (curr.next_reset_at && curr.next_reset_at < acc) { + return curr.next_reset_at; + } + return acc; + }, Infinity); + + if (result === Infinity) return null; + + return result; +}; + +export const cusEntsToReset = ({ + cusEnts, + feature, +}: { + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + feature: Feature; +}): ApiBalanceReset | null => { + // 1. If feature is allocated, null + if (isContUseFeature({ feature })) return null; + + // Check if there are multiple intervals + const uniqueIntervals = [ + ...new Set(cusEnts.map((cusEnt) => cusEnt.entitlement.interval)), + ]; + + if (uniqueIntervals.length > 1) { + return { interval: "multiple", interval_count: undefined, resets_at: null }; + } + + // 3. Only 1 interval + return { + interval: entIntvToResetIntv({ + entInterval: cusEnts[0].entitlement.interval, + }), + + interval_count: toIntervalCountResponse({ + intervalCount: cusEnts[0].entitlement.interval_count, + }), + + resets_at: cusEntsToNextResetAt({ cusEnts }), + }; +}; + +export const cusEntsToRollovers = ({ + cusEnts, + entityId, +}: { + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + entityId?: string; +}): ApiBalanceRollover[] | undefined => { + // If all cus ents no rollover, return undefined + + if (cusEnts.every((cusEnt) => !cusEnt.entitlement.rollover)) { + return undefined; + } + + return cusEnts + .map((cusEnt) => { + const rolloverFields = getRolloverFields({ cusEnt, entityId }); + if (rolloverFields) + return rolloverFields.rollovers.map((rollover) => ({ + balance: rollover.balance, + expires_at: rollover.expires_at || 0, + })); + return []; + }) + .filter(notNullish) + .flat(); +}; + export const getBooleanApiBalance = ({ cusEnts, apiFeature, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; apiFeature?: ApiFeatureV1; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; @@ -55,7 +141,7 @@ export const getUnlimitedApiBalance = ({ cusEnts, }: { apiFeature?: ApiFeatureV1; - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 0396f6f78..6dfd832b5 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -1,6 +1,7 @@ import type { ApiBalance, FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, FullCustomer, } from "@autumn/shared"; import { @@ -41,14 +42,19 @@ const cusEntsToBreakdown = ({ cusEnts, }: { ctx: RequestContext; - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; fullCus: FullCustomer; -}): { - key: string; - breakdown: ApiBalanceBreakdown; - prepaidQuantity: number; -}[] => { - const keyToCusEnts: Record = {}; +}): + | { + key: string; + breakdown: ApiBalanceBreakdown; + prepaidQuantity: number; + }[] + | undefined => { + const keyToCusEnts: Record< + string, + (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[] + > = {}; for (const cusEnt of cusEnts) { const key = cusEntToKey({ cusEnt }); keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt]; @@ -75,19 +81,8 @@ const cusEntsToBreakdown = ({ includeBreakdown: false, }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); - const planId = cusEnts[0].customer_product.product.id; - - // console.log(`Breakdown:`, { - // entityId: cusEnts[0].customer_product?.entity_id, - // granted_balance: breakdownItem.granted_balance, - // purchased_balance: breakdownItem.purchased_balance, - // current_balance: breakdownItem.current_balance, - // usage: breakdownItem.usage, - // max_purchase: breakdownItem.max_purchase, - // overage_allowed: breakdownItem.overage_allowed, - // reset: reset, - // }); + const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts, feature }); + const planId = cusEnts[0].customer_product?.product.id ?? null; breakdown.push({ key, @@ -114,41 +109,6 @@ const cusEntsToBreakdown = ({ return breakdown; }; -// export const cusEntsToPrepaidQuantity = ({ -// cusEnts, -// feature, -// }: { -// cusEnts: FullCusEntWithFullCusProduct[]; -// feature: Feature; -// }) => { -// let prepaidQuantity = new Decimal(0); - -// for (const cusEnt of cusEnts) { -// // 1. if cus ent doesn't match feature, skip -// if (!cusEntMatchesFeature({ cusEnt, feature })) continue; - -// // 2. If cus ent is not prepaid, skip -// const cusPrice = cusEntToCusPrice({ cusEnt }); - -// if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) continue; - -// // 3. Get quantity -// const options = cusEnt.customer_product.options.find( -// (option) => option.internal_feature_id === feature.internal_id, -// ); - -// if (!options) continue; - -// const quantityWithUnits = new Decimal(options.quantity) -// .mul(cusPrice.price.config.billing_units ?? 1) -// .toNumber(); - -// prepaidQuantity = prepaidQuantity.add(quantityWithUnits); -// } - -// return prepaidQuantity.toNumber(); -// }; - export const getApiBalance = ({ ctx, fullCus, @@ -159,7 +119,7 @@ export const getApiBalance = ({ }: { ctx: RequestContext; fullCus: FullCustomer; - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; feature: Feature; includeRollovers?: boolean; includeBreakdown?: boolean; @@ -245,13 +205,13 @@ export const getApiBalance = ({ const reset = cusEntsToReset({ cusEnts, feature }); const rollovers = cusEntsToRollovers({ cusEnts, entityId }); - const breakdown = includeBreakdown + const breakdownSet = includeBreakdown ? cusEntsToBreakdown({ ctx, fullCus, cusEnts }) - : []; + : undefined; const planId = cusEntsToPlanId({ cusEnts }); - const masterKey = breakdown ? null : cusEntToKey({ cusEnt: cusEnts[0] }); + const masterKey = breakdownSet ? null : cusEntToKey({ cusEnt: cusEnts[0] }); const { data: apiBalance, error } = ApiBalanceSchema.safeParse({ feature: expandIncludes({ @@ -284,7 +244,7 @@ export const getApiBalance = ({ reset: reset, plan_id: planId, - breakdown: breakdown.map((item) => item.breakdown), + breakdown: breakdownSet?.map((item) => item.breakdown), rollovers, } satisfies ApiBalance); @@ -292,7 +252,7 @@ export const getApiBalance = ({ // Return in latest format - version transformation happens at Customer level const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); - const breakdownLegacyData = breakdown.map((item) => ({ + const breakdownLegacyData = breakdownSet?.map((item) => ({ key: item.key, prepaid_quantity: item.prepaidQuantity, })); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index 157fcdde6..937a25d3e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -1,10 +1,9 @@ import { type ApiBalance, type CusFeatureLegacyData, - cusProductsToCusEnts, - type FullCusEntWithFullCusProduct, + cusProductsToCusEnts, type FullCusEntWithOptionalProduct, type FullCustomer, - orgToInStatuses, + orgToInStatuses } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -25,8 +24,22 @@ export const getApiBalances = async ({ entity: fullCus.entity, }); - const featureToCusEnt: Record = {}; - for (const cusEnt of cusEntsWithCusProduct) { + // Add extra entitlements (loose entitlements not tied to a product) + const extraEnts: FullCusEntWithOptionalProduct[] = ( + fullCus.extra_customer_entitlements || [] + ).map((ent) => ({ + ...ent, + customer_product: null, + })); + + // Combine both sources + const allCusEnts: FullCusEntWithOptionalProduct[] = [ + ...cusEntsWithCusProduct, + ...extraEnts, + ]; + + const featureToCusEnt: Record = {}; + for (const cusEnt of allCusEnts) { const featureId = cusEnt.entitlement.feature.id; featureToCusEnt[featureId] = [ ...(featureToCusEnt[featureId] || []), diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 46bd33b96..53764d323 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -61,6 +61,7 @@ export const getOrCreateCustomer = async ({ expand, allowNotFound: true, withSubs: true, + withExtraCustomerEntitlements: true, }); } @@ -90,6 +91,7 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, + withExtraCustomerEntitlements: true, }); } catch (error: any) { if (error?.data?.code === "23505" && customerId) { @@ -103,6 +105,7 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, + withExtraCustomerEntitlements: true, }); } else { throw error; @@ -128,6 +131,7 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, + withExtraCustomerEntitlements: true, }); } } diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 1ad624f6c..9ae6205d8 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -176,6 +176,54 @@ const buildSubscriptionsCTE = ( `; }; +const buildExtraEntitlementsCTE = (withExtraEntitlements: boolean) => { + if (!withExtraEntitlements) { + return sql``; + } + + return sql` + extra_customer_entitlements AS ( + SELECT + COALESCE( + json_agg( + to_jsonb(ce.*) || jsonb_build_object( + 'entitlement', ( + SELECT row_to_json(ent_with_feature) + FROM ( + SELECT e.*, row_to_json(f) AS feature + FROM entitlements e + JOIN features f ON e.internal_feature_id = f.internal_id + WHERE e.id = ce.entitlement_id + ) AS ent_with_feature + ), + 'replaceables', ( + SELECT COALESCE( + json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL), + '[]'::json + ) + FROM replaceables r + WHERE r.cus_ent_id = ce.id + ), + 'rollovers', ( + SELECT COALESCE( + json_agg(row_to_json(ro) ORDER BY ro.expires_at ASC NULLS LAST) + FILTER (WHERE ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000 OR ro.expires_at IS NULL), + '[]'::json + ) + FROM rollovers ro + WHERE ro.cus_ent_id = ce.id + ) + ) + ) FILTER (WHERE ce.id IS NOT NULL), + '[]'::json + ) AS extra_customer_entitlements + FROM customer_entitlements ce + WHERE ce.internal_customer_id = (SELECT internal_id FROM customer_record) + AND ce.customer_product_id IS NULL + ) + `; +}; + const buildInvoicesCTE = (hasEntityCTE: boolean) => { const entityFilter = hasEntityCTE ? sql`AND ( @@ -210,6 +258,7 @@ export const getFullCusQuery = ( withTrialsUsed: boolean, withSubs: boolean, withEvents: boolean, + withExtraEntitlements: boolean, entityId?: string, ) => { const sqlChunks: SQL[] = []; @@ -257,6 +306,12 @@ export const getFullCusQuery = ( sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses)); } + // Conditionally add extra entitlements CTE + if (withExtraEntitlements) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildExtraEntitlementsCTE(withExtraEntitlements)); + } + // Conditionally add invoices CTE if (includeInvoices) { sqlChunks.push(sql`, `); @@ -323,6 +378,12 @@ export const getFullCusQuery = ( (SELECT subscriptions FROM customer_subscriptions) AS subscriptions`); } + // Add extra entitlements to SELECT if withExtraEntitlements is true + if (withExtraEntitlements) { + selectFieldsChunks.push(sql`, + (SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`); + } + if (includeInvoices) { selectFieldsChunks.push(sql`, (SELECT invoices FROM customer_invoices) AS invoices`); diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 68e480e9a..819832cc5 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -161,15 +161,15 @@ cusRouter.get( const product = cusProduct ? cusProductToProduct({ cusProduct }) : await ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: product_id, - version: - version && Number.isInteger(parseInt(version)) - ? parseInt(version) - : undefined, - }); + db, + orgId: org.id, + env, + idOrInternalId: product_id, + version: + version && Number.isInteger(parseInt(version)) + ? parseInt(version) + : undefined, + }); const productV2 = mapToProductV2({ product: product!, features }); @@ -201,6 +201,7 @@ export const handleGetCustomerInternal = createRoute({ env, idOrInternalId: customer_id, withEntities: true, + withExtraCustomerEntitlements: true, expand: [CusExpand.Invoices], inStatuses: [ CusProductStatus.Active, diff --git a/server/tests/balances/check/basic/check-loose1.test.ts b/server/tests/balances/check/basic/check-loose1.test.ts new file mode 100644 index 000000000..6d917748f --- /dev/null +++ b/server/tests/balances/check/basic/check-loose1.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponseV2, + EntInterval, + SuccessCode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check-loose1"; + +describe(`${chalk.yellowBright("check-loose1: basic loose entitlement check")}`, () => { + const customerId = "check-loose1"; + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Create loose entitlement (no product attached) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: "500", + }); + }); + + test("v2: loose entitlement should be allowed with plan_id null", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Messages); + expect(res.balance?.granted_balance).toBe(500); + expect(res.balance?.current_balance).toBe(500); + expect(res.balance?.usage).toBe(0); + expect(res.balance?.unlimited).toBe(false); + }); + + test("v2: should respect required_balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 400, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.required_balance).toBe(400); + }); + + test("v2: should return allowed=false for insufficient balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 999, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); + expect(res.required_balance).toBe(999); + expect(res.balance?.current_balance).toBe(500); + }); +}); diff --git a/server/tests/balances/check/basic/check-loose2.test.ts b/server/tests/balances/check/basic/check-loose2.test.ts new file mode 100644 index 000000000..b40c0557e --- /dev/null +++ b/server/tests/balances/check/basic/check-loose2.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check-loose2"; + +describe(`${chalk.yellowBright("check-loose2: unlimited loose entitlement check")}`, () => { + const customerId = "check-loose2"; + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Create unlimited loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Users, + unlimited: true, + }); + }); + + test("v2: unlimited loose entitlement should always be allowed", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Users); + expect(res.balance?.unlimited).toBe(true); + }); + + test("v2: unlimited should allow any required_balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + required_balance: 999999, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.unlimited).toBe(true); + }); +}); diff --git a/server/tests/balances/check/basic/check-loose3.test.ts b/server/tests/balances/check/basic/check-loose3.test.ts new file mode 100644 index 000000000..0bfdc8d9c --- /dev/null +++ b/server/tests/balances/check/basic/check-loose3.test.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check-loose3"; + +describe(`${chalk.yellowBright("check-loose3: mixed product + loose entitlement")}`, () => { + const customerId = "check-loose3"; + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Attach product first (gives 100 messages) + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + + // Then add loose entitlement for same feature (adds 500 more) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: "500", + }); + }); + + test("v2: combined balance should include both sources", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.balance).toBeDefined(); + + // Total should be 100 (product) + 500 (loose) = 600 + expect(res.balance?.granted_balance).toBe(600); + expect(res.balance?.current_balance).toBe(600); + + // When mixed sources, plan_id should be null and breakdown should exist + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.breakdown).toBeDefined(); + expect(res.balance?.breakdown).toHaveLength(2); + }); + + test("v2: breakdown should show each source separately", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + const breakdown = res.balance?.breakdown; + expect(breakdown).toBeDefined(); + expect(breakdown).toHaveLength(2); + + // Find the product entitlement (has plan_id) + const productEnt = breakdown?.find((b) => b.plan_id === freeProd.id); + expect(productEnt).toBeDefined(); + expect(productEnt?.granted_balance).toBe(100); + + // Find the loose entitlement (plan_id is null) + const looseEnt = breakdown?.find((b) => b.plan_id === null); + expect(looseEnt).toBeDefined(); + expect(looseEnt?.granted_balance).toBe(500); + }); + + test("v2: should allow high required_balance with combined sources", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 550, // More than either source alone, but less than combined + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.required_balance).toBe(550); + }); + + test("v2: should deny when exceeds combined balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 700, // More than combined 600 + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); + expect(res.required_balance).toBe(700); + expect(res.balance?.current_balance).toBe(600); + }); +}); diff --git a/server/tests/balances/check/basic/check-loose4.test.ts b/server/tests/balances/check/basic/check-loose4.test.ts new file mode 100644 index 000000000..91ee7c0a2 --- /dev/null +++ b/server/tests/balances/check/basic/check-loose4.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2, EntInterval, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check-loose4"; + +describe(`${chalk.yellowBright("check-loose4: loose entitlement with reset interval")}`, () => { + const customerId = "check-loose4"; + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Create loose entitlement with monthly reset + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Action1, + granted_balance: "1000", + reset: { + interval: EntInterval.Month, + interval_count: 1, + }, + }); + }); + + test("v2: loose entitlement with reset should include reset info", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Action1); + expect(res.balance?.granted_balance).toBe(1000); + expect(res.balance?.current_balance).toBe(1000); + + // Reset info should be present + expect(res.balance?.reset).toBeDefined(); + expect(res.balance?.reset?.interval).toBe(ResetInterval.Month); + expect(res.balance?.reset?.resets_at).toBeDefined(); + + }); +}); diff --git a/shared/models/cusModels/fullCusModel.ts b/shared/models/cusModels/fullCusModel.ts index 7c23e00b9..f13966557 100644 --- a/shared/models/cusModels/fullCusModel.ts +++ b/shared/models/cusModels/fullCusModel.ts @@ -4,6 +4,7 @@ import { CusProductSchema, type FullCusProduct, } from "../cusProductModels/cusProductModels.js"; +import type { FullCustomerEntitlement } from "../cusProductModels/cusEntModels/cusEntModels.js"; import type { Event } from "../eventModels/eventTable.js"; import type { Subscription } from "../subModels/subModels.js"; import { type Customer, CustomerSchema } from "./cusModels.js"; @@ -22,6 +23,7 @@ export type FullCustomer = Customer & { invoices?: Invoice[]; subscriptions?: Subscription[]; events?: Event[]; + extra_customer_entitlements?: FullCustomerEntitlement[]; }; export const CustomerWithProductsSchema = CustomerSchema.extend({ diff --git a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts index c6fde4d72..bd396603b 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts @@ -13,7 +13,15 @@ export const FullCusEntWithFullCusProductSchema = customer_product: FullCusProductSchema, }); +export const FullCusEntWithOptionalProductSchema = + FullCustomerEntitlementSchema.extend({ + customer_product: FullCusProductSchema.nullable(), + }); + export type FullCusEntWithProduct = z.infer; export type FullCusEntWithFullCusProduct = z.infer< typeof FullCusEntWithFullCusProductSchema >; +export type FullCusEntWithOptionalProduct = z.infer< + typeof FullCusEntWithOptionalProductSchema +>; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts index 9ef5711e9..fe9bb0a79 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; import { cusEntToCusPrice, @@ -13,7 +13,7 @@ export const cusEntToPurchasedBalance = ({ cusEnt, entityId, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; entityId?: string; }) => { // return 0; @@ -36,7 +36,7 @@ export const cusEntToPurchasedBalance = ({ const cusProduct = cusEnt.customer_product; const options = entToOptions({ ent: cusEnt.entitlement, - options: cusProduct.options, + options: cusProduct?.options ?? [], }); const quantity = options?.quantity || 0; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts index 1e25bc117..5e67b7295 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../utils"; import { cusEntToBalance } from "../convertCusEntUtils"; @@ -7,7 +7,7 @@ export const cusEntsToBalance = ({ entityId, withRollovers = false, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; entityId?: string; withRollovers?: boolean; }) => { diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts index 1603b1602..58416d76b 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts @@ -1,15 +1,17 @@ import { Decimal } from "decimal.js"; -import { - cusEntToCusPrice, - type FullCusEntWithFullCusProduct, - isPrepaidPrice, - sumValues, -} from "../../.."; +import { sumValues } from "../../../index.js"; +import type { + FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, +} from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; +import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; +import { isPrepaidPrice } from "../../productUtils/priceUtils.js"; export const cusEntToPrepaidQuantity = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); @@ -17,7 +19,7 @@ export const cusEntToPrepaidQuantity = ({ if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0; // 3. Get quantity - const options = cusEnt.customer_product.options.find( + const options = cusEnt.customer_product?.options?.find( (option) => option.internal_feature_id === cusEnt.entitlement.internal_feature_id, ); @@ -34,7 +36,7 @@ export const cusEntToPrepaidQuantity = ({ export const cusEntsToPrepaidQuantity = ({ cusEnts, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; }) => { return sumValues( cusEnts.map((cusEnt) => cusEntToPrepaidQuantity({ cusEnt })), diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts index 931bd404e..f724373ed 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; @@ -6,7 +6,7 @@ export const cusEntsToAdjustment = ({ cusEnts, entityId, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; entityId?: string; }) => { return sumValues( diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts index 45c7e790a..af6a59de5 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; import { getRolloverFields } from "../../getRolloverFields"; @@ -10,7 +10,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; entityId?: string; withRollovers?: boolean; }) => { @@ -19,7 +19,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; entityId?: string; withRollovers?: boolean; }) => { @@ -36,7 +36,7 @@ export const cusEntsToAllowance = ({ const grantedBalance = cusEnt.entitlement.allowance || 0; const total = new Decimal(grantedBalance) - .mul(cusEnt.customer_product.quantity ?? 1) + .mul(cusEnt.customer_product?.quantity ?? 1) .mul(entityCount) .toNumber(); diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 563f618e6..9c1cac68a 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -1,7 +1,10 @@ import { Decimal } from "decimal.js"; import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBalance.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { + FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, +} from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js"; import { cusEntToCusPrice, @@ -11,10 +14,27 @@ import { getCusEntBalance } from "./balanceUtils.js"; import { getRolloverFields } from "./getRolloverFields.js"; import { getStartingBalance } from "./getStartingBalance.js"; +export const cusEntToKey = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; +}) => { + // Interval + const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`; + + const planId = cusEnt.customer_product + ? `${cusEnt.customer_product.product_id}` + : `extra:${cusEnt.id}`; + + const usageModel = `${cusEnt.usage_allowed}`; + + return `${interval}:${planId}:${usageModel}`; +}; + export const cusEntsToPlanId = ({ cusEnts, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; }) => { // Get number of keys const uniquePlanIds = new Set(); @@ -28,7 +48,7 @@ export const cusEntsToPlanId = ({ return null; } - return cusEnts[0].customer_product.product.id; + return cusEnts[0].customer_product?.product.id ?? null; }; export const cusEntToBalance = ({ @@ -62,7 +82,7 @@ export const cusEntToIncludedUsage = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; entityId?: string; withRollovers?: boolean; }) => { @@ -79,7 +99,7 @@ export const cusEntToIncludedUsage = ({ const cusProduct = cusEnt.customer_product; const options = entToOptions({ ent: cusEnt.entitlement, - options: cusProduct.options, + options: cusProduct?.options ?? [], }); const cusPrice = cusEntToCusPrice({ cusEnt }); @@ -87,7 +107,7 @@ export const cusEntToIncludedUsage = ({ entitlement: cusEnt.entitlement, options: options || undefined, relatedPrice: cusPrice?.price, - productQuantity: cusProduct.quantity || 1, + productQuantity: cusProduct?.quantity ?? 1, }); const total = new Decimal(startingBalance).mul(entityCount).toNumber(); diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts index 55ebdd6e4..1e0f9f80f 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts @@ -2,6 +2,7 @@ import { Decimal } from "decimal.js"; import { cusEntToIncludedUsage, type FullCusEntWithFullCusProduct, + type FullCusEntWithOptionalProduct, isPrepaidCusEnt, notNullish, nullish, @@ -11,7 +12,7 @@ export const cusEntsToMaxPurchase = ({ cusEnts, entityId, }: { - cusEnts: FullCusEntWithFullCusProduct[]; + cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; entityId?: string; }): number | null => { // 1. If there's usage-based cus ent, return undefined diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 7b4e095bb..e1a552c42 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -1,7 +1,7 @@ import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; -import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../productUtils/priceUtils.js"; @@ -104,14 +104,14 @@ export const updateCusEntInFullCus = ({ export const isPrepaidCusEnt = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false; // 3. Get quantity - const options = cusEnt.customer_product.options.find( + const options = cusEnt.customer_product?.options?.find( (option) => option.internal_feature_id === cusEnt.entitlement.internal_feature_id, ); diff --git a/shared/utils/productUtils/convertUtils.ts b/shared/utils/productUtils/convertUtils.ts index b179ad4f2..1ba7600c9 100644 --- a/shared/utils/productUtils/convertUtils.ts +++ b/shared/utils/productUtils/convertUtils.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels.js"; import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; import type { @@ -74,10 +74,10 @@ export const entToOptions = ({ export const cusEntToCusPrice = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct; + cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; }) => { const cusProduct = cusEnt.customer_product; - const cusPrices = cusProduct.customer_prices; + const cusPrices = cusProduct?.customer_prices ?? []; return cusPrices.find((cusPrice: FullCustomerPrice) => { const productMatch = cusPrice.customer_product_id === cusEnt.customer_product_id; 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 31c71c657..edc86cff9 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTable.tsx @@ -1,5 +1,5 @@ import type { - FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, FullCusProduct, } from "@autumn/shared"; import { Table } from "@/components/general/table"; @@ -16,10 +16,10 @@ export function CustomerBalanceTable({ aggregatedMap, isLoading, }: { - allEnts: FullCusEntWithFullCusProduct[]; + allEnts: FullCusEntWithOptionalProduct[]; filteredCustomerProducts: FullCusProduct[]; entityId: string | null; - aggregatedMap: Map; + aggregatedMap: Map; isLoading: boolean; }) { const { customer } = useCusQuery(); @@ -41,13 +41,13 @@ export function CustomerBalanceTable({ }); const enableSorting = false; - const table = useCustomerTable({ + const table = useCustomerTable({ data: allEnts, columns, options: {}, }); - const handleRowClick = (ent: FullCusEntWithFullCusProduct) => { + const handleRowClick = (ent: FullCusEntWithOptionalProduct) => { const featureId = ent.entitlement.feature.id; const ents = aggregatedMap.get(featureId) || [ent]; const hasMultipleBalances = ents.length > 1; 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 032d4a1a7..8ed4c882c 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx @@ -1,6 +1,6 @@ import type { Entity, - FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, FullCusProduct, } from "@autumn/shared"; import type { Row } from "@tanstack/react-table"; @@ -17,7 +17,7 @@ function UsageCell({ filteredCustomerProducts, entityId, }: { - ent: FullCusEntWithFullCusProduct; + ent: FullCusEntWithOptionalProduct; filteredCustomerProducts: FullCusProduct[]; entityId: string | null; }) { @@ -55,7 +55,7 @@ function BarCell({ filteredCustomerProducts, entityId, }: { - ent: FullCusEntWithFullCusProduct; + ent: FullCusEntWithOptionalProduct; filteredCustomerProducts: FullCusProduct[]; entityId: string | null; }) { @@ -100,7 +100,7 @@ export const CustomerBalanceTableColumns = ({ }: { filteredCustomerProducts: FullCusProduct[]; entityId: string | null; - aggregatedMap: Map; + aggregatedMap: Map; entities?: unknown[]; }) => [ { @@ -108,7 +108,7 @@ export const CustomerBalanceTableColumns = ({ accessorKey: "feature", enableResizing: true, minSize: 100, - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { const ent = row.original; const featureId = ent.entitlement.feature.id; const originalEnts = aggregatedMap.get(featureId); @@ -139,7 +139,7 @@ export const CustomerBalanceTableColumns = ({ { header: "Usage", accessorKey: "usage", - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => ( }) => ( + cell: ({ row }: { row: Row }) => ( ; + allEnts: FullCusEntWithOptionalProduct[]; + aggregatedMap: Map; isLoading: boolean; }) { const columns = useMemo( @@ -22,7 +22,7 @@ export function CustomerBooleanBalanceTable({ ); const enableSorting = false; - const table = useCustomerTable({ + const table = useCustomerTable({ data: allEnts, columns, options: {}, diff --git a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx b/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx index 023f4522f..29deb41d8 100644 --- a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx @@ -1,17 +1,17 @@ -import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; +import type { FullCusEntWithOptionalProduct } from "@autumn/shared"; import type { Row } from "@tanstack/react-table"; import { CustomerFeatureConfiguration } from "../customer-feature-usage/CustomerFeatureConfiguration"; export const CustomerBooleanBalanceTableColumns = ({ aggregatedMap, }: { - aggregatedMap: Map; + aggregatedMap: Map; }) => [ { header: "Feature", size: 200, accessorKey: "feature", - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { const ent = row.original; const featureId = ent.entitlement.feature.id; const originalEnts = aggregatedMap.get(featureId); @@ -36,7 +36,7 @@ export const CustomerBooleanBalanceTableColumns = ({ header: "Type", size: 200, accessorKey: "type", - cell: ({ row }: { row: Row }) => { + cell: ({ row }: { row: Row }) => { const ent = row.original; return ( diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx index 745d0c746..97e793e84 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx @@ -34,13 +34,13 @@ export const CustomerFeatureUsageColumns = [ if (subRowData) { const parentAllowance = subRowData.entitlement?.allowance ?? 0; - const parentQuantity = subRowData.customer_product.quantity || 1; + const parentQuantity = subRowData.customer_product?.quantity || 1; const parentTotal = parentAllowance * parentQuantity; const meteredCusEnt = subRowData.meteredCusEnt; if (meteredCusEnt?.entitlement) { const meteredAllowance = meteredCusEnt.entitlement.allowance || 0; - const meteredQuantity = meteredCusEnt.customer_product.quantity || 1; + const meteredQuantity = meteredCusEnt.customer_product?.quantity || 1; const meteredTotal = meteredAllowance * meteredQuantity; const meteredBalance = meteredCusEnt.balance || 0; const meteredUsed = meteredTotal - meteredBalance; @@ -54,7 +54,7 @@ export const CustomerFeatureUsageColumns = [ } else { allowance = subRowData.entitlement?.allowance ?? 0; balance = subRowData.meteredCusEnt?.balance ?? 0; - quantity = subRowData.customer_product.quantity || 1; + quantity = subRowData.customer_product?.quantity || 1; } featureName = subRowData.feature?.name ?? ""; @@ -64,7 +64,7 @@ export const CustomerFeatureUsageColumns = [ const parentEnt = cusEnt as FullCusEntWithSubRows; allowance = parentEnt.entitlement?.allowance || 0; balance = parentEnt?.balance || 0; - quantity = parentEnt.customer_product.quantity || 1; + quantity = parentEnt.customer_product?.quantity || 1; featureName = parentEnt.entitlement.feature?.name || ""; featureType = parentEnt.entitlement.feature?.type || FeatureType.Boolean; @@ -85,7 +85,7 @@ export const CustomerFeatureUsageColumns = [ if (subEnt.allowance_type !== AllowanceType.Unlimited) { const subTotal = (subEnt.allowance || 0) * - (meteredCusEnt.customer_product.quantity || 1); + (meteredCusEnt.customer_product?.quantity || 1); const subRemaining = meteredCusEnt.balance || 0; const subUsed = subTotal - subRemaining; totalSpent += subUsed * creditCost; @@ -136,7 +136,7 @@ export const CustomerFeatureUsageColumns = [ meteredCusEnt.entitlement.allowance_type || AllowanceType.Unlimited; allowance = meteredCusEnt.entitlement.allowance || 0; balance = meteredCusEnt.balance || 0; - quantity = meteredCusEnt.customer_product.quantity || 1; + quantity = meteredCusEnt.customer_product?.quantity || 1; isSubRow = true; creditAmount = credit_amount; } else { @@ -147,7 +147,7 @@ export const CustomerFeatureUsageColumns = [ parentEnt.entitlement.allowance_type || AllowanceType.Unlimited; allowance = parentEnt.entitlement.allowance || 0; balance = parentEnt.balance || 0; - quantity = parentEnt.customer_product.quantity || 1; + quantity = parentEnt.customer_product?.quantity || 1; isSubRow = false; subRows = parentEnt.subRows; } diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx index a61bf808f..8b0bd7df0 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx @@ -1,4 +1,8 @@ -import type { Entity, FullCusEntWithFullCusProduct } from "@autumn/shared"; +import type { + Entity, + FullCusEntWithOptionalProduct, + FullCustomerEntitlement, +} from "@autumn/shared"; import { FeatureType, type FullCusProduct } from "@autumn/shared"; import { BatteryHighIcon } from "@phosphor-icons/react"; import { type ExpandedState, getExpandedRowModel } from "@tanstack/react-table"; @@ -19,11 +23,9 @@ import { flattenCustomerEntitlements, processNonBooleanEntitlements, } from "./customerFeatureUsageUtils"; -import { useRawBalances } from "./useRawBalances"; export function CustomerFeatureUsageTable() { const { customer, features, isLoading } = useCusQuery(); - const { rawBalances } = useRawBalances(); const { entityId } = useEntity(); @@ -50,37 +52,21 @@ export function CustomerFeatureUsageTable() { ); }, [customer?.customer_products, customer?.entities, entityId]); - const cusEnts = useMemo(() => { + const cusEnts = useMemo((): FullCusEntWithOptionalProduct[] => { const productEnts = flattenCustomerEntitlements({ customerProducts: filteredCustomerProducts, }); - // Add raw balances (entitlements without internal_product_id) - // They need to have a customer_product structure to match FullCusEntWithFullCusProduct - const rawEnts = (rawBalances || []).map( - (raw: FullCusEntWithFullCusProduct) => ({ - ...raw, - rollovers: raw.rollovers || [], - replaceables: raw.replaceables || [], - customer_product: raw.customer_product || { - id: `raw-${raw.id}`, - internal_customer_id: customer?.internal_id, - customer_id: customer?.id, - internal_product_id: null, - product_id: null, - status: "active", - created_at: raw.created_at, - quantity: 1, - product: null, - customer_entitlements: [], - customer_prices: [], - free_trial: null, - }, - }), - ); + // Add extra entitlements (loose entitlements not tied to a product) + const extraEnts: FullCusEntWithOptionalProduct[] = ( + customer?.extra_customer_entitlements || [] + ).map((ent: FullCustomerEntitlement) => ({ + ...ent, + customer_product: null, + })); - return [...productEnts, ...rawEnts]; - }, [filteredCustomerProducts, rawBalances, customer]); + return [...productEnts, ...extraEnts]; + }, [filteredCustomerProducts, customer?.extra_customer_entitlements]); const featuresMap = useMemo( () => createFeaturesMap({ features: features ?? [] }), diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTableFilters.ts b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTableFilters.ts index bc3d12209..eed757e15 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTableFilters.ts +++ b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTableFilters.ts @@ -1,20 +1,24 @@ import { CusProductStatus, - type FullCusEntWithFullCusProduct, + type FullCusEntWithOptionalProduct, } from "@autumn/shared"; export function filterCustomerFeatureUsage({ entitlements, showExpired, }: { - entitlements: FullCusEntWithFullCusProduct[]; + entitlements: FullCusEntWithOptionalProduct[]; showExpired: boolean; -}): FullCusEntWithFullCusProduct[] { +}): FullCusEntWithOptionalProduct[] { return entitlements - .filter((ent: FullCusEntWithFullCusProduct) => { + .filter((ent: FullCusEntWithOptionalProduct) => { if (showExpired) { return true; } + // Extra entitlements (no customer_product) are always shown + if (!ent.customer_product) { + return true; + } // Exclude expired and scheduled products from balance calculations return ( ent.customer_product.status !== CusProductStatus.Expired && @@ -22,11 +26,14 @@ export function filterCustomerFeatureUsage({ ); }) .sort( - (a: FullCusEntWithFullCusProduct, b: FullCusEntWithFullCusProduct) => { - // Sort by status first (Active items first) - if (a.customer_product.status !== b.customer_product.status) { - if (a.customer_product.status === CusProductStatus.Active) return -1; - if (b.customer_product.status === CusProductStatus.Active) return 1; + (a: FullCusEntWithOptionalProduct, b: FullCusEntWithOptionalProduct) => { + const aStatus = a.customer_product?.status; + const bStatus = b.customer_product?.status; + + // Sort by status first (Active items first, null treated as active) + if (aStatus !== bStatus) { + if (!aStatus || aStatus === CusProductStatus.Active) return -1; + if (!bStatus || bStatus === CusProductStatus.Active) return 1; return 0; } diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTypes.ts b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTypes.ts index 00102d476..8449cde84 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTypes.ts +++ b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageTypes.ts @@ -1,7 +1,7 @@ import type { EntitlementWithFeature, Feature, - FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, FullCusProduct, } from "@autumn/shared"; @@ -19,13 +19,13 @@ export interface CreditSystemSubRow { /** The metered feature details (looked up from features map) */ feature?: Feature; /** The customer entitlement for this metered feature with usage data */ - meteredCusEnt?: FullCusEntWithFullCusProduct; + meteredCusEnt?: FullCusEntWithOptionalProduct; /** Flag to identify this as a subrow */ isSubRow: true; /** Parent entitlement (inherited for table context) */ entitlement: EntitlementWithFeature; /** Parent customer product (inherited for table context) */ - customer_product: FullCusProduct; + customer_product: FullCusProduct | null; /** Parent reset timestamp (inherited for table context) */ next_reset_at: number | null; } @@ -39,9 +39,9 @@ export type CustomerFeatureUsageRowData = | FullCusEntWithSubRows; /** - * Extended version of FullCusEntWithFullCusProduct that includes optional subrows + * Extended version of FullCusEntWithOptionalProduct that includes optional subrows * for credit system features. */ -export type FullCusEntWithSubRows = FullCusEntWithFullCusProduct & { +export type FullCusEntWithSubRows = FullCusEntWithOptionalProduct & { subRows?: CustomerFeatureUsageRowData[]; }; diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageUtils.ts b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageUtils.ts index 3f777a94c..fdaf4d5cd 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageUtils.ts +++ b/vite/src/views/customers2/components/table/customer-feature-usage/customerFeatureUsageUtils.ts @@ -1,7 +1,7 @@ import type { CreditSchemaItem, Feature, - FullCusEntWithFullCusProduct, + FullCusEntWithOptionalProduct, FullCusProduct, FullCustomerEntitlement, } from "@autumn/shared"; @@ -15,7 +15,7 @@ export function flattenCustomerEntitlements({ customerProducts, }: { customerProducts: FullCusProduct[]; -}): FullCusEntWithFullCusProduct[] { +}): FullCusEntWithOptionalProduct[] { return customerProducts.flatMap((cp: FullCusProduct) => cp.customer_entitlements.map((e: FullCustomerEntitlement) => ({ ...e, @@ -40,9 +40,9 @@ export function createFeaturesMap({ */ export interface DeduplicatedEntitlementsResult { /** Combined entitlements (one per feature) */ - entitlements: FullCusEntWithFullCusProduct[]; + entitlements: FullCusEntWithOptionalProduct[]; /** Mapping of featureId -> array of original entitlements that were aggregated */ - aggregatedMap: Map; + aggregatedMap: Map; } /** @@ -52,10 +52,10 @@ export function deduplicateEntitlements({ entitlements, entityId, }: { - entitlements: FullCusEntWithFullCusProduct[]; + entitlements: FullCusEntWithOptionalProduct[]; entityId?: string | null; }): DeduplicatedEntitlementsResult { - const featureMap = new Map(); + const featureMap = new Map(); for (const ent of entitlements) { const featureId = ent.entitlement.feature.id; @@ -65,8 +65,8 @@ export function deduplicateEntitlements({ featureMap.get(featureId)?.push(ent); } - const combined: FullCusEntWithFullCusProduct[] = []; - const aggregatedMap = new Map(); + const combined: FullCusEntWithOptionalProduct[] = []; + const aggregatedMap = new Map(); for (const [featureId, ents] of featureMap.entries()) { if (ents.length === 1) { @@ -103,7 +103,7 @@ export function deduplicateEntitlements({ 0, ); const summedQuantity = ents.reduce( - (sum, e) => sum + (e.customer_product.quantity ?? 1), + (sum, e) => sum + (e.customer_product?.quantity ?? 1), 0, ); const earliestReset = ents.reduce( @@ -122,10 +122,12 @@ export function deduplicateEntitlements({ ...first.entitlement, allowance: summedAllowance, }, - customer_product: { - ...first.customer_product, - quantity: summedQuantity, - }, + customer_product: first.customer_product + ? { + ...first.customer_product, + quantity: summedQuantity, + } + : null, next_reset_at: earliestReset ?? first.next_reset_at, }); } @@ -145,13 +147,13 @@ export function processNonBooleanEntitlements({ cusEnts, featuresMap, }: { - entitlements: FullCusEntWithFullCusProduct[]; - cusEnts: FullCusEntWithFullCusProduct[]; + entitlements: FullCusEntWithOptionalProduct[]; + cusEnts: FullCusEntWithOptionalProduct[]; featuresMap: Map; }): FullCusEntWithSubRows[] { // Create a map of feature id to customer entitlements for quick lookup const featureIdToCusEnt = new Map( - cusEnts.map((ent: FullCusEntWithFullCusProduct) => [ + cusEnts.map((ent: FullCusEntWithOptionalProduct) => [ ent.entitlement.feature.id, ent, ]), @@ -159,10 +161,10 @@ export function processNonBooleanEntitlements({ return entitlements .filter( - (ent: FullCusEntWithFullCusProduct) => + (ent: FullCusEntWithOptionalProduct) => ent.entitlement.feature.type !== FeatureType.Boolean, ) - .map((ent: FullCusEntWithFullCusProduct): FullCusEntWithSubRows => { + .map((ent: FullCusEntWithOptionalProduct): FullCusEntWithSubRows => { if (ent.entitlement.feature.type === FeatureType.CreditSystem) { const creditSchema = ent.entitlement.feature.config?.schema || []; const subRows = creditSchema.map((schemaItem: CreditSchemaItem) => { @@ -177,7 +179,7 @@ export function processNonBooleanEntitlements({ feature_amount: schemaItem.feature_amount, feature: meteredFeature, meteredCusEnt, - isSubRow: true, + isSubRow: true as const, entitlement: ent.entitlement, customer_product: ent.customer_product, next_reset_at: ent.next_reset_at, @@ -195,10 +197,10 @@ export function processNonBooleanEntitlements({ export function filterBooleanEntitlements({ entitlements, }: { - entitlements: FullCusEntWithFullCusProduct[]; -}): FullCusEntWithFullCusProduct[] { + entitlements: FullCusEntWithOptionalProduct[]; +}): FullCusEntWithOptionalProduct[] { return entitlements.filter( - (ent: FullCusEntWithFullCusProduct) => + (ent: FullCusEntWithOptionalProduct) => ent.entitlement.feature.type === FeatureType.Boolean, ); } diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts b/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts index feff05ef2..9a10232a6 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts +++ b/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts @@ -3,32 +3,36 @@ import { useParams } from "react-router"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { throwBackendError } from "@/utils/genUtils"; -export const useRawBalances = ({ enabled = true }: { enabled?: boolean } = {}) => { - const { customer_id } = useParams(); - const axiosInstance = useAxiosInstance(); +export const useRawBalances = ({ + enabled = true, +}: { + enabled?: boolean; +} = {}) => { + const { customer_id } = useParams(); + const axiosInstance = useAxiosInstance(); - const fetcher = async () => { - try { - const { data } = await axiosInstance.get(`/v1/balances/list`, { - params: { customer_id }, - }); - return data; - } catch (error) { - throwBackendError(error); - } - }; + const fetcher = async () => { + try { + const { data } = await axiosInstance.get(`/v1/balances/list`, { + params: { customer_id }, + }); + return data; + } catch (error) { + throwBackendError(error); + } + }; - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["rawBalances", customer_id], - queryFn: fetcher, - enabled: enabled && !!customer_id, - retry: false, - }); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["rawBalances", customer_id], + queryFn: fetcher, + enabled: enabled && !!customer_id, + retry: false, + }); - return { - rawBalances: data?.balances ?? [], - isLoading, - error, - refetch, - }; + return { + rawBalances: data?.balances ?? [], + isLoading, + error, + refetch, + }; }; From 507be22a9800eb60f9589ce9e3e16c298c03c3ec Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:12:04 +0000 Subject: [PATCH 07/59] =?UTF-8?q?chore:=20=F0=9F=A4=96=20rm=20useRawBalanc?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customer-feature-usage/useRawBalances.ts | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts b/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts deleted file mode 100644 index 9a10232a6..000000000 --- a/vite/src/views/customers2/components/table/customer-feature-usage/useRawBalances.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { throwBackendError } from "@/utils/genUtils"; - -export const useRawBalances = ({ - enabled = true, -}: { - enabled?: boolean; -} = {}) => { - const { customer_id } = useParams(); - const axiosInstance = useAxiosInstance(); - - const fetcher = async () => { - try { - const { data } = await axiosInstance.get(`/v1/balances/list`, { - params: { customer_id }, - }); - return data; - } catch (error) { - throwBackendError(error); - } - }; - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["rawBalances", customer_id], - queryFn: fetcher, - enabled: enabled && !!customer_id, - retry: false, - }); - - return { - rawBalances: data?.balances ?? [], - isLoading, - error, - refetch, - }; -}; From 1649d91a7c5b82d280205b48d78fe26f8e6946f1 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 29 Dec 2025 14:36:19 -0800 Subject: [PATCH 08/59] reviewed and made changes --- server/src/external/autumn/autumnCli.ts | 12 +- .../prepareNewBalanceForInsertion.ts | 138 +++++++++--------- .../validationUtilsForNewBalances.ts | 108 ++++++-------- .../balances/handlers/handleCreateBalance.ts | 126 ++++++++-------- .../setUsage/getSetUsageDeductions.ts | 10 +- .../track/trackUtils/rollbackDeduction.ts | 6 +- .../track/trackUtils/runDeductionTx.ts | 18 +-- .../updateGrantedBalance.ts | 6 +- .../internal/balances/utils/sync/syncItem.ts | 6 +- server/src/internal/customers/CusService.ts | 3 - .../createUsageInvoiceItems.ts | 28 ++-- .../cusProducts/cusPrices/CusPriceService.ts | 17 ++- .../apiCusCacheUtils/getCachedApiCustomer.ts | 1 - .../getApiBalance/getApiBalances.ts | 9 +- .../internal/customers/cusUtils/cusUtils.ts | 1 + .../customers/cusUtils/getOrCreateCustomer.ts | 4 - .../src/internal/customers/getFullCusQuery.ts | 19 +-- .../internal/customers/internalCusRouter.ts | 19 ++- .../handleProratedDowngrade.ts | 10 +- .../handleProratedUpgrade.ts | 7 + server/src/utils/importUtils/updateUsages.ts | 9 +- server/tests/_temp/temp.test.ts | 15 +- .../balances/check/basic/check-loose1.test.ts | 9 +- .../tests/balances/check/basic/check6.test.ts | 1 - .../balances/create/createBalanceParams.ts | 17 +++ shared/api/models.ts | 1 + shared/models/cusModels/fullCusModel.ts | 4 +- .../cusEntModels/cusEntWithProduct.ts | 2 +- .../balanceUtils/cusEntToStartingBalance.ts | 26 ++++ .../balanceUtils/cusEntsToPrepaidQuantity.ts | 19 ++- .../cusProductUtils/convertCusProduct.ts | 81 +--------- .../convertCusProduct/cusProductToCusEnts.ts | 13 ++ .../cusProductToFeatureOptions.ts | 25 ++++ .../convertCusProduct/cusProductsToCusEnts.ts | 34 +++++ .../utils/cusProductUtils/cusProductUtils.ts | 2 +- .../fullCustomerToCustomerEntitlements.ts | 87 +++++++++++ shared/utils/index.ts | 5 + .../hooks/useFeatureUsageBalance.ts | 2 +- 38 files changed, 506 insertions(+), 394 deletions(-) create mode 100644 shared/api/balances/create/createBalanceParams.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductToCusEnts.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts create mode 100644 shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index c82b57333..6e8b5fa16 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -8,6 +8,7 @@ import { type AttachBodyV0, type BalancesUpdateParams, type CheckQuery, + type CreateBalanceParams, type CreateCustomerParams, type CreateEntityParams, type CreateRewardProgram, @@ -676,16 +677,7 @@ export class AutumnInt { }; balances = { - create: async (params: { - customer_id: string; - feature_id: string; - granted_balance?: string; - unlimited?: boolean; - reset?: { - interval: string; - interval_count?: number; - }; - }) => { + create: async (params: CreateBalanceParams) => { const data = await this.post(`/balances/create`, params); return data; }, diff --git a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts index 2fe0e14f8..f57fa528c 100644 --- a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts @@ -1,86 +1,84 @@ import { - type CustomerEntitlement, - type Feature, - type FullCustomer, - planFeaturesToItems, - type ResetInterval, + type CreateBalanceSchema, + type CustomerEntitlement, + type Feature, + type FullCustomer, + planFeaturesToItems, + type ResetInterval, } from "@shared/index"; import type z from "zod/v4"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; import { toFeature } from "@/internal/products/product-items/productItemUtils/itemToPriceAndEnt"; -import type { CreateBalanceSchema } from "./validationUtilsForNewBalances"; export const prepareNewBalanceForInsertion = async ({ - ctx, - feature, - granted_balance, - unlimited, - reset, - fullCus, - feature_id, + ctx, + feature, + granted_balance, + unlimited, + reset, + fullCus, + feature_id, }: { - ctx: AutumnContext; - feature: Feature; - granted_balance: string | undefined; - unlimited: boolean | undefined; - reset: z.infer["reset"]; - fullCus: FullCustomer; - feature_id: string; + ctx: AutumnContext; + feature: Feature; + granted_balance: number | undefined; + unlimited: boolean | undefined; + reset: z.infer["reset"]; + fullCus: FullCustomer; + feature_id: string; }) => { - const inputAsItem = planFeaturesToItems({ - features: [feature], - planFeatures: [ - { - feature_id, - granted_balance: granted_balance - ? parseFloat(granted_balance) - : undefined, - unlimited, - reset: reset - ? { - interval: reset.interval as ResetInterval, - interval_count: reset.interval_count, - reset_when_enabled: true, - } - : undefined, - }, - ], - }); + const inputAsItem = planFeaturesToItems({ + features: [feature], + planFeatures: [ + { + feature_id, + granted_balance: granted_balance, + unlimited, + reset: reset + ? { + interval: reset.interval as ResetInterval, + interval_count: reset.interval_count, + reset_when_enabled: true, + } + : undefined, + }, + ], + }); - const { ent: newEntitlement } = toFeature({ - item: inputAsItem[0], - orgId: ctx.org.id, - isCustom: true, - internalFeatureId: feature.internal_id!, - }); + const { ent: newEntitlement } = toFeature({ + item: inputAsItem[0], + orgId: ctx.org.id, + isCustom: true, + internalFeatureId: feature.internal_id!, + }); - const newEntitlementWithFeature = { - ...newEntitlement, - feature, - feature_id: feature.id, - }; + const newEntitlementWithFeature = { + ...newEntitlement, + feature, + feature_id: feature.id, + }; - const newCustomerEntitlement = initCusEntitlement({ - entitlement: newEntitlementWithFeature, - customer: fullCus, - cusProductId: null, - freeTrial: null, - nextResetAt: - initNextResetAt({ - entitlement: newEntitlementWithFeature, - now: Date.now(), - }) ?? Date.now(), - entities: [], - carryExistingUsages: false, - replaceables: [], - now: Date.now(), - productOptions: undefined, - }) satisfies CustomerEntitlement; + const newCustomerEntitlement = initCusEntitlement({ + entitlement: newEntitlementWithFeature, + customer: fullCus, + cusProductId: null, + freeTrial: null, + nextResetAt: + initNextResetAt({ + entitlement: newEntitlementWithFeature, + now: Date.now(), + }) ?? Date.now(), + entities: [], + carryExistingUsages: false, + replaceables: [], + now: Date.now(), + productOptions: undefined, + }) satisfies CustomerEntitlement; - return { - newEntitlement, - newCustomerEntitlement, - }; + return { + newEntitlement, + newCustomerEntitlement, + }; }; diff --git a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts b/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts index 698cd43a2..2f989394c 100644 --- a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts +++ b/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts @@ -1,79 +1,67 @@ +import { CreateBalanceSchema } from "@autumn/shared"; import { - ErrCode, - type Feature, - FeatureSchema, - FeatureType, - RecaseError, - ResetInterval, + ErrCode, + type Feature, + FeatureSchema, + FeatureType, + RecaseError, + ResetInterval, } from "@shared/index"; import { StatusCodes } from "http-status-codes"; import z from "zod/v4"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -export const CreateBalanceSchema = z.object({ - feature_id: z.string(), - granted_balance: z.string().optional(), - unlimited: z.boolean().optional(), - reset: z - .object({ - interval: z.enum(ResetInterval), - interval_count: z.number().optional(), - }) - .optional(), - customer_id: z.string(), -}); - export const CreateBalanceForValidation = CreateBalanceSchema.extend({ - feature: FeatureSchema, + feature: FeatureSchema, }).refine((data) => { - if (!data.feature) { - return false; - } + if (!data.feature) { + return false; + } - if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval) { - return false; - } - } + if (data.feature.type === FeatureType.Boolean) { + if (data.granted_balance || data.unlimited || data.reset?.interval) { + return false; + } + } - if (data.feature.type === FeatureType.Metered) { - if (!data.granted_balance && !data.unlimited) { - return false; - } - if (data.granted_balance && data.unlimited) { - return false; - } - if (data.unlimited && data.reset?.interval) { - return false; - } - } + if (data.feature.type === FeatureType.Metered) { + if (!data.granted_balance && !data.unlimited) { + return false; + } + if (data.granted_balance && data.unlimited) { + return false; + } + if (data.unlimited && data.reset?.interval) { + return false; + } + } - return true; + return true; }); export const validateBooleanEntitlementConflict = async ({ - ctx, - feature, - internalCustomerId, + ctx, + feature, + internalCustomerId, }: { - ctx: AutumnContext; - feature: Feature; - internalCustomerId: string; + ctx: AutumnContext; + feature: Feature; + internalCustomerId: string; }) => { - if (feature.type === FeatureType.Boolean) { - const existingBooleanEntitlement = await CusEntService.getByFeature({ - db: ctx.db, - internalFeatureId: feature.internal_id!, - internalCustomerId, - }); + if (feature.type === FeatureType.Boolean) { + const existingBooleanEntitlement = await CusEntService.getByFeature({ + db: ctx.db, + internalFeatureId: feature.internal_id!, + internalCustomerId, + }); - if (existingBooleanEntitlement.length > 0) { - throw new RecaseError({ - message: `A boolean entitlement ${feature.id} already exists for customer ${internalCustomerId}`, - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - } + if (existingBooleanEntitlement.length > 0) { + throw new RecaseError({ + message: `A boolean entitlement ${feature.id} already exists for customer ${internalCustomerId}`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } }; diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 8dfbf2339..e259f0768 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,6 +1,5 @@ -import { - CustomerNotFoundError, FeatureNotFoundError -} from "@shared/index"; +import { CreateBalanceSchema } from "@autumn/shared"; +import { CustomerNotFoundError, FeatureNotFoundError } from "@shared/index"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; @@ -8,78 +7,77 @@ import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCac import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion"; import { - CreateBalanceForValidation, - CreateBalanceSchema, - validateBooleanEntitlementConflict, + CreateBalanceForValidation, + validateBooleanEntitlementConflict, } from "../createNewBalance/validationUtilsForNewBalances"; export const handleCreateBalance = createRoute({ - body: CreateBalanceSchema, - handler: async (c) => { - const ctx = c.get("ctx"); - const { feature_id, customer_id, granted_balance, unlimited, reset } = - c.req.valid("json"); + body: CreateBalanceSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { feature_id, customer_id, granted_balance, unlimited, reset } = + c.req.valid("json"); - const feature = ctx.features.find((f) => f.id === feature_id); - if (!feature) { - throw new FeatureNotFoundError({ featureId: feature_id }); - } + const feature = ctx.features.find((f) => f.id === feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } - const fullCustomer = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customer_id, - orgId: ctx.org.id, - env: ctx.env, - }); + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customer_id, + orgId: ctx.org.id, + env: ctx.env, + }); - if (!fullCustomer) { - throw new CustomerNotFoundError({ customerId: customer_id }); - } + if (!fullCustomer) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } - // This should throw an error if the data is invalid - CreateBalanceForValidation.parse({ - feature: feature, - granted_balance, - unlimited, - reset, - customer_id, - feature_id, - }); + // This should throw an error if the data is invalid + CreateBalanceForValidation.parse({ + feature: feature, + granted_balance, + unlimited, + reset, + customer_id, + feature_id, + }); - await validateBooleanEntitlementConflict({ - ctx, - feature, - internalCustomerId: fullCustomer.internal_id, - }) + await validateBooleanEntitlementConflict({ + ctx, + feature, + internalCustomerId: fullCustomer.internal_id, + }); - const { newEntitlement, newCustomerEntitlement } = await prepareNewBalanceForInsertion({ - ctx, - feature, - granted_balance, - unlimited, - reset, - fullCus: fullCustomer, - feature_id, - }); + const { newEntitlement, newCustomerEntitlement } = + await prepareNewBalanceForInsertion({ + ctx, + feature, + granted_balance, + unlimited, + reset, + fullCus: fullCustomer, + feature_id, + }); + await EntitlementService.insert({ + db: ctx.db, + data: [newEntitlement], + }); - await EntitlementService.insert({ - db: ctx.db, - data: [newEntitlement], - }); + await CusEntService.insert({ + db: ctx.db, + data: [newCustomerEntitlement], + }); - await CusEntService.insert({ - db: ctx.db, - data: [newCustomerEntitlement], - }); + await deleteCachedApiCustomer({ + orgId: ctx.org.id, + env: ctx.env, + customerId: customer_id, + source: "handleCreateBalance", + }); - await deleteCachedApiCustomer({ - orgId: ctx.org.id, - env: ctx.env, - customerId: customer_id, - source: "handleCreateBalance", - }); - - return c.json({ success: true }); - }, + return c.json({ success: true }); + }, }); diff --git a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts index ff6187055..357113b00 100644 --- a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts +++ b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts @@ -1,12 +1,12 @@ import { CusProductStatus, cusEntToIncludedUsage, - cusProductsToCusEnts, ErrCode, type Feature, FeatureNotFoundError, FeatureType, type FullCustomerEntitlement, + fullCustomerToCustomerEntitlements, orgToInStatuses, RecaseError, type SetUsageParams, @@ -69,8 +69,8 @@ export const getSetUsageDeductions = async ({ }); } - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, reverseOrder: org.config?.reverse_deduction_order, featureId: feature.id, inStatuses: orgToInStatuses({ org }), @@ -141,8 +141,8 @@ export const getSetUsageDeductions = async ({ // CALCULATE DEDUCTION // ========================================== - const deductionCusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const deductionCusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, reverseOrder: org.config?.reverse_deduction_order, featureId: deductionFeature.id, inStatuses: orgToInStatuses({ org }), diff --git a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts b/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts index 07a4c16dc..7bdb36044 100644 --- a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts +++ b/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts @@ -1,6 +1,6 @@ import { - cusProductsToCusEnts, type FullCustomer, + fullCustomerToCustomerEntitlements, type PgDeductionUpdate, } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; @@ -21,8 +21,8 @@ export const rollbackDeduction = async ({ `[ROLLBACK] Starting rollback for ${Object.keys(updates).length} entitlements`, ); - const cusEnts = cusProductsToCusEnts({ - cusProducts: oldFullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: oldFullCus, }); // For each updated entitlement, restore to original state from oldFullCus diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 2dc477cae..096a54ec5 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -6,9 +6,10 @@ import type { import { CusProductStatus, cusEntToCusPrice, - cusProductsToCusEnts, + cusEntToStartingBalance, FeatureUsageType, type FullCustomer, + fullCustomerToCustomerEntitlements, getMaxOverage, getRelevantFeatures, getStartingBalance, @@ -112,8 +113,8 @@ export const deductFromCusEnts = async ({ featureId: feature.id, }); - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, featureIds: relevantFeatures.map((f) => f.id), reverseOrder: org.config?.reverse_deduction_order, entity: fullCus.entity, @@ -154,14 +155,7 @@ export const deductFromCusEnts = async ({ FeatureUsageType.Continuous && nullish(cusPrice); // NOTE: WE USE STARTING BALANCE BECAUSE ADJUSTMENT IS ADDED IN performDeduction.sql function - const resetBalance = getStartingBalance({ - entitlement: ce.entitlement, - options: - getEntOptions(ce.customer_product.options, ce.entitlement) || - undefined, - relatedPrice: cusPrice?.price, - productQuantity: ce.customer_product.quantity, - }); + const startingBalance = cusEntToStartingBalance({ cusEnt: ce }); return { customer_entitlement_id: ce.id, @@ -172,7 +166,7 @@ export const deductFromCusEnts = async ({ (isFreeAllocated && overageBehaviour !== "reject"), min_balance: notNullish(maxOverage) ? -maxOverage : undefined, add_to_adjustment: addToAdjustment, - max_balance: resetBalance, + max_balance: startingBalance, }; }); diff --git a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts index 9314bc329..42d08b903 100644 --- a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts +++ b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts @@ -1,8 +1,8 @@ import type { FullCustomer, SortCusEntParams } from "@autumn/shared"; import { cusEntsToAllowance, - cusProductsToCusEnts, FeatureNotFoundError, + fullCustomerToCustomerEntitlements, InternalError, isEntityScopedCusEnt, notNullish, @@ -34,8 +34,8 @@ export const updateGrantedBalance = async ({ throw new FeatureNotFoundError({ featureId }); } - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, featureIds: [featureId], entity: fullCus.entity, inStatuses: orgToInStatuses({ org: ctx.org }), diff --git a/server/src/internal/balances/utils/sync/syncItem.ts b/server/src/internal/balances/utils/sync/syncItem.ts index ec1daf7fc..ab790078a 100644 --- a/server/src/internal/balances/utils/sync/syncItem.ts +++ b/server/src/internal/balances/utils/sync/syncItem.ts @@ -8,9 +8,9 @@ import { type ApiCustomer, type ApiEntityV1, cusEntToPrepaidQuantity, - cusProductsToCusEnts, filterEntityLevelCusProducts, filterOutEntitiesFromCusProducts, + fullCustomerToCustomerEntitlements, getRelevantFeatures, orgToInStatuses, type SortCusEntParams, @@ -213,8 +213,8 @@ export const syncItem = async ({ const redisBalance = redisEntity.balances?.[relevantFeature.id]; if (!redisBalance) continue; - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, featureId: relevantFeature.id, reverseOrder: org.config?.reverse_deduction_order, entity: fullCus.entity, diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 151ad1250..05ec0dfcc 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -34,7 +34,6 @@ export class CusService { withSubs = false, allowNotFound = false, withEvents = false, - withExtraCustomerEntitlements = false, }: { db: DrizzleCli; idOrInternalId: string; @@ -47,7 +46,6 @@ export class CusService { withSubs?: boolean; allowNotFound?: boolean; withEvents?: boolean; - withExtraCustomerEntitlements?: boolean; }): Promise { const includeInvoices = expand?.includes(CusExpand.Invoices) || false; const withTrialsUsed = expand?.includes(CusExpand.TrialsUsed) || false; @@ -74,7 +72,6 @@ export class CusService { withTrialsUsed, withSubs, withEvents, - withExtraCustomerEntitlements, entityId, ); diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts index 979e1c62b..83e33ac07 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts @@ -2,14 +2,17 @@ import { type BillingInterval, BillingType, CusProductStatus, - cusProductsToCusEnts, cusProductsToCusPrices, + cusProductToCusEnts, + cusProductToEnts, type FullCusProduct, + fullCustomerToCustomerEntitlements, intervalsDifferent, type UsagePriceConfig, } from "@autumn/shared"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { Logger } from "@/external/logtail/logtailUtils"; import { subToAutumnInterval } from "@/external/stripe/utils.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; @@ -47,14 +50,17 @@ export const getUsageInvoiceItems = async ({ cusProducts: [cusProduct], }); // const ents = cusProductToEnts({ cusProduct }); - const cusEnts = cusProductsToCusEnts({ - cusProducts: [cusProduct], - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.Expired, - CusProductStatus.PastDue, - ], - }); + // const cusEnts = fullCustomerToCustomerEntitlements({ + // fullCustomer: { + // customer_products: [cusProduct], + // }, + // inStatuses: [ + // CusProductStatus.Active, + // CusProductStatus.Expired, + // CusProductStatus.PastDue, + // ], + // }); + const cusEnts = cusProductToCusEnts({ cusProduct }); const invoiceItems: any[] = []; const cusEntIds: string[] = []; @@ -118,7 +124,6 @@ export const createUsageInvoiceItems = async ({ db, attachParams, cusProduct, - // stripeSubs, sub, invoiceId, logger, @@ -128,10 +133,9 @@ export const createUsageInvoiceItems = async ({ db: DrizzleCli; attachParams: AttachParams; cusProduct: FullCusProduct; - // stripeSubs: Stripe.Subscription[]; sub: Stripe.Subscription; invoiceId?: string; - logger: any; + logger: Logger; interval?: BillingInterval; intervalCount?: number; }) => { diff --git a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts index a1fe84afb..ada373a78 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts @@ -1,12 +1,11 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; import { - CustomerPrice, - FullCustomerEntitlement, - FullCustomerPrice, + type CustomerPrice, + customerPrices, + type FullCustomerEntitlement, + type FullCustomerPrice, } from "@autumn/shared"; -import { customerPrices } from "@autumn/shared"; - import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; export class CusPriceService { static async getRelatedToCusEnt({ @@ -16,6 +15,10 @@ export class CusPriceService { db: DrizzleCli; cusEnt: FullCustomerEntitlement; }) { + if (!cusEnt.customer_product_id) { + return null; + } + const customerPricesData = await db.query.customerPrices.findMany({ where: eq(customerPrices.customer_product_id, cusEnt.customer_product_id), with: { @@ -37,7 +40,7 @@ export class CusPriceService { db: DrizzleCli; data: CustomerPrice[] | CustomerPrice; }) { - if (Array.isArray(data) && data.length == 0) { + if (Array.isArray(data) && data.length === 0) { return; } diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index fb86c86b8..f4572be20 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -111,7 +111,6 @@ export const getCachedApiCustomer = async ({ env: env as AppEnv, withEntities: true, withSubs: true, - withExtraCustomerEntitlements: true, expand: [CusExpand.Invoices], }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index 937a25d3e..f881caf9d 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -1,9 +1,10 @@ import { type ApiBalance, type CusFeatureLegacyData, - cusProductsToCusEnts, type FullCusEntWithOptionalProduct, + type FullCusEntWithOptionalProduct, type FullCustomer, - orgToInStatuses + fullCustomerToCustomerEntitlements, + orgToInStatuses, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -18,8 +19,8 @@ export const getApiBalances = async ({ }) => { const { org } = ctx; - const cusEntsWithCusProduct = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEntsWithCusProduct = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, inStatuses: orgToInStatuses({ org }), entity: fullCus.entity, }); diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index 45a9a1867..cd7d58879 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -178,6 +178,7 @@ export const newCusToFullCus = ({ newCus }: { newCus: Customer }) => { const fullCus: FullCustomer = { ...newCus, customer_products: [], + extra_customer_entitlements: [], entities: [], }; diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 53764d323..46bd33b96 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -61,7 +61,6 @@ export const getOrCreateCustomer = async ({ expand, allowNotFound: true, withSubs: true, - withExtraCustomerEntitlements: true, }); } @@ -91,7 +90,6 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, - withExtraCustomerEntitlements: true, }); } catch (error: any) { if (error?.data?.code === "23505" && customerId) { @@ -105,7 +103,6 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, - withExtraCustomerEntitlements: true, }); } else { throw error; @@ -131,7 +128,6 @@ export const getOrCreateCustomer = async ({ entityId, expand, withSubs: true, - withExtraCustomerEntitlements: true, }); } } diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 9ae6205d8..4c7edf47a 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -176,11 +176,7 @@ const buildSubscriptionsCTE = ( `; }; -const buildExtraEntitlementsCTE = (withExtraEntitlements: boolean) => { - if (!withExtraEntitlements) { - return sql``; - } - +const buildExtraEntitlementsCTE = () => { return sql` extra_customer_entitlements AS ( SELECT @@ -258,7 +254,6 @@ export const getFullCusQuery = ( withTrialsUsed: boolean, withSubs: boolean, withEvents: boolean, - withExtraEntitlements: boolean, entityId?: string, ) => { const sqlChunks: SQL[] = []; @@ -307,10 +302,8 @@ export const getFullCusQuery = ( } // Conditionally add extra entitlements CTE - if (withExtraEntitlements) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildExtraEntitlementsCTE(withExtraEntitlements)); - } + sqlChunks.push(sql`, `); + sqlChunks.push(buildExtraEntitlementsCTE()); // Conditionally add invoices CTE if (includeInvoices) { @@ -379,10 +372,8 @@ export const getFullCusQuery = ( } // Add extra entitlements to SELECT if withExtraEntitlements is true - if (withExtraEntitlements) { - selectFieldsChunks.push(sql`, - (SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`); - } + selectFieldsChunks.push(sql`, + (SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`); if (includeInvoices) { selectFieldsChunks.push(sql`, diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 819832cc5..68e480e9a 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -161,15 +161,15 @@ cusRouter.get( const product = cusProduct ? cusProductToProduct({ cusProduct }) : await ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: product_id, - version: - version && Number.isInteger(parseInt(version)) - ? parseInt(version) - : undefined, - }); + db, + orgId: org.id, + env, + idOrInternalId: product_id, + version: + version && Number.isInteger(parseInt(version)) + ? parseInt(version) + : undefined, + }); const productV2 = mapToProductV2({ product: product!, features }); @@ -201,7 +201,6 @@ export const handleGetCustomerInternal = createRoute({ env, idOrInternalId: customer_id, withEntities: true, - withExtraCustomerEntitlements: true, expand: [CusExpand.Invoices], inStatuses: [ CusProductStatus.Active, diff --git a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts index 4a5c2a358..8ebf46f79 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts @@ -4,6 +4,7 @@ import { type FullCustomerPrice, getFeatureInvoiceDescription, type InsertReplaceable, + InternalError, OnDecrease, OnIncrease, type Organization, @@ -13,6 +14,7 @@ import { import { Decimal } from "decimal.js"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js"; import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; @@ -145,10 +147,16 @@ export const handleProratedDowngrade = async ({ subItem: Stripe.SubscriptionItem; newBalance: number; prevBalance: number; - logger: any; + logger: Logger; }) => { logger.info(`Handling quantity decrease`); + if (!cusEnt.customer_product) { + throw new InternalError({ + message: `[handleProratedDowngrade] Customer entitlement has no customer product: ${cusEnt.id}`, + }); + } + const { overage: prevOverage, usage: prevUsage } = getUsageFromBalance({ ent: cusEnt.entitlement, price: cusPrice.price, diff --git a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts index 50e15ed10..8e190ad9e 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts @@ -2,6 +2,7 @@ import { type Entitlement, type FullCusEntWithFullCusProduct, type FullCustomerPrice, + InternalError, OnIncrease, type Organization, type Price, @@ -117,6 +118,12 @@ export const handleProratedUpgrade = async ({ }) => { logger.info(`Handling quantity increase`); + if (!cusEnt.customer_product) { + throw new InternalError({ + message: `[handleProratedUpgrade] Customer entitlement has no customer product: ${cusEnt.id}`, + }); + } + // 1. Get num reps to use const reps = getReps({ cusEnt, diff --git a/server/src/utils/importUtils/updateUsages.ts b/server/src/utils/importUtils/updateUsages.ts index 626294cbd..6885e730a 100644 --- a/server/src/utils/importUtils/updateUsages.ts +++ b/server/src/utils/importUtils/updateUsages.ts @@ -1,4 +1,7 @@ -import { cusProductsToCusEnts, type FullCustomer } from "@autumn/shared"; +import { + type FullCustomer, + fullCustomerToCustomerEntitlements, +} from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; @@ -14,8 +17,8 @@ export const updateUsages = async ({ fullCus: FullCustomer; db: DrizzleCli; }) => { - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, inStatuses: RELEVANT_STATUSES, featureId, }); diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 002ba9f6b..665d57024 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -4,6 +4,7 @@ import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { CusService } from "@/internal/customers/CusService"; import { constructFeatureItem, constructPrepaidItem, @@ -58,10 +59,20 @@ describe(`${chalk.yellowBright("temp: invoice payment failed for one off credits prefix: testCase, }); - await autumnV1.attach({ + const res = await autumnV1.balances.create({ customer_id: customerId, - product_id: pro.id, + feature_id: TestFeature.Messages, + granted_balance: 100, }); + + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + console.log(fullCustomer); }); test("should handle invoice payment failed for one off credits", async () => {}); diff --git a/server/tests/balances/check/basic/check-loose1.test.ts b/server/tests/balances/check/basic/check-loose1.test.ts index 6d917748f..e93a54a0d 100644 --- a/server/tests/balances/check/basic/check-loose1.test.ts +++ b/server/tests/balances/check/basic/check-loose1.test.ts @@ -1,10 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - type CheckResponseV2, - EntInterval, - SuccessCode, -} from "@autumn/shared"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -49,7 +44,7 @@ describe(`${chalk.yellowBright("check-loose1: basic loose entitlement check")}`, await autumnV1.balances.create({ customer_id: customerId, feature_id: TestFeature.Messages, - granted_balance: "500", + granted_balance: 500, }); }); diff --git a/server/tests/balances/check/basic/check6.test.ts b/server/tests/balances/check/basic/check6.test.ts index 886250e0c..c6384cc1a 100644 --- a/server/tests/balances/check/basic/check6.test.ts +++ b/server/tests/balances/check/basic/check6.test.ts @@ -73,7 +73,6 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal })) as unknown as CheckResponseV2; const expectedLifetimeBreadown: ApiBalanceBreakdown = { - id: expect.any(String), plan_id: proProd.id, granted_balance: 1000, purchased_balance: 0, diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts new file mode 100644 index 000000000..a19907d0a --- /dev/null +++ b/shared/api/balances/create/createBalanceParams.ts @@ -0,0 +1,17 @@ +import { ResetInterval } from "@autumn/shared"; +import { z } from "zod/v4"; + +export const CreateBalanceSchema = z.object({ + feature_id: z.string(), + granted_balance: z.number().optional(), + unlimited: z.boolean().optional(), + reset: z + .object({ + interval: z.enum(ResetInterval), + interval_count: z.number().optional(), + }) + .optional(), + customer_id: z.string(), +}); + +export type CreateBalanceParams = z.infer; diff --git a/shared/api/models.ts b/shared/api/models.ts index a868cd574..5b15fd7a8 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -63,6 +63,7 @@ export * from "./balances/check/checkResponseV2.js"; export * from "./balances/check/enums/CheckExpand.js"; export * from "./balances/check/prevVersions/CheckResponseV0.js"; export * from "./balances/check/prevVersions/CheckResponseV1.js"; +export * from "./balances/create/createBalanceParams.js"; export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; diff --git a/shared/models/cusModels/fullCusModel.ts b/shared/models/cusModels/fullCusModel.ts index f13966557..0baca0946 100644 --- a/shared/models/cusModels/fullCusModel.ts +++ b/shared/models/cusModels/fullCusModel.ts @@ -1,10 +1,10 @@ import { ProductSchema } from "@models/productModels/productModels.js"; import { z } from "zod/v4"; +import type { FullCustomerEntitlement } from "../cusProductModels/cusEntModels/cusEntModels.js"; import { CusProductSchema, type FullCusProduct, } from "../cusProductModels/cusProductModels.js"; -import type { FullCustomerEntitlement } from "../cusProductModels/cusEntModels/cusEntModels.js"; import type { Event } from "../eventModels/eventTable.js"; import type { Subscription } from "../subModels/subModels.js"; import { type Customer, CustomerSchema } from "./cusModels.js"; @@ -23,7 +23,7 @@ export type FullCustomer = Customer & { invoices?: Invoice[]; subscriptions?: Subscription[]; events?: Event[]; - extra_customer_entitlements?: FullCustomerEntitlement[]; + extra_customer_entitlements: FullCustomerEntitlement[]; }; export const CustomerWithProductsSchema = CustomerSchema.extend({ diff --git a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts index bd396603b..736cfa160 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts @@ -10,7 +10,7 @@ export const FullCusEntWithProductSchema = FullCustomerEntitlementSchema.extend( export const FullCusEntWithFullCusProductSchema = FullCustomerEntitlementSchema.extend({ - customer_product: FullCusProductSchema, + customer_product: FullCusProductSchema.nullable(), }); export const FullCusEntWithOptionalProductSchema = diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts new file mode 100644 index 000000000..cb6a742f8 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts @@ -0,0 +1,26 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { + cusEntToCusPrice, + entToOptions, +} from "../../productUtils/convertUtils"; +import { getStartingBalance } from "../getStartingBalance"; + +export const cusEntToStartingBalance = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + const price = cusPrice?.price; + const options = entToOptions({ + ent: cusEnt.entitlement, + options: cusEnt.customer_product?.options ?? [], + }); + + return getStartingBalance({ + entitlement: cusEnt.entitlement, + options, + relatedPrice: price, + productQuantity: cusEnt.customer_product?.quantity ?? 1, + }); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts index 58416d76b..7c36a2caf 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts @@ -1,9 +1,6 @@ import { Decimal } from "decimal.js"; import { sumValues } from "../../../index.js"; -import type { - FullCusEntWithFullCusProduct, - FullCusEntWithOptionalProduct, -} from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../../productUtils/priceUtils.js"; @@ -11,18 +8,20 @@ import { isPrepaidPrice } from "../../productUtils/priceUtils.js"; export const cusEntToPrepaidQuantity = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0; + if (!cusEnt.customer_product) return 0; + // 3. Get quantity - const options = cusEnt.customer_product?.options?.find( - (option) => - option.internal_feature_id === cusEnt.entitlement.internal_feature_id, - ); + const options = cusProductToFeatureOptions({ + cusProduct: cusEnt.customer_product, + feature: cusEnt.entitlement.feature, + }); if (!options) return 0; @@ -36,7 +35,7 @@ export const cusEntToPrepaidQuantity = ({ export const cusEntsToPrepaidQuantity = ({ cusEnts, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; }) => { return sumValues( cusEnts.map((cusEnt) => cusEntToPrepaidQuantity({ cusEnt })), diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index ef6680d26..0d369cbec 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -1,8 +1,5 @@ -import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; -import type { SortCusEntParams } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; -import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; import type { CusProduct, FullCusProduct, @@ -10,10 +7,7 @@ import type { import { ProcessorType } from "../../models/genModels/genEnums.js"; import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; import type { FullProduct } from "../../models/productModels/productModels.js"; -import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; -import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; import { getBillingType } from "../productUtils/priceUtils.js"; -import { notNullish } from "../utils.js"; export const cusProductsToPrices = ({ cusProducts, @@ -52,79 +46,6 @@ export const cusProductsToCusPrices = ({ return cusPrices; }; -export const cusProductsToCusEnts = ({ - cusProducts, - inStatuses = [CusProductStatus.Active, CusProductStatus.PastDue], - reverseOrder = false, - featureId, - featureIds, - entity, - sortParams, -}: { - cusProducts: FullCusProduct[]; - inStatuses?: CusProductStatus[]; - reverseOrder?: boolean; - featureId?: string; - featureIds?: string[]; - entity?: Entity; - sortParams?: SortCusEntParams; -}) => { - let cusEnts: FullCusEntWithFullCusProduct[] = []; - - for (const cusProduct of cusProducts) { - if (!inStatuses.includes(cusProduct.status)) continue; - - cusEnts.push( - ...cusProduct.customer_entitlements.map((cusEnt) => ({ - ...cusEnt, - customer_product: cusProduct, - })), - ); - } - - if (featureId) { - cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.feature.id === featureId, - ); - } - - if (featureIds) { - cusEnts = cusEnts.filter((cusEnt) => - featureIds.includes(cusEnt.entitlement.feature.id), - ); - } - - if (entity) { - cusEnts = cusEnts.filter((cusEnt) => - cusEntMatchesEntity({ - cusEnt: cusEnt, - entity, - }), - ); - } - - sortCusEntsForDeduction({ - cusEnts, - reverseOrder, - entityId: entity?.id, - // sortParams, - }); - - if (sortParams?.cusEntIds && sortParams.cusEntIds.length > 0) { - cusEnts = cusEnts.filter((cusEnt) => - sortParams.cusEntIds?.includes(cusEnt.id), - ); - } - - if (notNullish(sortParams?.interval)) { - cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.interval === sortParams.interval, - ); - } - - return cusEnts as FullCusEntWithFullCusProduct[]; -}; - export const cusProductToPrices = ({ cusProduct, billingType, diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToCusEnts.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToCusEnts.ts new file mode 100644 index 000000000..888300009 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToCusEnts.ts @@ -0,0 +1,13 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js"; + +export const cusProductToCusEnts = ({ + cusProduct, +}: { + cusProduct: FullCusProduct; +}): FullCusEntWithFullCusProduct[] => { + return cusProduct.customer_entitlements.map((ce) => ({ + ...ce, + customer_product: cusProduct, + })); +}; diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts new file mode 100644 index 000000000..18718bd28 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts @@ -0,0 +1,25 @@ +import type { + FeatureOptions, + FullCusProduct, +} from "../../../models/cusProductModels/cusProductModels.js"; +import type { Feature } from "../../../models/featureModels/featureModels.js"; + +/** + * Get the feature options for a cus product + * @param cusProduct - The cus product to get the feature options for + * @param feature - The feature to get the feature options for + * @returns The feature options + */ +export const cusProductToFeatureOptions = ({ + cusProduct, + feature, +}: { + cusProduct?: FullCusProduct; + feature: Feature; +}): FeatureOptions | undefined => { + return cusProduct?.options.find( + (option) => + option.internal_feature_id === feature.internal_id || + option.feature_id === feature.id, + ); +}; diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts new file mode 100644 index 000000000..a11512c41 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts @@ -0,0 +1,34 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js"; +import { sortCusEntsForDeduction } from "../../cusEntUtils/sortCusEntsForDeduction.js"; + +export const cusProductsToCusEnts = ({ + cusProducts, + featureId, +}: { + cusProducts: FullCusProduct[]; + featureId?: string; +}) => { + let cusEnts: FullCusEntWithFullCusProduct[] = []; + + for (const cusProduct of cusProducts) { + cusEnts.push( + ...cusProduct.customer_entitlements.map((cusEnt) => ({ + ...cusEnt, + customer_product: cusProduct, + })), + ); + } + + if (featureId) { + cusEnts = cusEnts.filter( + (cusEnt) => cusEnt.entitlement.feature.id === featureId, + ); + } + + sortCusEntsForDeduction({ + cusEnts, + }); + + return cusEnts as FullCusEntWithFullCusProduct[]; +}; diff --git a/shared/utils/cusProductUtils/cusProductUtils.ts b/shared/utils/cusProductUtils/cusProductUtils.ts index ef7b2ca70..db5cb3a29 100644 --- a/shared/utils/cusProductUtils/cusProductUtils.ts +++ b/shared/utils/cusProductUtils/cusProductUtils.ts @@ -1,4 +1,4 @@ -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import { notNullish } from "../utils.js"; export const getTotalCusProdQuantity = ({ diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts new file mode 100644 index 000000000..ba3070e07 --- /dev/null +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts @@ -0,0 +1,87 @@ +import type { Entity } from "../../../models/cusModels/entityModels/entityModels.js"; +import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js"; +import type { SortCusEntParams } from "../../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums.js"; +import { cusEntMatchesEntity } from "../../cusEntUtils/filterCusEntUtils.js"; +import { sortCusEntsForDeduction } from "../../cusEntUtils/sortCusEntsForDeduction.js"; +import { notNullish } from "../../utils.js"; + +export const fullCustomerToCustomerEntitlements = ({ + fullCustomer, + inStatuses = [CusProductStatus.Active, CusProductStatus.PastDue], + reverseOrder = false, + featureId, + featureIds, + entity, + sortParams, +}: { + fullCustomer: FullCustomer; + inStatuses?: CusProductStatus[]; + reverseOrder?: boolean; + featureId?: string; + featureIds?: string[]; + entity?: Entity; + sortParams?: SortCusEntParams; +}) => { + const cusProducts = fullCustomer.customer_products; + let cusEnts: FullCusEntWithFullCusProduct[] = []; + + for (const cusProduct of cusProducts) { + if (!inStatuses.includes(cusProduct.status)) continue; + + cusEnts.push( + ...cusProduct.customer_entitlements.map((cusEnt) => ({ + ...cusEnt, + customer_product: cusProduct, + })), + ); + } + + for (const cusEnt of fullCustomer.extra_customer_entitlements) { + cusEnts.push({ + ...cusEnt, + customer_product: null, + }); + } + + if (featureId) { + cusEnts = cusEnts.filter( + (cusEnt) => cusEnt.entitlement.feature.id === featureId, + ); + } + + if (featureIds) { + cusEnts = cusEnts.filter((cusEnt) => + featureIds.includes(cusEnt.entitlement.feature.id), + ); + } + + if (entity) { + cusEnts = cusEnts.filter((cusEnt) => + cusEntMatchesEntity({ + cusEnt: cusEnt, + entity, + }), + ); + } + + sortCusEntsForDeduction({ + cusEnts, + reverseOrder, + entityId: entity?.id, + // sortParams, + }); + + if (sortParams?.cusEntId) { + cusEnts = cusEnts.filter((cusEnt) => cusEnt.id === sortParams.cusEntId); + } + + if (notNullish(sortParams?.interval)) { + cusEnts = cusEnts.filter( + (cusEnt) => cusEnt.entitlement.interval === sortParams.interval, + ); + } + + return cusEnts as FullCusEntWithFullCusProduct[]; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 21f230eb6..512bad95d 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -8,6 +8,7 @@ export * from "./cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToReset.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToRollovers.js"; export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; +export * from "./cusEntUtils/balanceUtils/cusEntToStartingBalance.js"; // Cus ent utils export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js"; export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js"; @@ -26,6 +27,9 @@ export * from "./cusEntUtils/getStartingBalance.js"; export * from "./cusEntUtils/sortCusEntsForDeduction.js"; // Cus product utils export * from "./cusProductUtils/classifyCusProduct.js"; +export * from "./cusProductUtils/convertCusProduct/cusProductsToCusEnts.js"; +export * from "./cusProductUtils/convertCusProduct/cusProductToCusEnts.js"; +export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/cusProductConstants.js"; export * from "./cusProductUtils/cusProductUtils.js"; @@ -35,6 +39,7 @@ export * from "./cusProductUtils/formatCusProductUtils.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; // Cus utils export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js"; +export * from "./cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.js"; export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js"; export * from "./expandUtils.js"; export * from "./featureUtils/apiFeatureToDbFeature.js"; diff --git a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts index 67f5b3be5..76280263c 100644 --- a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts +++ b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts @@ -68,7 +68,7 @@ export function useFeatureUsageBalance({ const isUnlimited = cusEnts.some((e) => e.unlimited); const usageType = cusEnts[0]?.entitlement?.feature?.config?.usage_type; const quantity = cusEnts.reduce( - (sum, e) => sum + (e.customer_product.quantity ?? 1), + (sum, e) => sum + (e.customer_product?.quantity ?? 1), 0, ); From b0920f5edad4c4e5be43db44f8ecd7ddaf4d4e5d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:10:55 +0000 Subject: [PATCH 09/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20boom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../honoMiddlewares/refreshCacheMiddleware.ts | 10 +- .../prepareNewBalanceForInsertion.ts | 0 .../validateCreateBalance.ts} | 64 +-- .../balances/handlers/handleCreateBalance.ts | 35 +- .../cusEnts/CusEntitlementService.ts | 68 ++- .../getApiBalance/apiBalanceUtils.ts | 11 +- .../getApiBalance/getApiBalance.ts | 26 +- .../getApiBalance/getApiBalances.ts | 15 +- .../src/internal/customers/getFullCusQuery.ts | 424 +++++++++--------- .../loose-1.test.ts} | 0 .../loose-2.test.ts} | 0 .../loose-3.test.ts} | 2 +- .../loose-4.test.ts} | 6 +- .../balances/track/loose/loose-basic.test.ts | 53 +++ .../balances/track/loose/loose-exact.test.ts | 49 ++ .../track/loose/loose-incremental.test.ts | 64 +++ .../balances/track/loose/loose-mixed.test.ts | 65 +++ .../track/loose/loose-overage-reject.test.ts | 48 ++ .../track/loose/loose-product-first.test.ts | 110 +++++ .../track/loose/loose-unlimited.test.ts | 47 ++ .../balances/track/loose/loose-zero.test.ts | 66 +++ .../balances/create/createBalanceParams.ts | 30 +- .../cusEntModels/cusEntWithProduct.ts | 8 - .../cusEntModels/resetCusEnt.ts | 2 +- .../balanceUtils/cusEntToPurchasedBalance.ts | 8 +- .../balanceUtils/cusEntsToBalance.ts | 4 +- .../cusEntsToAdjustment.ts | 4 +- .../grantedBalanceUtils/cusEntsToAllowance.ts | 6 +- .../utils/cusEntUtils/convertCusEntUtils.ts | 13 +- .../cusEntsToMaxPurchase.ts | 3 +- shared/utils/cusEntUtils/cusEntUtils.ts | 7 +- shared/utils/productUtils/convertUtils.ts | 4 +- 32 files changed, 915 insertions(+), 337 deletions(-) rename server/src/internal/balances/{createNewBalance => createBalance}/prepareNewBalanceForInsertion.ts (100%) rename server/src/internal/balances/{createNewBalance/validationUtilsForNewBalances.ts => createBalance/validateCreateBalance.ts} (55%) rename server/tests/balances/check/{basic/check-loose1.test.ts => loose/loose-1.test.ts} (100%) rename server/tests/balances/check/{basic/check-loose2.test.ts => loose/loose-2.test.ts} (100%) rename server/tests/balances/check/{basic/check-loose3.test.ts => loose/loose-3.test.ts} (99%) rename server/tests/balances/check/{basic/check-loose4.test.ts => loose/loose-4.test.ts} (93%) create mode 100644 server/tests/balances/track/loose/loose-basic.test.ts create mode 100644 server/tests/balances/track/loose/loose-exact.test.ts create mode 100644 server/tests/balances/track/loose/loose-incremental.test.ts create mode 100644 server/tests/balances/track/loose/loose-mixed.test.ts create mode 100644 server/tests/balances/track/loose/loose-overage-reject.test.ts create mode 100644 server/tests/balances/track/loose/loose-product-first.test.ts create mode 100644 server/tests/balances/track/loose/loose-unlimited.test.ts create mode 100644 server/tests/balances/track/loose/loose-zero.test.ts diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 4a4b5076c..9ef115425 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -42,7 +42,7 @@ const cusPrefixedUrls = [ * Note: /balances/update is NOT included because it updates Redis directly * to avoid race conditions with batched track syncs */ -const coreUrls = [ +const coreUrls: { method: string; url: string; source?: string }[] = [ { method: "POST", url: "/attach", @@ -51,6 +51,11 @@ const coreUrls = [ method: "POST", url: "/cancel", }, + { + method: "POST", + url: "/balances/create", + source: "handleCreateBalance", + } ]; /** @@ -115,8 +120,7 @@ export const refreshCacheMiddleware = async ( customerId: body.customer_id, orgId: org.id, env: env, - source: "refreshCacheMiddleware", - logger, + source: coreMatch.source || undefined, }); } } diff --git a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts similarity index 100% rename from server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts rename to server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts diff --git a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts similarity index 55% rename from server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts rename to server/src/internal/balances/createBalance/validateCreateBalance.ts index 2f989394c..49b639f43 100644 --- a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -1,44 +1,48 @@ -import { CreateBalanceSchema } from "@autumn/shared"; import { ErrCode, type Feature, - FeatureSchema, FeatureType, + type FullCustomer, RecaseError, - ResetInterval, + ValidateCreateBalanceParamsSchema, } from "@shared/index"; import { StatusCodes } from "http-status-codes"; -import z from "zod/v4"; +import type { z } from "zod/v4"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -export const CreateBalanceForValidation = CreateBalanceSchema.extend({ - feature: FeatureSchema, -}).refine((data) => { - if (!data.feature) { - return false; - } +export const validateCreateBalanceParams = async ({ + ctx, + feature, + internalCustomerId, + granted_balance, + unlimited, + reset, + fullCustomer, +}: { + ctx: AutumnContext; + feature: Feature; + internalCustomerId: string; + granted_balance: number | undefined; + unlimited: boolean | undefined; + reset: z.infer["reset"]; + fullCustomer: FullCustomer; +}) => { + ValidateCreateBalanceParamsSchema.parse({ + feature, + granted_balance, + unlimited, + reset, + customer_id: internalCustomerId, + feature_id: feature.id, + }); - if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval) { - return false; - } - } - - if (data.feature.type === FeatureType.Metered) { - if (!data.granted_balance && !data.unlimited) { - return false; - } - if (data.granted_balance && data.unlimited) { - return false; - } - if (data.unlimited && data.reset?.interval) { - return false; - } - } - - return true; -}); + await validateBooleanEntitlementConflict({ + ctx, + feature, + internalCustomerId: fullCustomer.internal_id, + }); +}; export const validateBooleanEntitlementConflict = async ({ ctx, diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index e259f0768..6cc66592e 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,15 +1,16 @@ -import { CreateBalanceSchema } from "@autumn/shared"; -import { CustomerNotFoundError, FeatureNotFoundError } from "@shared/index"; +import { + CreateBalanceSchema +} from "@autumn/shared"; +import { FeatureNotFoundError } from "@shared/index"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; -import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion"; +import { prepareNewBalanceForInsertion } from "../createBalance/prepareNewBalanceForInsertion"; import { - CreateBalanceForValidation, - validateBooleanEntitlementConflict, -} from "../createNewBalance/validationUtilsForNewBalances"; + validateCreateBalanceParams +} from "../createBalance/validateCreateBalance"; export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, @@ -22,7 +23,7 @@ export const handleCreateBalance = createRoute({ if (!feature) { throw new FeatureNotFoundError({ featureId: feature_id }); } - + 34; const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, @@ -30,24 +31,14 @@ export const handleCreateBalance = createRoute({ env: ctx.env, }); - if (!fullCustomer) { - throw new CustomerNotFoundError({ customerId: customer_id }); - } - - // This should throw an error if the data is invalid - CreateBalanceForValidation.parse({ - feature: feature, - granted_balance, - unlimited, - reset, - customer_id, - feature_id, - }); - - await validateBooleanEntitlementConflict({ + await validateCreateBalanceParams({ ctx, feature, internalCustomerId: fullCustomer.internal_id, + granted_balance, + unlimited, + reset, + fullCustomer, }); const { newEntitlement, newCustomerEntitlement } = diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 7afb5d38a..4fa060645 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -14,7 +14,7 @@ import { features, type ResetCusEnt, } from "@autumn/shared"; -import { and, eq, lt, sql } from "drizzle-orm"; +import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -149,6 +149,72 @@ export class CusEntService { return allResults as ResetCusEnt[]; } + static async getLooseResetPassed({ + db, + customDateUnix, + batchSize = 1000, + }: { + db: DrizzleCli; + customDateUnix?: number; + batchSize?: number; + }) { + const allResults: ResetCusEnt[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const data = await db + .select() + .from(customerEntitlements) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin( + features, + eq(entitlements.internal_feature_id, features.internal_id), + ) + .innerJoin( + customers, + eq(customerEntitlements.internal_customer_id, customers.internal_id), + ) + .where( + and( + isNull(customerEntitlements.customer_product_id), + lt( + customerEntitlements.next_reset_at, + customDateUnix ?? Date.now(), + ), + ), + ) + .limit(batchSize) + .offset(offset); + + if (data.length === 0) { + hasMore = false; + } else { + const mappedData = data.map((item) => ({ + ...item.customer_entitlements, + entitlement: { + ...item.entitlements, + feature: item.features, + }, + customer_product: null, + customer: item.customers, + replaceables: [], + rollovers: [], + })) as ResetCusEnt[]; + + allResults.push(...mappedData); + offset += batchSize; + hasMore = data.length === batchSize; + console.log(`Fetched ${allResults.length} entitlements to reset`); + } + } + + return allResults; + } + static async update({ db, id, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index f8c9c4a2d..096ca8f47 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -7,7 +7,6 @@ import { entIntvToResetIntv, type Feature, type FullCusEntWithFullCusProduct, - type FullCusEntWithOptionalProduct, getRolloverFields, isContUseFeature, notNullish, @@ -17,7 +16,7 @@ import { export const cusEntsToNextResetAt = ({ cusEnts, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; }) => { const result = cusEnts.reduce((acc, curr) => { if (curr.next_reset_at && curr.next_reset_at < acc) { @@ -35,7 +34,7 @@ export const cusEntsToReset = ({ cusEnts, feature, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; }): ApiBalanceReset | null => { // 1. If feature is allocated, null @@ -68,7 +67,7 @@ export const cusEntsToRollovers = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }): ApiBalanceRollover[] | undefined => { // If all cus ents no rollover, return undefined @@ -95,7 +94,7 @@ export const getBooleanApiBalance = ({ cusEnts, apiFeature, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; apiFeature?: ApiFeatureV1; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; @@ -141,7 +140,7 @@ export const getUnlimitedApiBalance = ({ cusEnts, }: { apiFeature?: ApiFeatureV1; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 6dfd832b5..245ae8f59 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -1,7 +1,6 @@ import type { ApiBalance, FullCusEntWithFullCusProduct, - FullCusEntWithOptionalProduct, FullCustomer, } from "@autumn/shared"; import { @@ -42,19 +41,14 @@ const cusEntsToBreakdown = ({ cusEnts, }: { ctx: RequestContext; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; fullCus: FullCustomer; -}): - | { - key: string; - breakdown: ApiBalanceBreakdown; - prepaidQuantity: number; - }[] - | undefined => { - const keyToCusEnts: Record< - string, - (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[] - > = {}; +}): { + key: string; + breakdown: ApiBalanceBreakdown; + prepaidQuantity: number; +}[] => { + const keyToCusEnts: Record = {}; for (const cusEnt of cusEnts) { const key = cusEntToKey({ cusEnt }); keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt]; @@ -81,8 +75,8 @@ const cusEntsToBreakdown = ({ includeBreakdown: false, }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts, feature }); - const planId = cusEnts[0].customer_product?.product.id ?? null; + const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); + const planId = cusEntsToPlanId({ cusEnts }); breakdown.push({ key, @@ -119,7 +113,7 @@ export const getApiBalance = ({ }: { ctx: RequestContext; fullCus: FullCustomer; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; feature: Feature; includeRollovers?: boolean; includeBreakdown?: boolean; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index f881caf9d..a4224aa75 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -1,7 +1,7 @@ import { type ApiBalance, type CusFeatureLegacyData, - type FullCusEntWithOptionalProduct, + type FullCusEntWithFullCusProduct, type FullCustomer, fullCustomerToCustomerEntitlements, orgToInStatuses, @@ -19,14 +19,19 @@ export const getApiBalances = async ({ }) => { const { org } = ctx; - const cusEntsWithCusProduct = fullCustomerToCustomerEntitlements({ + const allCusEntsFromFullCustomer = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, inStatuses: orgToInStatuses({ org }), entity: fullCus.entity, }); + // Filter out loose entitlements (customer_product is null) - they come from extra_customer_entitlements + const cusEntsWithCusProduct = allCusEntsFromFullCustomer.filter( + (ent) => ent.customer_product !== null, + ); + // Add extra entitlements (loose entitlements not tied to a product) - const extraEnts: FullCusEntWithOptionalProduct[] = ( + const extraEnts: FullCusEntWithFullCusProduct[] = ( fullCus.extra_customer_entitlements || [] ).map((ent) => ({ ...ent, @@ -34,12 +39,12 @@ export const getApiBalances = async ({ })); // Combine both sources - const allCusEnts: FullCusEntWithOptionalProduct[] = [ + const allCusEnts: FullCusEntWithFullCusProduct[] = [ ...cusEntsWithCusProduct, ...extraEnts, ]; - const featureToCusEnt: Record = {}; + const featureToCusEnt: Record = {}; for (const cusEnt of allCusEnts) { const featureId = cusEnt.entitlement.feature.id; featureToCusEnt[featureId] = [ diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 4c7edf47a..1853b81aa 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -1,21 +1,21 @@ import type { - AppEnv, - CusProductStatus, - ListCustomersV2Params, + AppEnv, + CusProductStatus, + ListCustomersV2Params, } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { - const withStatusFilter = () => { - return inStatuses - ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), - sql`, `, - )}])` - : sql``; - }; + const withStatusFilter = () => { + return inStatuses + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + }; - return sql` + return sql` customer_products_with_prices AS ( SELECT cp.*, @@ -83,11 +83,11 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { }; const buildEntitiesCTE = (withEntities: boolean) => { - if (!withEntities) { - return sql``; - } + if (!withEntities) { + return sql``; + } - return sql` + return sql` customer_entities AS ( SELECT COALESCE( @@ -105,11 +105,11 @@ const buildEntitiesCTE = (withEntities: boolean) => { }; const buildEntityCTE = (entityId?: string) => { - if (!entityId) { - return sql``; - } + if (!entityId) { + return sql``; + } - return sql` + return sql` entity_record AS ( SELECT * FROM entities e WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record) @@ -122,15 +122,15 @@ const buildEntityCTE = (entityId?: string) => { }; const buildTrialsUsedCTE = ( - withTrialsUsed: boolean, - orgId: string, - env: AppEnv, + withTrialsUsed: boolean, + orgId: string, + env: AppEnv, ) => { - if (!withTrialsUsed) { - return sql``; - } + if (!withTrialsUsed) { + return sql``; + } - return sql` + return sql` customer_trials_used AS ( SELECT COALESCE( @@ -153,14 +153,14 @@ const buildTrialsUsedCTE = ( }; const buildSubscriptionsCTE = ( - withSubs: boolean, - inStatuses?: CusProductStatus[], + withSubs: boolean, + inStatuses?: CusProductStatus[], ) => { - if (!withSubs) { - return sql``; - } + if (!withSubs) { + return sql``; + } - return sql` + return sql` customer_subscriptions AS ( SELECT COALESCE( @@ -177,7 +177,7 @@ const buildSubscriptionsCTE = ( }; const buildExtraEntitlementsCTE = () => { - return sql` + return sql` extra_customer_entitlements AS ( SELECT COALESCE( @@ -221,15 +221,15 @@ const buildExtraEntitlementsCTE = () => { }; const buildInvoicesCTE = (hasEntityCTE: boolean) => { - const entityFilter = hasEntityCTE - ? sql`AND ( + const entityFilter = hasEntityCTE + ? sql`AND ( NOT EXISTS (SELECT 1 FROM entity_record) OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) OR i.internal_entity_id IS NULL )` - : sql``; + : sql``; - return sql` + return sql` customer_invoices AS ( SELECT COALESCE( @@ -245,21 +245,21 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => { }; export const getFullCusQuery = ( - idOrInternalId: string, - orgId: string, - env: AppEnv, - inStatuses: CusProductStatus[], - includeInvoices: boolean, - withEntities: boolean, - withTrialsUsed: boolean, - withSubs: boolean, - withEvents: boolean, - entityId?: string, + idOrInternalId: string, + orgId: string, + env: AppEnv, + inStatuses: CusProductStatus[], + includeInvoices: boolean, + withEntities: boolean, + withTrialsUsed: boolean, + withSubs: boolean, + withEvents: boolean, + entityId?: string, ) => { - const sqlChunks: SQL[] = []; + const sqlChunks: SQL[] = []; - // Step 1: Get customer record - sqlChunks.push(sql` + // Step 1: Get customer record + sqlChunks.push(sql` WITH customer_record AS ( SELECT * FROM customers c WHERE ( @@ -272,49 +272,49 @@ export const getFullCusQuery = ( ) `); - // Step 2: Get entities - if (withEntities) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildEntitiesCTE(withEntities)); - } + // Step 2: Get entities + if (withEntities) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildEntitiesCTE(withEntities)); + } - // Step 3: Get entity - if (entityId) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildEntityCTE(entityId)); - } + // Step 3: Get entity + if (entityId) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildEntityCTE(entityId)); + } - // Add customer products CTE - sqlChunks.push(sql`, `); - // sqlChunks.push(buildCusProductsCTE(inStatuses)); - sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses)); + // Add customer products CTE + sqlChunks.push(sql`, `); + // sqlChunks.push(buildCusProductsCTE(inStatuses)); + sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses)); - // Conditionally add trials used CTE - if (withTrialsUsed) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env)); - } + // Conditionally add trials used CTE + if (withTrialsUsed) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env)); + } - // Conditionally add subscriptions CTE - if (withSubs) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses)); - } + // Conditionally add subscriptions CTE + if (withSubs) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses)); + } - // Conditionally add extra entitlements CTE - sqlChunks.push(sql`, `); - sqlChunks.push(buildExtraEntitlementsCTE()); + // Conditionally add extra entitlements CTE + sqlChunks.push(sql`, `); + sqlChunks.push(buildExtraEntitlementsCTE()); - // Conditionally add invoices CTE - if (includeInvoices) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildInvoicesCTE(!!entityId)); - } + // Conditionally add invoices CTE + if (includeInvoices) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildInvoicesCTE(!!entityId)); + } - // Conditionally add events CTE - if (withEvents) { - sqlChunks.push(sql`, `); - sqlChunks.push(sql` + // Conditionally add events CTE + if (withEvents) { + sqlChunks.push(sql`, `); + sqlChunks.push(sql` customer_events AS ( SELECT COALESCE( @@ -335,11 +335,11 @@ export const getFullCusQuery = ( AND e.set_usage = false ) `); - } + } - // Build final SELECT - const selectFieldsChunks: SQL[] = []; - selectFieldsChunks.push(sql` + // Build final SELECT + const selectFieldsChunks: SQL[] = []; + selectFieldsChunks.push(sql` cr.*, COALESCE( (SELECT json_agg(cpwp) FROM customer_products_with_prices cpwp), @@ -347,157 +347,155 @@ export const getFullCusQuery = ( ) AS customer_products `); - // Add entities to SELECT if withEntities is true - if (withEntities) { - selectFieldsChunks.push(sql`, + // Add entities to SELECT if withEntities is true + if (withEntities) { + selectFieldsChunks.push(sql`, (SELECT entities FROM customer_entities) AS entities`); - } + } - // Add entity to SELECT if entityId is provided - if (entityId) { - selectFieldsChunks.push(sql`, + // Add entity to SELECT if entityId is provided + if (entityId) { + selectFieldsChunks.push(sql`, (SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`); - } + } - // Add trials used to SELECT if withTrialsUsed is true - if (withTrialsUsed) { - selectFieldsChunks.push(sql`, + // Add trials used to SELECT if withTrialsUsed is true + if (withTrialsUsed) { + selectFieldsChunks.push(sql`, (SELECT trials_used FROM customer_trials_used) AS trials_used`); - } + } - // Add subscriptions to SELECT if withSubs is true - if (withSubs) { - selectFieldsChunks.push(sql`, + // Add subscriptions to SELECT if withSubs is true + if (withSubs) { + selectFieldsChunks.push(sql`, (SELECT subscriptions FROM customer_subscriptions) AS subscriptions`); - } + } - // Add extra entitlements to SELECT if withExtraEntitlements is true - selectFieldsChunks.push(sql`, + selectFieldsChunks.push(sql`, (SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`); - if (includeInvoices) { - selectFieldsChunks.push(sql`, + if (includeInvoices) { + selectFieldsChunks.push(sql`, (SELECT invoices FROM customer_invoices) AS invoices`); - } + } - if (withEvents) { - selectFieldsChunks.push(sql`, + if (withEvents) { + selectFieldsChunks.push(sql`, (SELECT events FROM customer_events) AS events`); - } + } - sqlChunks.push(sql` + sqlChunks.push(sql` SELECT ${sql.join(selectFieldsChunks, sql``)} FROM customer_record cr `); - return sql.join(sqlChunks, sql``); + return sql.join(sqlChunks, sql``); }; export const getPaginatedFullCusQuery = ({ - orgId, - env, - inStatuses, - includeInvoices, - withEntities, - withTrialsUsed, - withSubs, - limit = 10, - offset = 0, - withEvents = false, - entityId, - internalCustomerIds, - plans, - search, + orgId, + env, + inStatuses, + includeInvoices, + withEntities, + withTrialsUsed, + withSubs, + limit = 10, + offset = 0, + withEvents = false, + entityId, + internalCustomerIds, + plans, + search, }: { - orgId: string; - env: AppEnv; - inStatuses?: CusProductStatus[]; - includeInvoices: boolean; - withEntities: boolean; - withTrialsUsed: boolean; - withSubs: boolean; - limit: number; - offset: number; - withEvents?: boolean; - entityId?: string; - internalCustomerIds?: string[]; - plans?: ListCustomersV2Params["plans"]; - search?: string; + orgId: string; + env: AppEnv; + inStatuses?: CusProductStatus[]; + includeInvoices: boolean; + withEntities: boolean; + withTrialsUsed: boolean; + withSubs: boolean; + limit: number; + offset: number; + withEvents?: boolean; + entityId?: string; + internalCustomerIds?: string[]; + plans?: ListCustomersV2Params["plans"]; + search?: string; }) => { - const withStatusFilter = () => { - return inStatuses?.length - ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), - sql`, `, - )}])` - : sql``; - }; + const withStatusFilter = () => { + return inStatuses?.length + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + }; - const withCustomerProductFilter = () => { - const hasStatusFilter = inStatuses && inStatuses.length > 0; - const hasPlansFilter = plans && plans.length > 0; + const withCustomerProductFilter = () => { + const hasStatusFilter = inStatuses && inStatuses.length > 0; + const hasPlansFilter = plans && plans.length > 0; - if (!hasStatusFilter && !hasPlansFilter) return sql``; + if (!hasStatusFilter && !hasPlansFilter) return sql``; - const conditions: SQL[] = []; + const conditions: SQL[] = []; - if (hasStatusFilter) { - conditions.push( - sql`cp_filter.status = ANY(ARRAY[${sql.join( - inStatuses.map((s) => sql`${s}`), - sql`, `, - )}])`, - ); - } + if (hasStatusFilter) { + conditions.push( + sql`cp_filter.status = ANY(ARRAY[${sql.join( + inStatuses.map((s) => sql`${s}`), + sql`, `, + )}])`, + ); + } - if (hasPlansFilter) { - const planConditions = plans.map((plan) => { - if (plan.versions && plan.versions.length > 0) { - return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join( - plan.versions.map((v) => sql`${v}`), - sql`, `, - )}))`; - } - return sql`p_filter.id = ${plan.id}`; - }); + if (hasPlansFilter) { + const planConditions = plans.map((plan) => { + if (plan.versions && plan.versions.length > 0) { + return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join( + plan.versions.map((v) => sql`${v}`), + sql`, `, + )}))`; + } + return sql`p_filter.id = ${plan.id}`; + }); - conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`); - } + conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`); + } - const needsProductJoin = hasPlansFilter; + const needsProductJoin = hasPlansFilter; - return sql`AND EXISTS ( + return sql`AND EXISTS ( SELECT 1 FROM customer_products cp_filter ${needsProductJoin ? sql`JOIN products p_filter ON cp_filter.internal_product_id = p_filter.internal_id` : sql``} WHERE cp_filter.internal_customer_id = c.internal_id AND ${sql.join(conditions, sql` AND `)} )`; - }; + }; - const withSearchFilter = () => { - if (!search) return sql``; - const pattern = `%${search}%`; - return sql`AND ( + const withSearchFilter = () => { + if (!search) return sql``; + const pattern = `%${search}%`; + return sql`AND ( c.id ILIKE ${pattern} OR c.name ILIKE ${pattern} OR c.email ILIKE ${pattern} )`; - }; + }; - return sql` + return sql` WITH customer_records AS ( SELECT c.* FROM customers c WHERE c.org_id = ${orgId} AND c.env = ${env} - ${ - internalCustomerIds && internalCustomerIds.length > 0 - ? sql`AND c.internal_id IN (${sql.join( - internalCustomerIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`` - } + ${internalCustomerIds && internalCustomerIds.length > 0 + ? sql`AND c.internal_id IN (${sql.join( + internalCustomerIds.map((id) => sql`${id}`), + sql`, `, + )})` + : sql`` + } ${withCustomerProductFilter()} ${withSearchFilter()} ORDER BY c.created_at DESC @@ -576,9 +574,8 @@ export const getPaginatedFullCusQuery = ({ GROUP BY cpwp.internal_customer_id ) - ${ - withSubs - ? sql`, customer_subscriptions AS ( + ${withSubs + ? sql`, customer_subscriptions AS ( SELECT cpwp.internal_customer_id, COALESCE( @@ -589,12 +586,11 @@ export const getPaginatedFullCusQuery = ({ JOIN subscriptions s ON s.stripe_id = ANY(cpwp.subscription_ids) GROUP BY cpwp.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - withEntities - ? sql`, customer_entities AS ( + ${withEntities + ? sql`, customer_entities AS ( SELECT e.internal_customer_id, COALESCE( @@ -605,12 +601,11 @@ export const getPaginatedFullCusQuery = ({ WHERE e.internal_customer_id IN (SELECT internal_id FROM customer_records) GROUP BY e.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - includeInvoices - ? sql`, customer_invoices AS ( + ${includeInvoices + ? sql`, customer_invoices AS ( SELECT i.internal_customer_id, COALESCE( @@ -621,12 +616,11 @@ export const getPaginatedFullCusQuery = ({ WHERE i.internal_customer_id IN (SELECT internal_id FROM customer_records) GROUP BY i.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - withTrialsUsed - ? sql`, customer_trials_used AS ( + ${withTrialsUsed + ? sql`, customer_trials_used AS ( SELECT cp.internal_customer_id, json_agg(json_build_object( @@ -641,8 +635,8 @@ export const getPaginatedFullCusQuery = ({ AND cp.free_trial_id IS NOT NULL GROUP BY cp.internal_customer_id )` - : sql`` - } + : sql`` + } SELECT cr.*, diff --git a/server/tests/balances/check/basic/check-loose1.test.ts b/server/tests/balances/check/loose/loose-1.test.ts similarity index 100% rename from server/tests/balances/check/basic/check-loose1.test.ts rename to server/tests/balances/check/loose/loose-1.test.ts diff --git a/server/tests/balances/check/basic/check-loose2.test.ts b/server/tests/balances/check/loose/loose-2.test.ts similarity index 100% rename from server/tests/balances/check/basic/check-loose2.test.ts rename to server/tests/balances/check/loose/loose-2.test.ts diff --git a/server/tests/balances/check/basic/check-loose3.test.ts b/server/tests/balances/check/loose/loose-3.test.ts similarity index 99% rename from server/tests/balances/check/basic/check-loose3.test.ts rename to server/tests/balances/check/loose/loose-3.test.ts index 0bfdc8d9c..d63618dc2 100644 --- a/server/tests/balances/check/basic/check-loose3.test.ts +++ b/server/tests/balances/check/loose/loose-3.test.ts @@ -50,7 +50,7 @@ describe(`${chalk.yellowBright("check-loose3: mixed product + loose entitlement" await autumnV1.balances.create({ customer_id: customerId, feature_id: TestFeature.Messages, - granted_balance: "500", + granted_balance: 500, }); }); diff --git a/server/tests/balances/check/basic/check-loose4.test.ts b/server/tests/balances/check/loose/loose-4.test.ts similarity index 93% rename from server/tests/balances/check/basic/check-loose4.test.ts rename to server/tests/balances/check/loose/loose-4.test.ts index 91ee7c0a2..a0b5e84a9 100644 --- a/server/tests/balances/check/basic/check-loose4.test.ts +++ b/server/tests/balances/check/loose/loose-4.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, type CheckResponseV2, EntInterval, ResetInterval } from "@autumn/shared"; +import { ApiVersion, type CheckResponseV2, ResetInterval } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -44,9 +44,9 @@ describe(`${chalk.yellowBright("check-loose4: loose entitlement with reset inter await autumnV1.balances.create({ customer_id: customerId, feature_id: TestFeature.Action1, - granted_balance: "1000", + granted_balance: 1000, reset: { - interval: EntInterval.Month, + interval: ResetInterval.Month, interval_count: 1, }, }); diff --git a/server/tests/balances/track/loose/loose-basic.test.ts b/server/tests/balances/track/loose/loose-basic.test.ts new file mode 100644 index 000000000..242b25dd8 --- /dev/null +++ b/server/tests/balances/track/loose/loose-basic.test.ts @@ -0,0 +1,53 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-basic: basic track with loose entitlement")}`, () => { + const customerId = "loose-basic"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 100 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + }); + + test("should deduct from loose entitlement", async () => { + // Track 10 usage + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Wait for sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check balance + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.granted_balance).toBe(100); + expect(res.balance?.current_balance).toBe(90); + expect(res.balance?.usage).toBe(10); + }); +}); diff --git a/server/tests/balances/track/loose/loose-exact.test.ts b/server/tests/balances/track/loose/loose-exact.test.ts new file mode 100644 index 000000000..fb2918450 --- /dev/null +++ b/server/tests/balances/track/loose/loose-exact.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-exact: track exact balance amount")}`, () => { + const customerId = "loose-exact"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 50 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should deduct exact balance amount leaving 0", async () => { + // Track exactly 50 (all balance) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); // No balance left + expect(res.balance?.granted_balance).toBe(50); + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(50); + }); +}); diff --git a/server/tests/balances/track/loose/loose-incremental.test.ts b/server/tests/balances/track/loose/loose-incremental.test.ts new file mode 100644 index 000000000..48ad0936c --- /dev/null +++ b/server/tests/balances/track/loose/loose-incremental.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-incremental: multiple tracks accumulate")}`, () => { + const customerId = "loose-incremental"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 100 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + }); + + test("should deduct with first track", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(80); // 100 - 20 + expect(res.balance?.usage).toBe(20); + }); + + test("should accumulate with second track", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(50); // 80 - 30 + expect(res.balance?.usage).toBe(50); // 20 + 30 + }); +}); diff --git a/server/tests/balances/track/loose/loose-mixed.test.ts b/server/tests/balances/track/loose/loose-mixed.test.ts new file mode 100644 index 000000000..72d2842b3 --- /dev/null +++ b/server/tests/balances/track/loose/loose-mixed.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-mixed: multiple loose ents for same feature")}`, () => { + const customerId = "loose-mixed"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create first loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + + // Create second loose entitlement for same feature + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should combine multiple loose ents in balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.granted_balance).toBe(150); // 100 + 50 + expect(res.balance?.current_balance).toBe(150); + }); + + test("should deduct across multiple loose ents", async () => { + // Track 120 (needs both ents) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(30); // 150 - 120 + expect(res.balance?.usage).toBe(120); + }); +}); diff --git a/server/tests/balances/track/loose/loose-overage-reject.test.ts b/server/tests/balances/track/loose/loose-overage-reject.test.ts new file mode 100644 index 000000000..0c5d01b29 --- /dev/null +++ b/server/tests/balances/track/loose/loose-overage-reject.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-overage: track more than balance caps at 0")}`, () => { + const customerId = "loose-overage"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 20 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 20, + }); + }); + + test("should cap at 0 when tracking more than balance (no overage charge)", async () => { + // Track 50 (more than 20 balance) - should cap at 0, not go negative + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + // Loose entitlements cap at 0 - no negative balance, no overage charge + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(20); // Only deducted what was available + }); +}); diff --git a/server/tests/balances/track/loose/loose-product-first.test.ts b/server/tests/balances/track/loose/loose-product-first.test.ts new file mode 100644 index 000000000..2a5e89347 --- /dev/null +++ b/server/tests/balances/track/loose/loose-product-first.test.ts @@ -0,0 +1,110 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const testCase = "loose-product-first"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 10, +}); + +const testProduct = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +describe(`${chalk.yellowBright("loose-product-first: deducts from product before loose entitlement")}`, () => { + const customerId = testCase; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create product with 10 messages + await initProductsV0({ + ctx, + products: [testProduct], + prefix: testCase, + }); + + // Attach product to customer + await autumnV1.attach({ + customer_id: customerId, + product_id: testProduct.id, + }); + + // Wait for product attachment + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Create loose entitlement with 50 messages (created AFTER product, so deducted second) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should have combined balance of 60", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(60); // 10 from product + 50 from loose + }); + + test("should deduct from product first, then loose entitlement", async () => { + // Track 15 messages (should use all 10 from product, then 5 from loose) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 15, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(45); // 60 - 15 + expect(res.balance?.usage).toBe(15); + }); + + test("should continue deducting from loose after product exhausted", async () => { + // Track 30 more messages (all from loose since product is exhausted) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(15); // 45 - 30 + expect(res.balance?.usage).toBe(45); // 15 + 30 + }); +}); diff --git a/server/tests/balances/track/loose/loose-unlimited.test.ts b/server/tests/balances/track/loose/loose-unlimited.test.ts new file mode 100644 index 000000000..3489963e7 --- /dev/null +++ b/server/tests/balances/track/loose/loose-unlimited.test.ts @@ -0,0 +1,47 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-unlimited: unlimited loose entitlement")}`, () => { + const customerId = "loose-unlimited"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create unlimited loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + unlimited: true, + }); + }); + + test("should allow any track amount with unlimited", async () => { + // Track large amount + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 999999, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.unlimited).toBe(true); + }); +}); diff --git a/server/tests/balances/track/loose/loose-zero.test.ts b/server/tests/balances/track/loose/loose-zero.test.ts new file mode 100644 index 000000000..3f1e5e4af --- /dev/null +++ b/server/tests/balances/track/loose/loose-zero.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-zero: track when balance is zero")}`, () => { + const customerId = "loose-zero"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 10 balance, then use it all + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 10, + }); + + // Use all balance + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test("should have no effect when balance is already zero", async () => { + // Verify balance is 0 + let res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(10); + + // Try to track more - should succeed but have no effect (already at 0) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + // Still at 0, usage unchanged (capped) + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(10); + }); +}); diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index a19907d0a..cf348f9e9 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -1,4 +1,4 @@ -import { ResetInterval } from "@autumn/shared"; +import { FeatureSchema, FeatureType, ResetInterval } from "@autumn/shared"; import { z } from "zod/v4"; export const CreateBalanceSchema = z.object({ @@ -14,4 +14,32 @@ export const CreateBalanceSchema = z.object({ customer_id: z.string(), }); +export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ + feature: FeatureSchema, +}).refine((data) => { + if (!data.feature) { + return false; + } + + if (data.feature.type === FeatureType.Boolean) { + if (data.granted_balance || data.unlimited || data.reset?.interval) { + return false; + } + } + + if (data.feature.type === FeatureType.Metered) { + if (!data.granted_balance && !data.unlimited) { + return false; + } + if (data.granted_balance && data.unlimited) { + return false; + } + if (data.unlimited && data.reset?.interval) { + return false; + } + } + + return true; +}); + export type CreateBalanceParams = z.infer; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts index 736cfa160..3c8c61864 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts @@ -13,15 +13,7 @@ export const FullCusEntWithFullCusProductSchema = customer_product: FullCusProductSchema.nullable(), }); -export const FullCusEntWithOptionalProductSchema = - FullCustomerEntitlementSchema.extend({ - customer_product: FullCusProductSchema.nullable(), - }); - export type FullCusEntWithProduct = z.infer; export type FullCusEntWithFullCusProduct = z.infer< typeof FullCusEntWithFullCusProductSchema >; -export type FullCusEntWithOptionalProduct = z.infer< - typeof FullCusEntWithOptionalProductSchema ->; diff --git a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts index 61f52e9cb..7e55829c0 100644 --- a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts +++ b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts @@ -7,5 +7,5 @@ import { export type ResetCusEnt = FullCustomerEntitlement & { customer: Customer; - customer_product: CusProduct; + customer_product: CusProduct | null; }; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts index fe9bb0a79..412e9e397 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; import { cusEntToCusPrice, @@ -13,12 +13,12 @@ export const cusEntToPurchasedBalance = ({ cusEnt, entityId, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; }) => { - // return 0; - // 1. If prepaid const cusPrice = cusEntToCusPrice({ cusEnt }); + + if (!cusEnt.customer_product) return 0; if (nullish(cusPrice)) { const { balance } = getCusEntBalance({ cusEnt, diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts index 5e67b7295..1e25bc117 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../utils"; import { cusEntToBalance } from "../convertCusEntUtils"; @@ -7,7 +7,7 @@ export const cusEntsToBalance = ({ entityId, withRollovers = false, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; withRollovers?: boolean; }) => { diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts index f724373ed..931bd404e 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; @@ -6,7 +6,7 @@ export const cusEntsToAdjustment = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }) => { return sumValues( diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts index af6a59de5..6aabf7c77 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; import { getRolloverFields } from "../../getRolloverFields"; @@ -10,7 +10,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; withRollovers?: boolean; }) => { @@ -19,7 +19,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; withRollovers?: boolean; }) => { diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 9c1cac68a..53c24be9c 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -1,10 +1,7 @@ import { Decimal } from "decimal.js"; import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBalance.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { - FullCusEntWithFullCusProduct, - FullCusEntWithOptionalProduct, -} from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js"; import { cusEntToCusPrice, @@ -17,7 +14,7 @@ import { getStartingBalance } from "./getStartingBalance.js"; export const cusEntToKey = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { // Interval const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`; @@ -34,7 +31,7 @@ export const cusEntToKey = ({ export const cusEntsToPlanId = ({ cusEnts, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; }) => { // Get number of keys const uniquePlanIds = new Set(); @@ -82,10 +79,12 @@ export const cusEntToIncludedUsage = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; withRollovers?: boolean; }) => { + if (!cusEnt.customer_product) return 0; + const rollover = getRolloverFields({ cusEnt, entityId, diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts index 1e0f9f80f..55ebdd6e4 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts @@ -2,7 +2,6 @@ import { Decimal } from "decimal.js"; import { cusEntToIncludedUsage, type FullCusEntWithFullCusProduct, - type FullCusEntWithOptionalProduct, isPrepaidCusEnt, notNullish, nullish, @@ -12,7 +11,7 @@ export const cusEntsToMaxPurchase = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }): number | null => { // 1. If there's usage-based cus ent, return undefined diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index e1a552c42..8517f3351 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -1,7 +1,7 @@ import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../productUtils/priceUtils.js"; @@ -104,12 +104,13 @@ export const updateCusEntInFullCus = ({ export const isPrepaidCusEnt = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { - // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false; + if (!cusEnt.customer_product) return false; + // 3. Get quantity const options = cusEnt.customer_product?.options?.find( (option) => diff --git a/shared/utils/productUtils/convertUtils.ts b/shared/utils/productUtils/convertUtils.ts index 1ba7600c9..47d15c073 100644 --- a/shared/utils/productUtils/convertUtils.ts +++ b/shared/utils/productUtils/convertUtils.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels.js"; import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; import type { @@ -74,7 +74,7 @@ export const entToOptions = ({ export const cusEntToCusPrice = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { const cusProduct = cusEnt.customer_product; const cusPrices = cusProduct?.customer_prices ?? []; From 0325df27187fad227527c93aeec1d5a5b0c489b4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:10:59 +0000 Subject: [PATCH 10/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron/cronInit.ts | 9 ++++--- server/src/cron/cronUtils.ts | 51 ++++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 4cedc410c..66ba04592 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -19,10 +19,11 @@ const { db, client } = initDrizzle(); export const cronTask = async () => { try { - const cusEnts: ResetCusEnt[] = await CusEntService.getActiveResetPassed({ - db, - batchSize: 500, - }); + const [productCusEnts, looseCusEnts] = await Promise.all([ + CusEntService.getActiveResetPassed({ db, batchSize: 500 }), + CusEntService.getLooseResetPassed({ db, batchSize: 500 }), + ]); + const cusEnts: ResetCusEnt[] = [...productCusEnts, ...looseCusEnts]; const batchSize = 100; for (let i = 0; i < cusEnts.length; i += batchSize) { diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index 965fa1dc8..a58d92f00 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -113,7 +113,7 @@ const handleShortDurationCusEnt = async ({ ...getResetBalancesUpdate({ cusEnt, allowance: new Decimal(ent.allowance || 0) - .mul(cusEnt.customer_product.quantity) + .mul(cusEnt.customer_product?.quantity ?? 1) .toNumber(), }), }; @@ -177,20 +177,21 @@ export const resetCustomerEntitlement = async ({ }); } - // Fetch related price - const cusPrices = await CusPriceService.getByCustomerProductId({ - db, - customerProductId: cusEnt.customer_product_id, - }); - - // 2. Quantity is from prices... - const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices); - if (relatedCusPrice) { - return; + // Fetch related price (skip for loose ents) + let relatedCusPrice = null; + if (cusEnt.customer_product_id) { + const cusPrices = await CusPriceService.getByCustomerProductId({ + db, + customerProductId: cusEnt.customer_product_id, + }); + relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices); + if (relatedCusPrice) { + return; + } } const entOptions = getEntOptions( - cusEnt.customer_product.options, + cusEnt.customer_product?.options ?? [], cusEnt.entitlement, ); @@ -239,7 +240,7 @@ export const resetCustomerEntitlement = async ({ entitlement: cusEnt.entitlement, options: entOptions || undefined, relatedPrice: undefined, - productQuantity: cusEnt.customer_product.quantity, + productQuantity: cusEnt.customer_product?.quantity ?? 1, }); // 1. Check if should reset @@ -260,16 +261,20 @@ export const resetCustomerEntitlement = async ({ allowance: resetBalance || undefined, }); - try { - nextResetAt = await checkSubAnchor({ - db, - cusEnt, - nextResetAt, - }); - } catch (error) { - console.log( - `WARNING: Failed to check sub anchor: ${error}, Org: ${cusEnt.customer.org_id}`, - ); + // Only check sub anchor for product-based ents (loose ents have no subscription) + if (cusEnt.customer_product) { + try { + nextResetAt = await checkSubAnchor({ + db, + cusEnt: cusEnt as FullCusEntWithProduct, + nextResetAt, + }); + } catch (error) { + console.log( + `WARNING: Failed to check sub anchor: ${error}, Org: ${cusEnt.customer.org_id}`, + ); + console.log(error); + } } await CusEntService.update({ From bf7812560b8219f09a85fafb0c16e59e66694533 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:26:29 +0000 Subject: [PATCH 11/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20openapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/api/_openapi2.0_/balancesOpenApi.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/shared/api/_openapi2.0_/balancesOpenApi.ts b/shared/api/_openapi2.0_/balancesOpenApi.ts index fd07d06d4..ef454ceda 100644 --- a/shared/api/_openapi2.0_/balancesOpenApi.ts +++ b/shared/api/_openapi2.0_/balancesOpenApi.ts @@ -1,3 +1,4 @@ +import { CreateBalanceSchema } from "@api/balances/create/createBalanceParams.js"; import type { ZodOpenApiPathsObject } from "zod-openapi"; import { ExtBalancesUpdateParamsSchema } from "../balances/balancesUpdateModels.js"; import { SuccessResponseSchema } from "../common/commonResponses.js"; @@ -24,4 +25,25 @@ export const balancesOpenApi: ZodOpenApiPathsObject = { }, }, }, + "/balances/create": { + post: { + summary: "Create Balance", + description: + "Create a new balance for a specific feature for a customer.", + tags: ["balances"], + requestBody: { + content: { + "application/json": { schema: CreateBalanceSchema }, + }, + }, + responses: { + "200": { + description: "Balance created successfully", + content: { + "application/json": { schema: SuccessResponseSchema }, + }, + }, + }, + }, + }, }; From 65608e7a1b424e739136aaccfe32ac4c5ad2588a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:09:43 +0000 Subject: [PATCH 12/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20entity=20level=20l?= =?UTF-8?q?oose=20entitlements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/testGroups/g1.sh | 2 + .../prepareNewBalanceForInsertion.ts | 20 ++- .../createBalance/validateCreateBalance.ts | 15 +++ .../balances/handlers/handleCreateBalance.ts | 16 ++- .../internal/balances/utils/sync/syncItem.ts | 14 +- .../balances/utils/sync/syncItemV2.ts | 39 +++--- .../customers/add-product/initCusEnt.ts | 3 + .../apiCusCacheUtils/getCachedApiCustomer.ts | 9 +- .../apiCusCacheUtils/setCachedApiCustomer.ts | 19 +-- .../apiCusCacheUtils/setCachedApiSubs.ts | 9 +- .../setCachedGrantedBalance.ts | 16 ++- .../getApiBalance/getApiBalances.ts | 23 +--- .../apiEntityCacheUtils/getCachedApiEntity.ts | 11 +- .../loose/entities/entity-loose-1.test.ts | 96 ++++++++++++++ .../loose/entities/entity-loose-2.test.ts | 80 ++++++++++++ .../loose/entities/entity-loose-3.test.ts | 122 ++++++++++++++++++ .../loose/entities/entity-loose-4.test.ts | 66 ++++++++++ .../loose/entities/entity-loose-5.test.ts | 105 +++++++++++++++ .../loose/entities/entity-loose-6.test.ts | 115 +++++++++++++++++ .../balances/check/loose/loose-3.test.ts | 2 +- .../balances/check/loose/loose-5.test.ts | 64 +++++++++ .../balances/create/createBalanceParams.ts | 5 + .../cusEntModels/cusEntModels.ts | 1 + .../cusEntModels/cusEntTable.ts | 7 + .../cusEntModels/resetCusEnt.ts | 9 +- shared/utils/cusEntUtils/balanceUtils.ts | 13 ++ .../cusEntsToStartingBalance.ts | 26 +--- shared/utils/cusEntUtils/filterCusEntUtils.ts | 25 +++- .../cusProductUtils/filterCusProductUtils.ts | 59 ++++++--- vite/src/views/admin/adminUtils.ts | 24 +++- 30 files changed, 863 insertions(+), 152 deletions(-) create mode 100644 server/tests/balances/check/loose/entities/entity-loose-1.test.ts create mode 100644 server/tests/balances/check/loose/entities/entity-loose-2.test.ts create mode 100644 server/tests/balances/check/loose/entities/entity-loose-3.test.ts create mode 100644 server/tests/balances/check/loose/entities/entity-loose-4.test.ts create mode 100644 server/tests/balances/check/loose/entities/entity-loose-5.test.ts create mode 100644 server/tests/balances/check/loose/entities/entity-loose-6.test.ts create mode 100644 server/tests/balances/check/loose/loose-5.test.ts diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 5c216b963..777d43f91 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -20,11 +20,13 @@ BUN_PARALLEL_COMPACT \ 'server/tests/balances/track/concurrency' \ 'server/tests/balances/track/negative' \ 'server/tests/balances/check/breakdown' \ + 'server/tests/balances/track/loose' \ 'server/tests/balances/check/basic' \ 'server/tests/balances/check/credit-systems' \ 'server/tests/balances/check/misc' \ 'server/tests/balances/check/prepaid' \ 'server/tests/balances/check/send-event' \ + 'server/tests/balances/check/loose --max=6 diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index f57fa528c..9667ff32a 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -1,6 +1,7 @@ import { type CreateBalanceSchema, type CustomerEntitlement, + type Entity, type Feature, type FullCustomer, planFeaturesToItems, @@ -20,6 +21,7 @@ export const prepareNewBalanceForInsertion = async ({ reset, fullCus, feature_id, + entity, }: { ctx: AutumnContext; feature: Feature; @@ -28,6 +30,7 @@ export const prepareNewBalanceForInsertion = async ({ reset: z.infer["reset"]; fullCus: FullCustomer; feature_id: string; + entity?: Entity; }) => { const inputAsItem = planFeaturesToItems({ features: [feature], @@ -38,10 +41,10 @@ export const prepareNewBalanceForInsertion = async ({ unlimited, reset: reset ? { - interval: reset.interval as ResetInterval, - interval_count: reset.interval_count, - reset_when_enabled: true, - } + interval: reset.interval as ResetInterval, + interval_count: reset.interval_count, + reset_when_enabled: true, + } : undefined, }, ], @@ -54,6 +57,10 @@ export const prepareNewBalanceForInsertion = async ({ internalFeatureId: feature.internal_id!, }); + if (entity) { + newEntitlement.entity_feature_id = entity.feature_id; + } + const newEntitlementWithFeature = { ...newEntitlement, feature, @@ -77,6 +84,11 @@ export const prepareNewBalanceForInsertion = async ({ productOptions: undefined, }) satisfies CustomerEntitlement; + // If entity is provided, assign balance to entity instead of customer-level + if (entity) { + newCustomerEntitlement.internal_entity_id = entity.internal_id; + } + return { newEntitlement, newCustomerEntitlement, diff --git a/server/src/internal/balances/createBalance/validateCreateBalance.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts index 49b639f43..7d70bcebc 100644 --- a/server/src/internal/balances/createBalance/validateCreateBalance.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -19,6 +19,7 @@ export const validateCreateBalanceParams = async ({ unlimited, reset, fullCustomer, + entity_id, }: { ctx: AutumnContext; feature: Feature; @@ -27,6 +28,7 @@ export const validateCreateBalanceParams = async ({ unlimited: boolean | undefined; reset: z.infer["reset"]; fullCustomer: FullCustomer; + entity_id?: string; }) => { ValidateCreateBalanceParamsSchema.parse({ feature, @@ -35,6 +37,7 @@ export const validateCreateBalanceParams = async ({ reset, customer_id: internalCustomerId, feature_id: feature.id, + entity_id, }); await validateBooleanEntitlementConflict({ @@ -42,6 +45,18 @@ export const validateCreateBalanceParams = async ({ feature, internalCustomerId: fullCustomer.internal_id, }); + + // Entity cannot receive a balance of its own feature type + if (entity_id) { + const entity = fullCustomer.entities.find((e) => e.id === entity_id); + if (entity && feature.id === entity.feature_id) { + throw new RecaseError({ + message: `Cannot give an entity a balance of its own feature type`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } }; export const validateBooleanEntitlementConflict = async ({ diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 6cc66592e..c78a84d5b 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,5 +1,6 @@ import { - CreateBalanceSchema + CreateBalanceSchema, + EntityNotFoundError } from "@autumn/shared"; import { FeatureNotFoundError } from "@shared/index"; import { createRoute } from "@/honoMiddlewares/routeHandler"; @@ -16,21 +17,28 @@ export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, handler: async (c) => { const ctx = c.get("ctx"); - const { feature_id, customer_id, granted_balance, unlimited, reset } = + const { feature_id, customer_id, entity_id, granted_balance, unlimited, reset } = c.req.valid("json"); const feature = ctx.features.find((f) => f.id === feature_id); if (!feature) { throw new FeatureNotFoundError({ featureId: feature_id }); } - 34; + const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, orgId: ctx.org.id, env: ctx.env, + withEntities: true, }); + if (entity_id && !fullCustomer.entities.find((e) => e.id === entity_id)) { + throw new EntityNotFoundError({ + entityId: entity_id, + }); + } + await validateCreateBalanceParams({ ctx, feature, @@ -39,6 +47,7 @@ export const handleCreateBalance = createRoute({ unlimited, reset, fullCustomer, + entity_id, }); const { newEntitlement, newCustomerEntitlement } = @@ -49,6 +58,7 @@ export const handleCreateBalance = createRoute({ unlimited, reset, fullCus: fullCustomer, + entity: entity_id ? fullCustomer.entities.find((e) => e.id === entity_id) : undefined, feature_id, }); diff --git a/server/src/internal/balances/utils/sync/syncItem.ts b/server/src/internal/balances/utils/sync/syncItem.ts index ab790078a..9b34cf514 100644 --- a/server/src/internal/balances/utils/sync/syncItem.ts +++ b/server/src/internal/balances/utils/sync/syncItem.ts @@ -8,8 +8,8 @@ import { type ApiCustomer, type ApiEntityV1, cusEntToPrepaidQuantity, - filterEntityLevelCusProducts, - filterOutEntitiesFromCusProducts, + filterEntityLevelCustomerEntitlementsFromFullCustomer, + filterOutEntitiesFromFullCustomer, fullCustomerToCustomerEntitlements, getRelevantFeatures, orgToInStatuses, @@ -175,7 +175,7 @@ export const syncItem = async ({ } // Get fresh customer from DB (no locking - let deduction handle it) - const fullCus = + let fullCus = item.fullCustomer || (await CusService.getFull({ db, @@ -190,14 +190,12 @@ export const syncItem = async ({ // If entityId provided, deduct entity level cusEnts if (entityId) { - fullCus.customer_products = filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, + fullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer({ + fullCustomer: fullCus, }); } else { // If entityId NOT provided, JUST deduct customer level cusEnts - fullCus.customer_products = filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, - }); + fullCus = filterOutEntitiesFromFullCustomer({ fullCus }) as FullCustomer; } const relevantFeatures = getRelevantFeatures({ diff --git a/server/src/internal/balances/utils/sync/syncItemV2.ts b/server/src/internal/balances/utils/sync/syncItemV2.ts index 54e218654..617c73221 100644 --- a/server/src/internal/balances/utils/sync/syncItemV2.ts +++ b/server/src/internal/balances/utils/sync/syncItemV2.ts @@ -4,13 +4,12 @@ import { type ApiCustomer, type ApiEntityV1, cusEntsToAllowance, - cusEntToPrepaidQuantity, - cusProductsToCusEnts, - type FullCusEntWithFullCusProduct, - filterEntityLevelCusProducts, - filterOutEntitiesFromCusProducts, + cusEntToPrepaidQuantity, type FullCusEntWithFullCusProduct, + filterEntityLevelCustomerEntitlementsFromFullCustomer, + filterOutEntitiesFromFullCustomer, + fullCustomerToCustomerEntitlements, getRelevantFeatures, - orgToInStatuses, + orgToInStatuses } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { sql } from "drizzle-orm"; @@ -180,7 +179,7 @@ export const syncItemV2 = async ({ } // Get fresh customer from DB - const fullCus = await CusService.getFull({ + let fullCus = await CusService.getFull({ db, idOrInternalId: customerId, orgId: org.id, @@ -193,13 +192,11 @@ export const syncItemV2 = async ({ // Filter to entity-level or customer-level cusProducts if (entityId) { - fullCus.customer_products = filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, + fullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer({ + fullCustomer: fullCus, }); } else { - fullCus.customer_products = filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, - }); + fullCus = filterOutEntitiesFromFullCustomer({ fullCus }); } const relevantFeatures = getRelevantFeatures({ @@ -214,8 +211,8 @@ export const syncItemV2 = async ({ const redisBalance = redisEntity.balances?.[relevantFeature.id]; if (!redisBalance) continue; - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, featureId: relevantFeature.id, reverseOrder: org.config?.reverse_deduction_order, entity: fullCus.entity, @@ -244,19 +241,19 @@ export const syncItemV2 = async ({ const result = await db.execute( sql`SELECT * FROM sync_balances( ${JSON.stringify({ - entitlements: allEntries, - target_entity_id: entityId || null, - })}::jsonb + entitlements: allEntries, + target_entity_id: entityId || null, + })}::jsonb )`, ); // Format result for readable logging const syncResult = result[0] as | { - sync_balances?: { - updates?: Record; - }; - } + sync_balances?: { + updates?: Record; + }; + } | undefined; const updates = syncResult?.sync_balances?.updates; diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index 604528bf3..a56b9c291 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -102,6 +102,7 @@ const initCusEntBalance = ({ export const initCusEntitlement = ({ entitlement, customer, + entity, cusProductId, freeTrial, options, @@ -120,6 +121,7 @@ export const initCusEntitlement = ({ }: { entitlement: EntitlementWithFeature; customer: Customer; + entity?: Entity; cusProductId: string | null; freeTrial: FreeTrial | null; options?: FeatureOptions; @@ -183,6 +185,7 @@ export const initCusEntitlement = ({ id: generateId("cus_ent"), internal_customer_id: customer.internal_id, internal_feature_id: entitlement.internal_feature_id, + internal_entity_id: entity?.internal_id ?? null, feature_id: (entitlement.feature_id ?? entitlement.feature.id) as string, customer_id: customer.id, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index f4572be20..e403b984d 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -7,7 +7,7 @@ import { CusExpand, type CustomerLegacyData, CustomerLegacyDataSchema, - filterOutEntitiesFromCusProducts, + filterOutEntitiesFromFullCustomer, filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; @@ -137,12 +137,7 @@ export const getCachedApiCustomer = async ({ const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({ ctx, - fullCus: { - ...fullCus, - customer_products: filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, - }), - }, + fullCus: filterOutEntitiesFromFullCustomer({ fullCus }), withAutumnId: true, }); diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts index e4868ee18..86c322cda 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts @@ -4,8 +4,8 @@ import { CusExpand, type EntityLegacyData, type FullCustomer, - filterEntityLevelCusProducts, - filterOutEntitiesFromCusProducts, + filterEntityLevelCustomerEntitlementsFromFullCustomer, + filterOutEntitiesFromFullCustomer, } from "@autumn/shared"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; @@ -47,18 +47,13 @@ export const setCachedApiCustomer = async ({ const { apiCustomer: masterApiCustomer, legacyData } = await getApiCustomerBase({ ctx: ctxWithExpand, - fullCus: { - ...structuredClone(fullCus), - customer_products: filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, - }), - }, + fullCus: filterOutEntitiesFromFullCustomer({ fullCus }), withAutumnId: true, }); // Build entity api customers (entity-level features only) - const entityLevelCusProducts = filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, + const filteredFullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer({ + fullCustomer: fullCus, }); // Build entities first @@ -67,8 +62,8 @@ export const setCachedApiCustomer = async ({ entityData: ApiEntityV1 & { legacyData: EntityLegacyData }; }[] = []; const entityFullCus = { - ...fullCus, - customer_products: entityLevelCusProducts, + ...filteredFullCus, + customer_products: filteredFullCus.customer_products, }; for (const entity of fullCus.entities) { diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts index 1fc017cb5..c1aec4568 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts @@ -3,7 +3,7 @@ import { CusExpand, type FullCustomer, filterCusProductsByEntity, - filterOutEntitiesFromCusProducts, + filterOutEntitiesFromFullCustomer, } from "@autumn/shared"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; @@ -33,12 +33,7 @@ export const setCachedApiSubs = async ({ }); const { data: masterApiSubs } = await getApiSubscriptions({ ctx: ctxWithExpand, - fullCus: { - ...structuredClone(fullCus), - customer_products: filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, - }), - }, + fullCus: filterOutEntitiesFromFullCustomer({ fullCus }), }); // Split subscriptions by status diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedGrantedBalance.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedGrantedBalance.ts index 0efa056f0..0de25416a 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedGrantedBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedGrantedBalance.ts @@ -1,7 +1,7 @@ import { type FullCustomer, - filterEntityLevelCusProducts, - filterOutEntitiesFromCusProducts, + filterEntityLevelCustomerEntitlementsFromFullCustomer, + filterOutEntitiesFromFullCustomer, } from "@autumn/shared"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -47,10 +47,12 @@ export const setCachedGrantedBalance = async ({ // ============================================================================ // 1. Build customer-level balances payload (customer-level products only) // ============================================================================ - const customerLevelCusProducts = filterOutEntitiesFromCusProducts({ - cusProducts: fullCus.customer_products, + const filteredFullCus = filterOutEntitiesFromFullCustomer({ + fullCus, }); + const customerLevelCusProducts = filteredFullCus.customer_products; + const { data: customerBalances } = await getApiBalances({ ctx, fullCus: { @@ -73,9 +75,9 @@ export const setCachedGrantedBalance = async ({ // ============================================================================ // 2. Build entity balances batch (entity-level products only) // ============================================================================ - const entityLevelCusProducts = filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, - }); + const entityLevelCusProducts = filterEntityLevelCustomerEntitlementsFromFullCustomer({ + fullCustomer: fullCus, + }).customer_products; const entityBatch: EntityBatchItem[] = []; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index a4224aa75..d87473b0f 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -19,31 +19,14 @@ export const getApiBalances = async ({ }) => { const { org } = ctx; - const allCusEntsFromFullCustomer = fullCustomerToCustomerEntitlements({ + // fullCustomerToCustomerEntitlements already includes extra_customer_entitlements + // and filters them by entity via cusEntMatchesEntity + const allCusEnts = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, inStatuses: orgToInStatuses({ org }), entity: fullCus.entity, }); - // Filter out loose entitlements (customer_product is null) - they come from extra_customer_entitlements - const cusEntsWithCusProduct = allCusEntsFromFullCustomer.filter( - (ent) => ent.customer_product !== null, - ); - - // Add extra entitlements (loose entitlements not tied to a product) - const extraEnts: FullCusEntWithFullCusProduct[] = ( - fullCus.extra_customer_entitlements || [] - ).map((ent) => ({ - ...ent, - customer_product: null, - })); - - // Combine both sources - const allCusEnts: FullCusEntWithFullCusProduct[] = [ - ...cusEntsWithCusProduct, - ...extraEnts, - ]; - const featureToCusEnt: Record = {}; for (const cusEnt of allCusEnts) { const featureId = cusEnt.entitlement.feature.id; diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts index 88be92f35..67505a9ee 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts @@ -6,7 +6,7 @@ import { EntityLegacyDataSchema, EntityNotFoundError, type FullCustomer, - filterEntityLevelCusProducts, + filterEntityLevelCustomerEntitlementsFromFullCustomer, filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; @@ -144,12 +144,9 @@ export const getCachedApiEntity = async ({ const { apiEntity: pureApiEntity } = await getApiEntityBase({ ctx, entity, - fullCus: { - ...fullCus, - customer_products: filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, - }), - }, + fullCus: filterEntityLevelCustomerEntitlementsFromFullCustomer({ + fullCustomer: fullCus, + }), withAutumnId: true, }); diff --git a/server/tests/balances/check/loose/entities/entity-loose-1.test.ts b/server/tests/balances/check/loose/entities/entity-loose-1.test.ts new file mode 100644 index 000000000..361f8200e --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-1.test.ts @@ -0,0 +1,96 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "entity-loose1"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: basic entity loose entitlement check`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create entity + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Create loose entitlement on entity + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + granted_balance: 500, + }); + }); + + test("v2: entity loose entitlement should be allowed with plan_id null", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.entity_id).toBe(entityId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Messages); + expect(res.balance?.granted_balance).toBe(500); + expect(res.balance?.current_balance).toBe(500); + expect(res.balance?.usage).toBe(0); + expect(res.balance?.unlimited).toBe(false); + }); + + test("v2: should respect required_balance for entity", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + required_balance: 400, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.required_balance).toBe(400); + }); + + test("v2: should return allowed=false for insufficient entity balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + required_balance: 999, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); + expect(res.required_balance).toBe(999); + expect(res.balance?.current_balance).toBe(500); + }); + + test("v2: customer-level check should see merged entity balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id - checking at customer level + })) as unknown as CheckResponseV2; + + // Customer should see merged entity balances + expect(res.balance?.current_balance).toBe(500); + }); +}); diff --git a/server/tests/balances/check/loose/entities/entity-loose-2.test.ts b/server/tests/balances/check/loose/entities/entity-loose-2.test.ts new file mode 100644 index 000000000..b671e628f --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-2.test.ts @@ -0,0 +1,80 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "entity-loose2"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: unlimited entity loose entitlement`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create entity + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Create unlimited loose entitlement on entity + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + unlimited: true, + }); + }); + + test("v2: unlimited entity loose entitlement should always be allowed", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.customer_id).toBe(customerId); + expect(res.entity_id).toBe(entityId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Messages); + expect(res.balance?.unlimited).toBe(true); + }); + + test("v2: unlimited entity should allow any required_balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + required_balance: 999999, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.unlimited).toBe(true); + }); + + test("v2: customer-level should see merged entity's unlimited balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id + })) as unknown as CheckResponseV2; + + // Customer should see merged unlimited from entity + expect(res.balance?.unlimited).toBe(true); + }); +}); diff --git a/server/tests/balances/check/loose/entities/entity-loose-3.test.ts b/server/tests/balances/check/loose/entities/entity-loose-3.test.ts new file mode 100644 index 000000000..0424e6b92 --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-3.test.ts @@ -0,0 +1,122 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "entity-loose3"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: entity with product + loose entitlement`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Create entity + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Attach product at customer level (gives 100 messages) + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + + // Add loose entitlement for entity (adds 500 more just for entity) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + granted_balance: 500, + }); + }); + + test("v2: entity check should include customer product + entity loose ent", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.entity_id).toBe(entityId); + expect(res.balance).toBeDefined(); + + // Entity should see: 100 (from customer product) + 500 (entity loose) = 600 + expect(res.balance?.granted_balance).toBe(600); + expect(res.balance?.current_balance).toBe(600); + + // Breakdown should show both sources + expect(res.balance?.breakdown).toBeDefined(); + expect(res.balance?.breakdown).toHaveLength(2); + }); + + test("v2: customer-level check should see merged balances", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + // Customer should see merged: 100 (product) + 500 (entity loose) = 600 + expect(res.balance?.granted_balance).toBe(600); + expect(res.balance?.current_balance).toBe(600); + }); + + test("v2: entity breakdown should show both sources", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + const breakdown = res.balance?.breakdown; + expect(breakdown).toBeDefined(); + expect(breakdown).toHaveLength(2); + + // Find the product entitlement (has plan_id) + const productEnt = breakdown?.find((b) => b.plan_id === freeProd.id); + expect(productEnt).toBeDefined(); + expect(productEnt?.granted_balance).toBe(100); + + // Find the loose entitlement (plan_id is null) + const looseEnt = breakdown?.find((b) => b.plan_id === null); + expect(looseEnt).toBeDefined(); + expect(looseEnt?.granted_balance).toBe(500); + }); +}); diff --git a/server/tests/balances/check/loose/entities/entity-loose-4.test.ts b/server/tests/balances/check/loose/entities/entity-loose-4.test.ts new file mode 100644 index 000000000..833c3d7d9 --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-4.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "entity-loose4"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: entity loose entitlement with reset interval`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create entity + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Create loose entitlement on entity with monthly reset + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Action1, + entity_id: entityId, + granted_balance: 1000, + reset: { + interval: ResetInterval.Month, + interval_count: 1, + }, + }); + }); + + test("v2: entity loose entitlement with reset should include reset info", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.entity_id).toBe(entityId); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.feature_id).toBe(TestFeature.Action1); + expect(res.balance?.granted_balance).toBe(1000); + expect(res.balance?.current_balance).toBe(1000); + + // Reset info should be present + expect(res.balance?.reset).toBeDefined(); + expect(res.balance?.reset?.interval).toBe(ResetInterval.Month); + expect(res.balance?.reset?.resets_at).toBeDefined(); + }); +}); diff --git a/server/tests/balances/check/loose/entities/entity-loose-5.test.ts b/server/tests/balances/check/loose/entities/entity-loose-5.test.ts new file mode 100644 index 000000000..a497c9979 --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-5.test.ts @@ -0,0 +1,105 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "entity-loose5"; +const customerId = testCase; +const entity1Id = `${testCase}-user-1`; +const entity2Id = `${testCase}-user-2`; + +describe(`${chalk.yellowBright(`${testCase}: multiple entities with isolated balances`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create two entities + await autumnV1.entities.create(customerId, [ + { + id: entity1Id, + name: "User 1", + feature_id: TestFeature.Users, + }, + { + id: entity2Id, + name: "User 2", + feature_id: TestFeature.Users, + }, + ]); + + // Give entity 1 a balance of 100 + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity1Id, + granted_balance: 100, + }); + + // Give entity 2 a balance of 500 + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity2Id, + granted_balance: 500, + }); + }); + + test("v2: entity 1 should have 100 balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity1Id, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.entity_id).toBe(entity1Id); + expect(res.balance?.granted_balance).toBe(100); + expect(res.balance?.current_balance).toBe(100); + }); + + test("v2: entity 2 should have 500 balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity2Id, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.entity_id).toBe(entity2Id); + expect(res.balance?.granted_balance).toBe(500); + expect(res.balance?.current_balance).toBe(500); + }); + + test("v2: entity balances should be isolated (entity1 can't use entity2's balance)", async () => { + // Entity 1 asking for 200 should fail (only has 100) + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity1Id, + required_balance: 200, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); + expect(res.balance?.current_balance).toBe(100); + }); + + test("v2: customer level should see merged entity balances", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id + })) as unknown as CheckResponseV2; + + // Customer should see merged entity balances: 100 + 500 = 600 + expect(res.balance?.current_balance).toBe(600); + }); +}); diff --git a/server/tests/balances/check/loose/entities/entity-loose-6.test.ts b/server/tests/balances/check/loose/entities/entity-loose-6.test.ts new file mode 100644 index 000000000..35bd6f01a --- /dev/null +++ b/server/tests/balances/check/loose/entities/entity-loose-6.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "entity-loose6"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: customer loose + entity loose isolation`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create entity + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Give CUSTOMER a loose balance of 200 + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id - this is customer level + granted_balance: 200, + }); + + // Give ENTITY a loose balance of 300 + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + granted_balance: 300, + }); + }); + + test("v2: customer level should see merged balances (200 + 300 = 500)", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + // No entity_id + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + // Customer should see merged: 200 (customer) + 300 (entity) = 500 + expect(res.balance?.granted_balance).toBe(500); + expect(res.balance?.current_balance).toBe(500); + }); + + test("v2: entity level should see customer + entity balance (200 + 300 = 500)", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.entity_id).toBe(entityId); + // Entity should see combined: 200 (customer) + 300 (entity) = 500 + expect(res.balance?.granted_balance).toBe(500); + expect(res.balance?.current_balance).toBe(500); + }); + + test("v2: entity breakdown should show both sources", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + const breakdown = res.balance?.breakdown; + expect(breakdown).toBeDefined(); + expect(breakdown).toHaveLength(2); + + // Both should be loose (plan_id null) + const balances = breakdown?.map((b) => b.granted_balance).sort((a, b) => a - b); + expect(balances).toEqual([200, 300]); + }); + + test("v2: both customer and entity can use up to 500 (merged)", async () => { + // Customer with 250 required should succeed (has 500 merged) + const customerRes = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 250, + })) as unknown as CheckResponseV2; + + expect(customerRes.allowed).toBe(true); + expect(customerRes.balance?.current_balance).toBe(500); + + // Entity with 450 required should succeed + const entityRes = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + required_balance: 450, + })) as unknown as CheckResponseV2; + + expect(entityRes.allowed).toBe(true); + expect(entityRes.balance?.current_balance).toBe(500); + }); +}); diff --git a/server/tests/balances/check/loose/loose-3.test.ts b/server/tests/balances/check/loose/loose-3.test.ts index d63618dc2..7a5f04cb8 100644 --- a/server/tests/balances/check/loose/loose-3.test.ts +++ b/server/tests/balances/check/loose/loose-3.test.ts @@ -69,7 +69,7 @@ describe(`${chalk.yellowBright("check-loose3: mixed product + loose entitlement" expect(res.balance?.current_balance).toBe(600); // When mixed sources, plan_id should be null and breakdown should exist - expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.breakdown?.find((b) => b.plan_id === null)).toBeDefined(); expect(res.balance?.breakdown).toBeDefined(); expect(res.balance?.breakdown).toHaveLength(2); }); diff --git a/server/tests/balances/check/loose/loose-5.test.ts b/server/tests/balances/check/loose/loose-5.test.ts new file mode 100644 index 000000000..ac92a35c9 --- /dev/null +++ b/server/tests/balances/check/loose/loose-5.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "check-loose5"; +const customerId = testCase; +const entityId = `${testCase}-user-1`; + +describe(`${chalk.yellowBright(`${testCase}: loose entitlement on entity`)}`, () => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create entity on the Users feature + await autumnV1.entities.create(customerId, [ + { + id: entityId, + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + // Give the entity 10 messages (loose entitlement) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + granted_balance: 10, + }); + }); + + test("entity should have 10 messages balance", async () => { + const entity = await autumnV1.entities.get(customerId, entityId); + + expect(entity.features[TestFeature.Messages]).toBeDefined(); + expect(entity.features[TestFeature.Messages].balance).toBe(10); + }); + + test("v2 check: entity should have access to messages", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + })) as unknown as CheckResponseV2; + + console.log(res); + + expect(res.allowed).toBe(true); + expect(res.balance).toBeDefined(); + expect(res.balance?.feature_id).toBe(TestFeature.Messages); + expect(res.balance?.granted_balance).toBe(10); + expect(res.balance?.current_balance).toBe(10); + }); +}); diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index cf348f9e9..7253fefa2 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -12,6 +12,11 @@ export const CreateBalanceSchema = z.object({ }) .optional(), customer_id: z.string(), + entity_id: z.string().optional(), +}).refine((data) => { + if (data.entity_id && !data.customer_id) { + return false; + } else return true; }); export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index 3e537c297..4ce88bc5d 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -21,6 +21,7 @@ export const CustomerEntitlementSchema = z.object({ // Foreign keys id: z.string(), internal_customer_id: z.string(), + internal_entity_id: z.string().nullable(), internal_feature_id: z.string(), customer_id: z.string().nullish(), // for debugging purposes feature_id: z.string(), // for debugging purposes diff --git a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts index d6a494990..16ce57f96 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts @@ -7,6 +7,7 @@ import { pgTable, text, } from "drizzle-orm/pg-core"; +import { entities } from "../../../db/schema.js"; import { collatePgColumn } from "../../../db/utils.js"; import { features } from "../../featureModels/featureTable.js"; import { entitlements } from "../../productModels/entModels/entTable.js"; @@ -20,6 +21,7 @@ export const customerEntitlements = pgTable( customer_product_id: text(), entitlement_id: text().notNull(), internal_customer_id: text().notNull(), + internal_entity_id: text(), internal_feature_id: text().notNull(), unlimited: boolean("unlimited").default(false), @@ -47,6 +49,11 @@ export const customerEntitlements = pgTable( foreignColumns: [features.internal_id], name: "entitlements_internal_feature_id_fkey", }).onDelete("cascade"), + foreignKey({ + columns: [table.internal_entity_id], + foreignColumns: [entities.internal_id], + name: "customer_entitlements_internal_entity_id_fkey", + }).onDelete("cascade"), foreignKey({ columns: [table.customer_product_id], foreignColumns: [customerProducts.id], diff --git a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts index 7e55829c0..569e30594 100644 --- a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts +++ b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts @@ -1,9 +1,6 @@ -import { Customer } from "../../cusModels/cusModels.js"; -import { CusProduct } from "../cusProductModels.js"; -import { - CustomerEntitlement, - FullCustomerEntitlement, -} from "./cusEntModels.js"; +import type { Customer } from "../../cusModels/cusModels.js"; +import type { CusProduct } from "../cusProductModels.js"; +import type { FullCustomerEntitlement } from "./cusEntModels.js"; export type ResetCusEnt = FullCustomerEntitlement & { customer: Customer; diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index 990e03922..5b2052e87 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -63,6 +63,19 @@ export const getCusEntBalance = ({ } if (notNullish(entitlement.entity_feature_id)) { + // NEW APPROACH: has internal_entity_id set, use top-level balance + // (one row per entity, balance stored directly on the row) + if (notNullish(cusEnt.internal_entity_id)) { + return { + balance: cusEnt.balance || 0, + additional_balance: cusEnt.additional_balance || 0, + adjustment: cusEnt.adjustment || 0, + unused: cusEnt.replaceables?.length || 0, + count: 1, + }; + } + + // OLD APPROACH: entities object stores per-entity balances if (nullish(entityId)) { return getSummedEntityBalances({ cusEnt, diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToStartingBalance.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToStartingBalance.ts index 4ee12f4b6..422361cb8 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToStartingBalance.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToStartingBalance.ts @@ -1,31 +1,7 @@ import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { - cusEntToCusPrice, - entToOptions, -} from "../../productUtils/convertUtils"; import { sumValues } from "../../utils"; +import { cusEntToStartingBalance } from "../balanceUtils/cusEntToStartingBalance"; -import { getStartingBalance } from "../getStartingBalance"; - -export const cusEntToStartingBalance = ({ - cusEnt, -}: { - cusEnt: FullCusEntWithFullCusProduct; -}) => { - const cusPrice = cusEntToCusPrice({ cusEnt }); - const price = cusPrice?.price; - const options = entToOptions({ - ent: cusEnt.entitlement, - options: cusEnt.customer_product.options, - }); - - return getStartingBalance({ - entitlement: cusEnt.entitlement, - options, - relatedPrice: price, - productQuantity: cusEnt.customer_product.quantity, - }); -}; export const cusEntsToStartingBalance = ({ cusEnts, diff --git a/shared/utils/cusEntUtils/filterCusEntUtils.ts b/shared/utils/cusEntUtils/filterCusEntUtils.ts index f5eb8a17b..61a5d6275 100644 --- a/shared/utils/cusEntUtils/filterCusEntUtils.ts +++ b/shared/utils/cusEntUtils/filterCusEntUtils.ts @@ -14,24 +14,31 @@ export const cusEntMatchesEntity = ({ }) => { if (!entity) return true; + // Check 1: customer_product level entity (entity-level products) let cusProductMatch = true; - if (notNullish(cusEnt.customer_product?.internal_entity_id)) { cusProductMatch = cusEnt.customer_product.internal_entity_id === entity.internal_id; } + // Check 2: entity_feature_id match (per-entity features) let entityFeatureIdMatch = true; - // let feature = features?.find( - // (f) => f.id == cusEnt.entitlement.entity_feature_id, - // ); - if (notNullish(cusEnt.entitlement.entity_feature_id)) { entityFeatureIdMatch = cusEnt.entitlement.entity_feature_id === entity.feature_id; } - return cusProductMatch && entityFeatureIdMatch; + // Check 3: cusEnt-level entity (for loose entitlements / extra_customer_entitlements) + let cusEntEntityMatch = true; + if (notNullish(cusEnt.internal_entity_id)) { + // NEW APPROACH: direct internal_entity_id on the customer_entitlement row + cusEntEntityMatch = cusEnt.internal_entity_id === entity.internal_id; + } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { + // OLD APPROACH: entities object uses external entity.id as keys + cusEntEntityMatch = entity.id !== null && entity.id in cusEnt.entities; + } + + return cusProductMatch && entityFeatureIdMatch && cusEntEntityMatch; }; export const filterOutEntityCusEnts = ({ @@ -42,7 +49,11 @@ export const filterOutEntityCusEnts = ({ return cusEnts.filter( (ce) => nullish(ce.entitlement.entity_feature_id) && - nullish(ce.customer_product?.internal_entity_id), + nullish(ce.customer_product?.internal_entity_id) && + // NEW: Filter out new approach (internal_entity_id on cusEnt) + nullish(ce.internal_entity_id) && + // OLD: Filter out old approach (entities object) + (!ce.entities || Object.keys(ce.entities).length === 0), ); }; diff --git a/shared/utils/cusProductUtils/filterCusProductUtils.ts b/shared/utils/cusProductUtils/filterCusProductUtils.ts index 6c3afc180..eb27b3a29 100644 --- a/shared/utils/cusProductUtils/filterCusProductUtils.ts +++ b/shared/utils/cusProductUtils/filterCusProductUtils.ts @@ -18,7 +18,7 @@ export const filterCusProductsByEntity = ({ entity: Entity; org: Organization; }): FullCusProduct[] => { - return cusProducts.filter((p: FullCusProduct) => { + return cusProducts?.filter((p: FullCusProduct) => { if (org.config.entity_product) { return ( notNullish(p.internal_entity_id) && @@ -30,24 +30,31 @@ export const filterCusProductsByEntity = ({ p.internal_entity_id === entity.internal_id || nullish(p.internal_entity_id) ); - }); + }) || []; }; -export const filterEntityLevelCusProducts = ({ - cusProducts, +export const filterEntityLevelCustomerEntitlementsFromFullCustomer = ({ + fullCustomer, }: { - cusProducts: FullCusProduct[]; -}): FullCusProduct[] => { - const finalCusProducts: FullCusProduct[] = structuredClone(cusProducts); + fullCustomer: FullCustomer; +}): FullCustomer => { + const finalCusProducts: FullCusProduct[] = structuredClone(fullCustomer?.customer_products || []); for (let i = 0; i < finalCusProducts.length; i++) { if (notNullish(finalCusProducts[i].internal_entity_id)) continue; - const newCusEnts = cusProducts[i].customer_entitlements.filter((ce) => + const newCusEnts = finalCusProducts[i].customer_entitlements.filter((ce) => notNullish(ce.entitlement.entity_feature_id), ); finalCusProducts[i].customer_entitlements = newCusEnts; } + // Filter extra_customer_entitlements to keep only entity-level ones + const finalExtraCusEnts = structuredClone(fullCustomer?.extra_customer_entitlements || []).filter((ce) => + // NEW APPROACH: has internal_entity_id + notNullish(ce.internal_entity_id) || + // OLD APPROACH: has entities object with data + (ce.entities && Object.keys(ce.entities).length > 0), + ); // finalCusProducts = finalCusProducts.filter((cp: FullCusProduct) => { // // 1. If no cusEnts, return false @@ -69,21 +76,39 @@ export const filterEntityLevelCusProducts = ({ // return false; // }); - return finalCusProducts; + return { + ...fullCustomer, + customer_products: finalCusProducts, + extra_customer_entitlements: finalExtraCusEnts, + } satisfies FullCustomer; }; -export const filterOutEntitiesFromCusProducts = ({ - cusProducts, +export const filterOutEntitiesFromFullCustomer = ({ + fullCus, }: { - cusProducts: FullCusProduct[]; -}): FullCusProduct[] => { + fullCus: FullCustomer; +}): FullCustomer => { // 1. Remove cus products with internal_entity_id - const finalCusProducts = structuredClone(cusProducts).filter( + const finalCusProducts = structuredClone(fullCus?.customer_products || []).filter( (p: FullCusProduct) => { return nullish(p.internal_entity_id); }, ); + // Filter extra_customer_entitlements to remove entity-level ones + const finalExtraCusEnts = structuredClone(fullCus?.extra_customer_entitlements || []).filter( + (cusEnt: FullCustomerEntitlement) => { + return ( + // Must NOT have entity_feature_id (per-entity feature) + nullish(cusEnt.entitlement.entity_feature_id) && + // Must NOT have internal_entity_id (new approach) + nullish(cusEnt.internal_entity_id) && + // Must NOT have entities object (old approach) + (!cusEnt.entities || Object.keys(cusEnt.entities).length === 0) + ); + }, + ); + // 2. Remove cus products with entity balances... for (let i = 0; i < finalCusProducts.length; i++) { finalCusProducts[i].customer_entitlements = finalCusProducts[ @@ -93,7 +118,11 @@ export const filterOutEntitiesFromCusProducts = ({ }); } - return finalCusProducts; + return { + ...fullCus, + customer_products: finalCusProducts, + extra_customer_entitlements: finalExtraCusEnts, + } satisfies FullCustomer; }; export const getActiveCusProducts = ({ diff --git a/vite/src/views/admin/adminUtils.ts b/vite/src/views/admin/adminUtils.ts index 81486c4cb..3a0b5be7e 100644 --- a/vite/src/views/admin/adminUtils.ts +++ b/vite/src/views/admin/adminUtils.ts @@ -79,14 +79,34 @@ export const getCusEntHoverTexts = ({ }, ]; - if (featureEntities.length > 0) { + // NEW APPROACH: Check if cusEnt has internal_entity_id (entity-level loose entitlement) + if (cusEnt.internal_entity_id) { + const entity = entities.find( + (e: Entity) => e.internal_id === cusEnt.internal_entity_id, + ); + if (entity) { + hoverTexts.push({ + key: "Entity", + value: `${entity.id} (${entity.name})${entity.deleted ? " Deleted" : ""}`, + }); + } + // Always show internal_entity_id for debugging + hoverTexts.push({ + key: "Internal Entity ID", + value: cusEnt.internal_entity_id, + }); + } + // Check for per-entity features (features that ARE entity types) + else if (featureEntities.length > 0) { hoverTexts.push({ key: "Entities", value: featureEntities .map((e: Entity) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`) .join("\n"), }); - } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { + } + // OLD APPROACH: entities object with per-entity balances + else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { const mappedEntities = Object.keys(cusEnt.entities) .map((e: string) => { const entity = entities.find((ee: Entity) => ee.id === e); From 4387c58b9dd81545c9709294f65022c7421a8732 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:56:58 +0000 Subject: [PATCH 13/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20script=20g1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/testGroups/g1.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 777d43f91..60eff6fa8 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -26,7 +26,7 @@ BUN_PARALLEL_COMPACT \ 'server/tests/balances/check/misc' \ 'server/tests/balances/check/prepaid' \ 'server/tests/balances/check/send-event' \ - 'server/tests/balances/check/loose + 'server/tests/balances/check/loose' \ --max=6 From 5e2739676a3b985e0573800cd145d4f64768cb44 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:01:18 +0000 Subject: [PATCH 14/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20old=20cusenttokey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/cusEntUtils/convertCusEntUtils.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 53c24be9c..5f8e579a3 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -11,23 +11,6 @@ import { getCusEntBalance } from "./balanceUtils.js"; import { getRolloverFields } from "./getRolloverFields.js"; import { getStartingBalance } from "./getStartingBalance.js"; -export const cusEntToKey = ({ - cusEnt, -}: { - cusEnt: FullCusEntWithFullCusProduct; -}) => { - // Interval - const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`; - - const planId = cusEnt.customer_product - ? `${cusEnt.customer_product.product_id}` - : `extra:${cusEnt.id}`; - - const usageModel = `${cusEnt.usage_allowed}`; - - return `${interval}:${planId}:${usageModel}`; -}; - export const cusEntsToPlanId = ({ cusEnts, }: { @@ -38,7 +21,7 @@ export const cusEntsToPlanId = ({ for (const cusEnt of cusEnts) { const planId = cusEnt.customer_product?.product?.id; - uniquePlanIds.add(planId); + if (planId) uniquePlanIds.add(planId); } if (uniquePlanIds.size > 1) { From cb181e03975f0d85b9e3cb6106a8f6c082994536 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:05:59 +0000 Subject: [PATCH 15/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20expires=20at=20db?= =?UTF-8?q?=20and=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balances/create/createBalanceParams.ts | 11 +++++++- .../api/customers/cusFeatures/apiBalance.ts | 1 + .../cusEntModels/cusEntModels.ts | 3 ++ .../cusEntModels/cusEntTable.ts | 3 ++ .../CustomerBalanceTableColumns.tsx | 28 +++++++++++++------ 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index 7253fefa2..5438a0266 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -11,6 +11,7 @@ export const CreateBalanceSchema = z.object({ interval_count: z.number().optional(), }) .optional(), + expires_at: z.number().optional(), // Unix timestamp in milliseconds customer_id: z.string(), entity_id: z.string().optional(), }).refine((data) => { @@ -27,7 +28,7 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ } if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval) { + if (data.granted_balance || data.unlimited || data.reset?.interval || data.expires_at) { return false; } } @@ -45,6 +46,14 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ } return true; +}).refine((data) => { + // expires_at and reset interval are mutually exclusive (for all non-boolean feature types) + if (data.expires_at && data.reset?.interval) { + return false; + } + return true; +}, { + message: "expires_at and reset interval are mutually exclusive - a balance cannot have both", }); export type CreateBalanceParams = z.infer; diff --git a/shared/api/customers/cusFeatures/apiBalance.ts b/shared/api/customers/cusFeatures/apiBalance.ts index 588e7bc14..6b8dcae0f 100644 --- a/shared/api/customers/cusFeatures/apiBalance.ts +++ b/shared/api/customers/cusFeatures/apiBalance.ts @@ -28,6 +28,7 @@ export const ApiBalanceBreakdownSchema = z.object({ // Extra fields prepaid_quantity: z.number().default(0), + expires_at: z.number().nullable().optional(), // For loose entitlements with expiry }); export const ApiBalanceSchema = z.object({ diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index 4ce88bc5d..96c2c46b8 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -40,6 +40,9 @@ export const CustomerEntitlementSchema = z.object({ next_reset_at: z.number().nullable(), adjustment: z.number().nullish().default(0), + // Expiry for loose entitlements (entitlements without reset intervals) + expires_at: z.number().nullable(), + // Group by fields entities: z.record(z.string(), EntityBalanceSchema).nullish(), }); diff --git a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts index 16ce57f96..83ac51e08 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts @@ -39,6 +39,9 @@ export const customerEntitlements = pgTable( // Need to work on free balance... entities: jsonb("entities").$type>(), + // Expiry for loose entitlements (entitlements without reset intervals) + expires_at: numeric({ mode: "number" }), + // Optional... customer_id: text("customer_id"), feature_id: text("feature_id"), 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 8ed4c882c..09590dc3a 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx @@ -65,16 +65,28 @@ function BarCell({ entityId, }); + // Determine whether to show reset or expiry info + const hasReset = ent.next_reset_at != null; + const hasExpiry = ent.expires_at != null; + return (
- - Resets {formatUnixToDateTimeString(ent.next_reset_at)} - + {hasExpiry ? ( + + Expires {formatUnixToDateTimeString(ent.expires_at)} + + ) : ( + + Resets {formatUnixToDateTimeString(ent.next_reset_at)} + + )}
Date: Tue, 6 Jan 2026 15:06:35 +0000 Subject: [PATCH 16/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20backend=20for=20ex?= =?UTF-8?q?pirable=20loose=20extra=20ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deductionLuaScripts/batchDeduction.lua | 28 ++- .../luaUtils/filterBalanceUtils.lua | 45 +++- .../src/_luaScripts/luaUtils/loadBalances.lua | 124 ++++++++++- .../_luaScripts/luaUtils/storeBalances.lua | 3 +- .../prepareNewBalanceForInsertion.ts | 10 + .../createBalance/validateCreateBalance.ts | 3 + .../balances/handlers/handleCreateBalance.ts | 4 +- .../customers/add-product/initCusEnt.ts | 3 + .../cusEnts/CusEntitlementService.ts | 1 + .../getApiBalance/getApiBalance.ts | 4 + .../src/internal/customers/getFullCusQuery.ts | 1 + .../balances/check/loose/loose-expiry.test.ts | 205 ++++++++++++++++++ .../balances/track/loose/loose-expiry.test.ts | 171 +++++++++++++++ 13 files changed, 578 insertions(+), 24 deletions(-) create mode 100644 server/tests/balances/check/loose/loose-expiry.test.ts create mode 100644 server/tests/balances/track/loose/loose-expiry.test.ts diff --git a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua index b509f3b24..9fb3cb04d 100644 --- a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua +++ b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua @@ -42,6 +42,10 @@ local cacheKey = buildCustomerCacheKey(orgId, env, customerId) -- Parse requests local requests = cjson.decode(requestsJson) +-- Get current time in milliseconds for expiry checks +local nowMs = redis.call("TIME") +nowMs = tonumber(nowMs[1]) * 1000 + math.floor(tonumber(nowMs[2]) / 1000) + -- Check if customer exists local customerExists = redis.call("EXISTS", cacheKey) if customerExists == 0 then @@ -167,8 +171,8 @@ local function deductFromCurrentBalance(ctx, target, entityId, cusFeature, amoun for index, breakdown in ipairs(cusFeature.breakdown) do if remaining == 0 then break end - -- Check if this breakdown matches the filters - if breakdownMatchesFilters(breakdown, filters) then + -- Check if this breakdown matches the filters (and is not expired) + if breakdownMatchesFilters(breakdown, filters, nowMs) then local breakdownCurrentBalance = breakdown.current_balance or 0 -- For refunds (negative amount), always apply. For deductions, only if balance > 0 if remaining < 0 or breakdownCurrentBalance > 0 then @@ -210,8 +214,8 @@ local function deductFromCurrentBalance(ctx, target, entityId, cusFeature, amoun end else -- No breakdowns: deduct from top-level current_balance - -- Check if top-level cusFeature matches the filters (treat as single-item breakdown) - if breakdownMatchesFilters(cusFeature, filters) then + -- Check if top-level cusFeature matches the filters (treat as single-item breakdown, and is not expired) + if breakdownMatchesFilters(cusFeature, filters, nowMs) then local topLevelCurrentBalance = cusFeature.current_balance or 0 -- For refunds (negative amount), always apply. For deductions, only if balance > 0 if remaining < 0 or topLevelCurrentBalance > 0 then @@ -283,8 +287,8 @@ local function deductPositiveAmountFromOverage(ctx, target, entityId, cusFeature for index, breakdown in ipairs(cusFeature.breakdown) do if remaining <= 0 then break end - -- Check if this breakdown matches the filters and allows overage - if breakdownMatchesFilters(breakdown, filters) then + -- Check if this breakdown matches the filters (and is not expired) and allows overage + if breakdownMatchesFilters(breakdown, filters, nowMs) then -- Check if this breakdown explicitly allows overage -- Only deduct from breakdowns that have overage_allowed=true -- "allow" mode bypasses this check @@ -317,8 +321,8 @@ local function deductPositiveAmountFromOverage(ctx, target, entityId, cusFeature end else -- No breakdowns: deduct from top-level overage - -- Check if top-level cusFeature matches the filters (treat as single-item breakdown) - if breakdownMatchesFilters(cusFeature, filters) then + -- Check if top-level cusFeature matches the filters (treat as single-item breakdown, and is not expired) + if breakdownMatchesFilters(cusFeature, filters, nowMs) then local topLevelPurchasedBalance = cusFeature.purchased_balance or 0 -- Calculate availableCapacity: nil if unlimited, otherwise max_purchase - purchased_balance local availableCapacity @@ -364,8 +368,8 @@ local function deductNegativeAmountFromOverage(ctx, target, entityId, cusFeature for index, breakdown in ipairs(cusFeature.breakdown) do if remaining >= 0 then break end - -- Check if this breakdown matches the filters - if breakdownMatchesFilters(breakdown, filters) then + -- Check if this breakdown matches the filters (and is not expired) + if breakdownMatchesFilters(breakdown, filters, nowMs) then -- "allow" mode bypasses overage_allowed check for refunds too -- Allocated features (continuous use) automatically bypass breakdown-level overage check local breakdownAllowOverage = breakdown.overage_allowed == true or overageBehavior == "allow" or allocatedFeatureBypass @@ -387,8 +391,8 @@ local function deductNegativeAmountFromOverage(ctx, target, entityId, cusFeature end else -- No breakdowns: refund from top-level overage - -- Check if top-level cusFeature matches the filters - if breakdownMatchesFilters(cusFeature, filters) then + -- Check if top-level cusFeature matches the filters (and is not expired) + if breakdownMatchesFilters(cusFeature, filters, nowMs) then local topLevelPurchasedBalance = cusFeature.purchased_balance or 0 local topLevelPrepaidQuantity = cusFeature.prepaid_quantity or 0 -- Can only decrement purchased_balance down to prepaid_quantity (prepaid credits can't be refunded) diff --git a/server/src/_luaScripts/luaUtils/filterBalanceUtils.lua b/server/src/_luaScripts/luaUtils/filterBalanceUtils.lua index f347de1e0..0b829dc32 100644 --- a/server/src/_luaScripts/luaUtils/filterBalanceUtils.lua +++ b/server/src/_luaScripts/luaUtils/filterBalanceUtils.lua @@ -7,11 +7,39 @@ -- FILTER HELPERS (must be defined first - used by other functions) -- ============================================================================ +-- Check if a breakdown item has expired +-- @param item table - Breakdown item with optional expires_at field +-- @param nowMs number - Current time in milliseconds (Unix timestamp) +-- @return boolean - true if expired, false otherwise +local function isBreakdownExpired(item, nowMs) + if not item.expires_at then + return false + end + + -- expires_at is null/cjson.null means no expiry + if item.expires_at == cjson.null then + return false + end + + local expiresAt = tonumber(item.expires_at) + if not expiresAt then + return false + end + + return expiresAt <= nowMs +end + -- Check if a breakdown item matches the given filters -- @param item table - Breakdown item (or top-level balance treated as single item) -- @param filters table|nil - Filter criteria { id?: string, interval?: string } +-- @param nowMs number|nil - Current time in milliseconds for expiry check (optional) -- @return boolean -local function breakdownMatchesFilters(item, filters) +local function breakdownMatchesFilters(item, filters, nowMs) + -- Check expiry first (if nowMs is provided) + if nowMs and isBreakdownExpired(item, nowMs) then + return false + end + if not filters then return true end @@ -79,8 +107,9 @@ end -- Handles both breakdown and non-breakdown cases -- @param balance table|nil - Balance object (with optional breakdown array) -- @param filters table|nil - Filter criteria { id?: string, interval?: string } +-- @param nowMs number|nil - Current time in milliseconds for expiry check (optional) -- @return number - The total backend balance (filtered if filters provided) -local function balanceToBackendBalance(balance, filters) +local function balanceToBackendBalance(balance, filters, nowMs) if not balance then return 0 end @@ -90,7 +119,7 @@ local function balanceToBackendBalance(balance, filters) if not hasRealBreakdowns then -- No breakdowns: use top-level cusFeature calculation - if breakdownMatchesFilters(balance, filters) then + if breakdownMatchesFilters(balance, filters, nowMs) then return cusFeatureToBackendBalance(balance) end return 0 @@ -99,7 +128,7 @@ local function balanceToBackendBalance(balance, filters) -- Sum backend balances across filtered breakdowns local totalBackendBalance = 0 for _, breakdown in ipairs(breakdowns) do - if breakdownMatchesFilters(breakdown, filters) then + if breakdownMatchesFilters(breakdown, filters, nowMs) then totalBackendBalance = totalBackendBalance + breakdownToBackendBalance(breakdown) end end @@ -114,8 +143,9 @@ end -- Filter a balance's breakdown items and return the sum of matching current_balances -- @param balance table|nil - Balance object (with optional breakdown array) -- @param filters table - Filter criteria { id?: string, interval?: string } +-- @param nowMs number|nil - Current time in milliseconds for expiry check (optional) -- @return table - { filteredBalance: number, filteredBreakdownIndices: array, matchedBreakdownIds: array } -local function loadFilteredBalance(balance, filters) +local function loadFilteredBalance(balance, filters, nowMs) local result = { filteredBalance = 0, filteredBreakdownIndices = {}, @@ -135,13 +165,14 @@ local function loadFilteredBalance(balance, filters) breakdowns = {{ id = balance.id, current_balance = balance.current_balance, - reset = balance.reset + reset = balance.reset, + expires_at = balance.expires_at }} end -- Filter breakdowns and accumulate results for index, breakdown in ipairs(breakdowns) do - if breakdownMatchesFilters(breakdown, filters) then + if breakdownMatchesFilters(breakdown, filters, nowMs) then result.filteredBalance = result.filteredBalance + toNum(breakdown.current_balance) -- Only track indices for real breakdowns (not virtual single-item) diff --git a/server/src/_luaScripts/luaUtils/loadBalances.lua b/server/src/_luaScripts/luaUtils/loadBalances.lua index f33ad74a0..45cce7b7f 100644 --- a/server/src/_luaScripts/luaUtils/loadBalances.lua +++ b/server/src/_luaScripts/luaUtils/loadBalances.lua @@ -107,7 +107,8 @@ local function fetchBreakdown(cacheKey, featureId, breakdownCount) current_balance = true, usage = true, max_purchase = true, - prepaid_quantity = true + prepaid_quantity = true, + expires_at = true } local breakdownBooleanFields = { @@ -385,6 +386,112 @@ local function mergeFeatureBalances(targetBalance, sourceBalance) end end +-- ============================================================================ +-- FILTER EXPIRED BREAKDOWNS +-- ============================================================================ + +-- Filter out expired breakdown items from all balances and recalculate totals +-- @param balances table - Map of featureId -> balance object +-- @param nowMs number - Current time in milliseconds +-- @return table - Filtered balances (may remove entire features if all breakdowns expired) +local function filterExpiredBreakdowns(balances, nowMs) + if not balances then return balances end + + local filteredBalances = {} + + for featureId, balance in pairs(balances) do + if not balance.breakdown or #balance.breakdown == 0 then + -- No breakdown - check top-level expires_at + if not balance.expires_at or balance.expires_at == cjson.null or balance.expires_at > nowMs then + filteredBalances[featureId] = balance + end + else + -- Has breakdown - filter expired items + local validBreakdowns = {} + local totalGranted = 0 + local totalPurchased = 0 + local totalCurrent = 0 + local totalUsage = 0 + + for _, bd in ipairs(balance.breakdown) do + local expiresAt = bd.expires_at + local isExpired = expiresAt and expiresAt ~= cjson.null and expiresAt <= nowMs + + if not isExpired then + table.insert(validBreakdowns, bd) + totalGranted = totalGranted + toNum(bd.granted_balance) + totalPurchased = totalPurchased + toNum(bd.purchased_balance) + totalCurrent = totalCurrent + toNum(bd.current_balance) + totalUsage = totalUsage + toNum(bd.usage) + end + end + + if #validBreakdowns > 0 then + -- Update balance with filtered breakdowns and recalculated totals + balance.breakdown = validBreakdowns + balance.granted_balance = totalGranted + balance.purchased_balance = totalPurchased + balance.current_balance = totalCurrent + balance.usage = totalUsage + + -- Recalculate reset object from remaining breakdowns + local firstInterval = nil + local firstIntervalCount = nil + local minResetsAt = nil + local hasMultipleIntervals = false + + for _, bd in ipairs(validBreakdowns) do + local bdReset = bd.reset + if bdReset and bdReset ~= cjson.null and type(bdReset) == "table" then + local bdInterval = bdReset.interval + local bdIntervalCount = bdReset.interval_count + local bdResetsAt = bdReset.resets_at + + -- Track if we have multiple different intervals + if firstInterval == nil then + firstInterval = bdInterval + firstIntervalCount = bdIntervalCount + elseif bdInterval ~= firstInterval or bdIntervalCount ~= firstIntervalCount then + hasMultipleIntervals = true + end + + -- Track minimum resets_at + if bdResetsAt and type(bdResetsAt) == "number" then + if minResetsAt == nil or bdResetsAt < minResetsAt then + minResetsAt = bdResetsAt + end + end + end + end + + -- Update balance.reset based on remaining breakdowns + if firstInterval then + if hasMultipleIntervals then + balance.reset = { + interval = "multiple", + resets_at = minResetsAt + } + else + balance.reset = { + interval = firstInterval, + interval_count = firstIntervalCount, + resets_at = minResetsAt + } + end + else + -- No breakdowns have reset info + balance.reset = nil + end + + filteredBalances[featureId] = balance + end + -- If no valid breakdowns remain, the feature is omitted entirely + end + end + + return filteredBalances +end + -- ============================================================================ -- LOAD SINGLE BALANCE (WITH _key FIELDS FOR REDIS OPERATIONS) -- ============================================================================ @@ -572,7 +679,10 @@ local function loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityI end end - return mergedBalances + -- Filter out expired breakdowns + local nowMs = redis.call("TIME") + nowMs = tonumber(nowMs[1]) * 1000 + math.floor(tonumber(nowMs[2]) / 1000) + return filterExpiredBreakdowns(mergedBalances, nowMs) end -- Load customer balances with merged entity balances @@ -630,7 +740,10 @@ local function loadBalances(cacheKey, orgId, env, customerId, entityId) customerBalances[featureId] = balanceData end - return customerBalances + -- Filter out expired breakdowns + local nowMs = redis.call("TIME") + nowMs = tonumber(nowMs[1]) * 1000 + math.floor(tonumber(nowMs[2]) / 1000) + return filterExpiredBreakdowns(customerBalances, nowMs) end -- If entityId is provided, load entity-level balances (entity + customer merged) @@ -827,6 +940,11 @@ local function loadBalances(cacheKey, orgId, env, customerId, entityId) end end + -- Filter out expired breakdowns + local nowMs = redis.call("TIME") + nowMs = tonumber(nowMs[1]) * 1000 + math.floor(tonumber(nowMs[2]) / 1000) + balances = filterExpiredBreakdowns(balances, nowMs) + -- Return merged balances return balances end \ No newline at end of file diff --git a/server/src/_luaScripts/luaUtils/storeBalances.lua b/server/src/_luaScripts/luaUtils/storeBalances.lua index f5b7709a7..9738c4217 100644 --- a/server/src/_luaScripts/luaUtils/storeBalances.lua +++ b/server/src/_luaScripts/luaUtils/storeBalances.lua @@ -101,7 +101,8 @@ local function storeBalances(cacheKey, balances) "overage_allowed", toString(breakdownItem.overage_allowed), "reset", breakdownResetJson, "plan_id", toString(breakdownItem.plan_id), - "prepaid_quantity", toString(breakdownItem.prepaid_quantity) + "prepaid_quantity", toString(breakdownItem.prepaid_quantity), + "expires_at", toString(breakdownItem.expires_at) ) redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS) end diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index 9667ff32a..2c1e8719c 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -19,6 +19,7 @@ export const prepareNewBalanceForInsertion = async ({ granted_balance, unlimited, reset, + expires_at, fullCus, feature_id, entity, @@ -28,6 +29,7 @@ export const prepareNewBalanceForInsertion = async ({ granted_balance: number | undefined; unlimited: boolean | undefined; reset: z.infer["reset"]; + expires_at: number | undefined; fullCus: FullCustomer; feature_id: string; entity?: Entity; @@ -82,6 +84,7 @@ export const prepareNewBalanceForInsertion = async ({ replaceables: [], now: Date.now(), productOptions: undefined, + expires_at: expires_at ?? null, }) satisfies CustomerEntitlement; // If entity is provided, assign balance to entity instead of customer-level @@ -89,6 +92,13 @@ export const prepareNewBalanceForInsertion = async ({ newCustomerEntitlement.internal_entity_id = entity.internal_id; } + // Set expiry if provided (mutually exclusive with reset interval) + if (expires_at) { + newCustomerEntitlement.expires_at = expires_at; + // Clear next_reset_at since expiring entitlements don't reset + newCustomerEntitlement.next_reset_at = null; + } + return { newEntitlement, newCustomerEntitlement, diff --git a/server/src/internal/balances/createBalance/validateCreateBalance.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts index 7d70bcebc..2a89df3e4 100644 --- a/server/src/internal/balances/createBalance/validateCreateBalance.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -18,6 +18,7 @@ export const validateCreateBalanceParams = async ({ granted_balance, unlimited, reset, + expires_at, fullCustomer, entity_id, }: { @@ -27,6 +28,7 @@ export const validateCreateBalanceParams = async ({ granted_balance: number | undefined; unlimited: boolean | undefined; reset: z.infer["reset"]; + expires_at: number | undefined; fullCustomer: FullCustomer; entity_id?: string; }) => { @@ -35,6 +37,7 @@ export const validateCreateBalanceParams = async ({ granted_balance, unlimited, reset, + expires_at, customer_id: internalCustomerId, feature_id: feature.id, entity_id, diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index c78a84d5b..8bbfa703a 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -17,7 +17,7 @@ export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, handler: async (c) => { const ctx = c.get("ctx"); - const { feature_id, customer_id, entity_id, granted_balance, unlimited, reset } = + const { feature_id, customer_id, entity_id, granted_balance, unlimited, reset, expires_at } = c.req.valid("json"); const feature = ctx.features.find((f) => f.id === feature_id); @@ -46,6 +46,7 @@ export const handleCreateBalance = createRoute({ granted_balance, unlimited, reset, + expires_at, fullCustomer, entity_id, }); @@ -57,6 +58,7 @@ export const handleCreateBalance = createRoute({ granted_balance, unlimited, reset, + expires_at, fullCus: fullCustomer, entity: entity_id ? fullCustomer.entities.find((e) => e.id === entity_id) : undefined, feature_id, diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index a56b9c291..8fdeaf553 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -118,6 +118,7 @@ export const initCusEntitlement = ({ replaceables, now, productOptions, + expires_at, }: { entitlement: EntitlementWithFeature; customer: Customer; @@ -137,6 +138,7 @@ export const initCusEntitlement = ({ replaceables: AttachReplaceable[]; now?: number; productOptions?: ProductOptions; + expires_at?: number | null; }) => { now = now || Date.now(); let { newBalance, newEntities } = initCusEntBalance({ @@ -204,5 +206,6 @@ export const initCusEntitlement = ({ entities: newEntities, usage_allowed: usageAllowed, next_reset_at: nextResetAtValue, + expires_at: expires_at ?? null, }; }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 4fa060645..a52f8bf8e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -181,6 +181,7 @@ export class CusEntService { .where( and( isNull(customerEntitlements.customer_product_id), + isNull(customerEntitlements.expires_at), // Ignore entitlements with expiry (they don't reset) lt( customerEntitlements.next_reset_at, customDateUnix ?? Date.now(), diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 245ae8f59..3f878af55 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -78,6 +78,9 @@ const cusEntsToBreakdown = ({ const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); const planId = cusEntsToPlanId({ cusEnts }); + // Get expires_at from the first cusEnt (since key is cusEnt.id, there's only one) + const expiresAt = cusEnts[0]?.expires_at ?? null; + breakdown.push({ key, breakdown: ApiBalanceBreakdownSchema.parse({ @@ -95,6 +98,7 @@ const cusEntsToBreakdown = ({ reset: reset, prepaid_quantity: prepaidQuantity, + expires_at: expiresAt, }), prepaidQuantity: prepaidQuantity, }); diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 1853b81aa..4a109c489 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -216,6 +216,7 @@ const buildExtraEntitlementsCTE = () => { FROM customer_entitlements ce WHERE ce.internal_customer_id = (SELECT internal_id FROM customer_record) AND ce.customer_product_id IS NULL + AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) ) `; }; diff --git a/server/tests/balances/check/loose/loose-expiry.test.ts b/server/tests/balances/check/loose/loose-expiry.test.ts new file mode 100644 index 000000000..07b7ea26b --- /dev/null +++ b/server/tests/balances/check/loose/loose-expiry.test.ts @@ -0,0 +1,205 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponseV2, + ResetInterval, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +/** + * Sleep until a specific epoch time in milliseconds + */ +function sleepUntil(epochMs: number): Promise { + const delay = epochMs - Date.now(); + + if (delay <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check-loose-expiry"; + +describe(`${chalk.yellowBright(`${testCase}: expiring loose entitlement check`)}`, () => { + const customerBasic = `${testCase}-basic`; + const customerProductMix = `${testCase}-prod`; + const customerResetMix = `${testCase}-reset`; + + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + // Setup products + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Setup customers only + await initCustomerV3({ + ctx, + customerId: customerBasic, + withTestClock: false, + }); + + await initCustomerV3({ + ctx, + customerId: customerProductMix, + withTestClock: false, + }); + + await initCustomerV3({ + ctx, + customerId: customerResetMix, + withTestClock: false, + }); + }); + + test("basic: expiring loose entitlement should be allowed before expiry, then denied after", async () => { + const expiresAt = Date.now() + 3000; + + // Create expiring loose entitlement + await autumnV1.balances.create({ + customer_id: customerBasic, + feature_id: TestFeature.Messages, + granted_balance: 500, + expires_at: expiresAt, + }); + + // Check before expiry + const resBefore = (await autumnV2.check({ + customer_id: customerBasic, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore.allowed).toBe(true); + expect(resBefore.customer_id).toBe(customerBasic); + expect(resBefore.balance).toBeDefined(); + expect(resBefore.balance?.plan_id).toBeNull(); + expect(resBefore.balance?.feature_id).toBe(TestFeature.Messages); + expect(resBefore.balance?.granted_balance).toBe(500); + expect(resBefore.balance?.current_balance).toBe(500); + expect(resBefore.balance?.usage).toBe(0); + expect(resBefore.balance?.unlimited).toBe(false); + + // Wait until expiry + await sleepUntil(expiresAt + 1000); + + // Check after expiry + const resAfter = (await autumnV2.check({ + customer_id: customerBasic, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfter.allowed).toBe(false); + expect(resAfter.balance).toBeNull(); + }); + + test("product-mix: should combine product and expiring loose ent, then only product after expiry", async () => { + const expiresAt = Date.now() + 3000; + + // Attach product with 100 messages + await autumnV1.attach({ + customer_id: customerProductMix, + product_id: freeProd.id, + }); + + // Create expiring loose entitlement with 200 messages + await autumnV1.balances.create({ + customer_id: customerProductMix, + feature_id: TestFeature.Messages, + granted_balance: 200, + expires_at: expiresAt, + }); + + // Check before expiry + const resBefore = (await autumnV2.check({ + customer_id: customerProductMix, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore.allowed).toBe(true); + expect(resBefore.balance?.granted_balance).toBe(300); // 100 from product + 200 from loose + expect(resBefore.balance?.current_balance).toBe(300); + + // Wait until expiry + await sleepUntil(expiresAt + 1000); + + // Check after expiry + const resAfter = (await autumnV2.check({ + customer_id: customerProductMix, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfter.allowed).toBe(true); + expect(resAfter.balance?.granted_balance).toBe(100); // Only product balance remains + expect(resAfter.balance?.current_balance).toBe(100); + }); + + test("reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry", async () => { + const expiresAt = Date.now() + 3000; + + // Create expiring loose entitlement + await autumnV1.balances.create({ + customer_id: customerResetMix, + feature_id: TestFeature.Messages, + granted_balance: 200, + expires_at: expiresAt, + }); + + // Create resetting loose entitlement (no expiry) + await autumnV1.balances.create({ + customer_id: customerResetMix, + feature_id: TestFeature.Messages, + granted_balance: 100, + reset: { + interval: ResetInterval.Month, + }, + }); + + // Check before expiry + const resBefore = (await autumnV2.check({ + customer_id: customerResetMix, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore.allowed).toBe(true); + expect(resBefore.balance?.granted_balance).toBe(300); // 200 expiring + 100 resetting + expect(resBefore.balance?.current_balance).toBe(300); + + // Wait until expiry + await sleepUntil(expiresAt + 1000); + + // Check after expiry + const resAfter = (await autumnV2.check({ + customer_id: customerResetMix, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfter.allowed).toBe(true); + expect(resAfter.balance?.granted_balance).toBe(100); // Only resetting balance remains + expect(resAfter.balance?.current_balance).toBe(100); + expect(resAfter.balance?.reset).toBeDefined(); + expect(resAfter.balance?.reset?.interval).toBe(ResetInterval.Month); + }); +}); diff --git a/server/tests/balances/track/loose/loose-expiry.test.ts b/server/tests/balances/track/loose/loose-expiry.test.ts new file mode 100644 index 000000000..455611a56 --- /dev/null +++ b/server/tests/balances/track/loose/loose-expiry.test.ts @@ -0,0 +1,171 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +/** + * Sleep until a specific epoch time in milliseconds + */ +function sleepUntil(epochMs: number): Promise { + const delay = epochMs - Date.now(); + + if (delay <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +describe(`${chalk.yellowBright("loose-expiry: track with expiring loose entitlement")}`, () => { + const customerId = "loose-expiry-track"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + // Expiry time: 3 seconds from test start + let expiresAt: number; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Set expiry to 3 seconds from now + expiresAt = Date.now() + 3000; + + // Create expiring loose entitlement with 100 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + expires_at: expiresAt, + }); + }); + + test("should deduct from expiring loose entitlement before expiry", async () => { + // Track 10 usage + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Wait for sync + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check balance - should have 90 remaining + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.granted_balance).toBe(100); + expect(res.balance?.current_balance).toBe(90); + expect(res.balance?.usage).toBe(10); + }); + + test("should not allow access after expiry", async () => { + // Wait until expiry + await sleepUntil(expiresAt + 1000); // +1s buffer + + // Check balance - should have no balance (expired) + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); + expect(res.balance).toBeNull(); + }); +}); + +describe(`${chalk.yellowBright("loose-expiry-mixed: mixed expiring and non-expiring loose ents")}`, () => { + const customerId = "loose-expiry-mixed"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + // Expiry time: 3 seconds from test start + let expiresAt: number; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Set expiry to 3 seconds from now + expiresAt = Date.now() + 3000; + + // Create expiring loose entitlement (100 messages, expires in 3s) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + expires_at: expiresAt, + }); + + // Create non-expiring loose entitlement (50 messages, never expires) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should combine expiring and non-expiring loose ents before expiry", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.granted_balance).toBe(150); // 100 + 50 + expect(res.balance?.current_balance).toBe(150); + }); + + test("should deduct across mixed loose ents", async () => { + // Track 120 (needs both ents) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(30); // 150 - 120 + expect(res.balance?.usage).toBe(120); + }); + + test("should only have non-expiring balance after expiry", async () => { + // Wait until expiry + await sleepUntil(expiresAt + 1000); // +1s buffer + + // Check balance - should only have the non-expiring 50 + // Note: The expiring ent had 100, we used 120 total + // After expiry, we should only see the non-expiring ent's remaining balance + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + // The non-expiring ent should still be accessible + // Balance depends on deduction order - let's just check it's accessible + expect(res.balance).toBeDefined(); + expect(res.balance?.granted_balance).toBe(50); // Only the non-expiring one + }); +}); From 9682cb2c76e058db56ef43787bcc7d03cb4e603e Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:16:46 +0000 Subject: [PATCH 17/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20a=20bug=20where=20y?= =?UTF-8?q?ou=20can't=20disable=20a=20default=20product?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../products/handlers/productActions/validateDefaultFlag.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/internal/products/handlers/productActions/validateDefaultFlag.ts b/server/src/internal/products/handlers/productActions/validateDefaultFlag.ts index 5decbda02..1880491b8 100644 --- a/server/src/internal/products/handlers/productActions/validateDefaultFlag.ts +++ b/server/src/internal/products/handlers/productActions/validateDefaultFlag.ts @@ -88,7 +88,10 @@ export const validateDefaultFlag = async ({ curProduct?: FullProduct; }) => { const validate = (): { type: "free" | "default_trial" | undefined } => { - const isDefault = body.is_default || curProduct?.is_default || false; + const isDefault = + body.is_default !== undefined + ? body.is_default + : curProduct?.is_default || false; if (!isDefault) return { type: undefined }; // If default, check if there are any prices...? From bce668eb7a75088724e3b83cc7783bcc5bd87413 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:54:59 +0000 Subject: [PATCH 18/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/internal/balances/track/trackUtils/runDeductionTx.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 9219849cb..1fbf67fe3 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -118,8 +118,8 @@ export const deductFromCusEnts = async ({ reverseOrder: org.config?.reverse_deduction_order, entity: fullCus.entity, inStatuses: orgToInStatuses({ org }), - // customerEntitlementFilters, - // isRefund, + customerEntitlementFilters, + isRefund, }); // Debug: log sort order From f07c2b7d2402ecae2498b731c32f4eeb1740eb0d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:10:39 +0000 Subject: [PATCH 19/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20misc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balances/handlers/handleCreateBalance.ts | 36 ++++++++++--------- .../deduction/prepareFeatureDeduction.ts | 6 ++-- .../convertCusProduct/cusProductsToCusEnts.ts | 34 ------------------ shared/utils/index.ts | 2 +- 4 files changed, 24 insertions(+), 54 deletions(-) delete mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 8bbfa703a..4bcb5a197 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,24 +1,27 @@ -import { - CreateBalanceSchema, - EntityNotFoundError -} from "@autumn/shared"; +import { CreateBalanceSchema, EntityNotFoundError } from "@autumn/shared"; import { FeatureNotFoundError } from "@shared/index"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { prepareNewBalanceForInsertion } from "../createBalance/prepareNewBalanceForInsertion"; -import { - validateCreateBalanceParams -} from "../createBalance/validateCreateBalance"; +import { validateCreateBalanceParams } from "../createBalance/validateCreateBalance"; export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, handler: async (c) => { const ctx = c.get("ctx"); - const { feature_id, customer_id, entity_id, granted_balance, unlimited, reset, expires_at } = - c.req.valid("json"); + const { org, env } = ctx; + const { + feature_id, + customer_id, + entity_id, + granted_balance, + unlimited, + reset, + expires_at, + } = c.req.valid("json"); const feature = ctx.features.find((f) => f.id === feature_id); if (!feature) { @@ -28,8 +31,8 @@ export const handleCreateBalance = createRoute({ const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, - orgId: ctx.org.id, - env: ctx.env, + orgId: org.id, + env: env, withEntities: true, }); @@ -60,7 +63,9 @@ export const handleCreateBalance = createRoute({ reset, expires_at, fullCus: fullCustomer, - entity: entity_id ? fullCustomer.entities.find((e) => e.id === entity_id) : undefined, + entity: entity_id + ? fullCustomer.entities.find((e) => e.id === entity_id) + : undefined, feature_id, }); @@ -74,10 +79,9 @@ export const handleCreateBalance = createRoute({ data: [newCustomerEntitlement], }); - await deleteCachedApiCustomer({ - orgId: ctx.org.id, - env: ctx.env, + await deleteCachedFullCustomer({ customerId: customer_id, + ctx, source: "handleCreateBalance", }); diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index ed15bbecf..f79a49c83 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -43,9 +43,9 @@ export const prepareFeatureDeduction = ({ const relevantFeatures = notNullish(targetBalance) ? [feature] : getRelevantFeatures({ - features: ctx.features, - featureId: feature.id, - }); + features: ctx.features, + featureId: feature.id, + }); // Get customer entitlements for these features const cusEnts = cusProductsToCusEnts({ diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts deleted file mode 100644 index a11512c41..000000000 --- a/shared/utils/cusProductUtils/convertCusProduct/cusProductsToCusEnts.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels.js"; -import { sortCusEntsForDeduction } from "../../cusEntUtils/sortCusEntsForDeduction.js"; - -export const cusProductsToCusEnts = ({ - cusProducts, - featureId, -}: { - cusProducts: FullCusProduct[]; - featureId?: string; -}) => { - let cusEnts: FullCusEntWithFullCusProduct[] = []; - - for (const cusProduct of cusProducts) { - cusEnts.push( - ...cusProduct.customer_entitlements.map((cusEnt) => ({ - ...cusEnt, - customer_product: cusProduct, - })), - ); - } - - if (featureId) { - cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.feature.id === featureId, - ); - } - - sortCusEntsForDeduction({ - cusEnts, - }); - - return cusEnts as FullCusEntWithFullCusProduct[]; -}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 12d644cf6..268eb15bd 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -28,7 +28,7 @@ export * from "./cusEntUtils/getStartingBalance.js"; export * from "./cusEntUtils/sortCusEntsForDeduction.js"; // Cus product utils export * from "./cusProductUtils/classifyCusProduct.js"; -export * from "./cusProductUtils/convertCusProduct/cusProductsToCusEnts.js"; +export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/convertCusProduct/cusProductToCusEnts.js"; export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; export * from "./cusProductUtils/cusProductConstants.js"; From a724f30dd9d6f80fc8fca761a0d68eeb57d199c9 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:12:10 +0000 Subject: [PATCH 20/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20shouldnt=20copy=20o?= =?UTF-8?q?ver=20default=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handlers/handleCopyProduct/handleCopyProductV2.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts index f31b99bb5..9866fdab9 100644 --- a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts +++ b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts @@ -86,6 +86,10 @@ export const handleCopyProductV2 = createRoute({ }), ]); + if (fromFullProduct) { + fromFullProduct.is_default = false; + } + // 3. Sync features between environments if copying across environments if (fromEnv !== toEnv) { for (const fromFeature of fromFeatures) { From 15afa970458bd29a0421e05819772bbda1cd196f Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:07:40 +0000 Subject: [PATCH 21/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20lua=20for=20loose?= =?UTF-8?q?=20ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contextUtils.lua | 18 ++++++++++-- .../luaUtils.lua | 29 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua index bc532a292..5d83d347c 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua @@ -42,9 +42,20 @@ local function init_context(params) local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id) if cus_ent then - local cp_idx_0 = cp_idx - 1 - local ce_idx_0 = ce_idx - 1 - local base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + local base_path + local is_loose = (cp_idx == nil) -- Loose entitlement if no customer_product index + + if is_loose then + -- Loose entitlement: path is $.extra_customer_entitlements[idx] + local ece_idx_0 = ce_idx - 1 + base_path = '$.extra_customer_entitlements[' .. ece_idx_0 .. ']' + else + -- Product entitlement: path is $.customer_products[cp_idx].customer_entitlements[ce_idx] + local cp_idx_0 = cp_idx - 1 + local ce_idx_0 = ce_idx - 1 + base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + end + local has_entity_scope = ent_obj.entity_feature_id ~= nil and ent_obj.entity_feature_id ~= cjson.null local ent_data = { @@ -52,6 +63,7 @@ local function init_context(params) has_entity_scope = has_entity_scope, adjustment = cus_ent.adjustment or 0, unlimited = cus_ent.unlimited, + is_loose = is_loose, } if has_entity_scope then diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua index 765d85983..780cc2ca9 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua @@ -32,20 +32,33 @@ end -- ============================================================================ -- HELPER: Find entitlement in FullCustomer by ID --- Returns: cus_ent table, cus_product table, cus_ent_index, cus_product_index +-- Returns: cus_ent table, cus_product table (or nil for loose), cus_ent_index, cus_product_index (or nil for loose) +-- For loose entitlements: cus_product=nil and cus_product_index=nil -- ============================================================================ local function find_entitlement(full_customer, ent_id) - if not full_customer.customer_products then return nil, nil, nil, nil end - - for cp_idx, cus_product in ipairs(full_customer.customer_products) do - if cus_product.customer_entitlements then - for ce_idx, cus_ent in ipairs(cus_product.customer_entitlements) do - if cus_ent.id == ent_id then - return cus_ent, cus_product, ce_idx, cp_idx + -- Search in customer_products first + if full_customer.customer_products then + for cp_idx, cus_product in ipairs(full_customer.customer_products) do + if cus_product.customer_entitlements then + for ce_idx, cus_ent in ipairs(cus_product.customer_entitlements) do + if cus_ent.id == ent_id then + return cus_ent, cus_product, ce_idx, cp_idx + end end end end end + + -- Search in extra_customer_entitlements (loose entitlements) + if full_customer.extra_customer_entitlements then + for ece_idx, cus_ent in ipairs(full_customer.extra_customer_entitlements) do + if cus_ent.id == ent_id then + -- Return nil for cus_product and cus_product_index to indicate loose entitlement + return cus_ent, nil, ece_idx, nil + end + end + end + return nil, nil, nil, nil end From ce3123ef634152b91cc255517dc8eb3bbfdb4889 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:20:07 +0000 Subject: [PATCH 22/59] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20loose=20entitlemen?= =?UTF-8?q?ts=20v?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prepareNewBalanceForInsertion.ts | 2 +- .../applyDeductionUpdateToFullCustomer.ts | 36 +++++++++++++++++++ .../deduction/deductionToTrackResponse.ts | 10 +++--- .../utils/deduction/logDeductionUpdates.ts | 6 ++-- .../deduction/prepareFeatureDeduction.ts | 8 ++--- .../balances/utils/sync/syncItemV3.ts | 6 ++-- .../fullCustomerToCustomerEntitlements.ts | 7 ++++ 7 files changed, 59 insertions(+), 16 deletions(-) diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index 2c1e8719c..8b5d3b70b 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -79,7 +79,7 @@ export const prepareNewBalanceForInsertion = async ({ entitlement: newEntitlementWithFeature, now: Date.now(), }) ?? Date.now(), - entities: [], + entities: entity ? [entity] : [], carryExistingUsages: false, replaceables: [], now: Date.now(), diff --git a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts index 2b6d023b9..afe9801b5 100644 --- a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts +++ b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts @@ -10,6 +10,7 @@ export const applyDeductionUpdateToFullCustomer = ({ cusEntId: string; update: DeductionUpdate; }) => { + // Search in customer_products first for (let i = 0; i < fullCus.customer_products.length; i++) { for ( let j = 0; @@ -44,7 +45,42 @@ export const applyDeductionUpdateToFullCustomer = ({ adjustment: update.adjustment, replaceables, }; + return; // Found and updated, exit early } } } + + // Search in extra_customer_entitlements (loose entitlements) + for (let i = 0; i < fullCus.extra_customer_entitlements.length; i++) { + const ce = fullCus.extra_customer_entitlements[i]; + if (ce.id === cusEntId) { + let replaceables = ce.replaceables ?? []; + + if (update.newReplaceables) { + replaceables = [ + ...replaceables, + ...update.newReplaceables.map((r) => ({ + ...r, + delete_next_cycle: r.delete_next_cycle ?? true, + from_entity_id: r.from_entity_id ?? null, + })), + ]; + } + + if (update.deletedReplaceables) { + replaceables = replaceables.filter( + (r) => !update.deletedReplaceables?.map((r) => r.id).includes(r.id), + ); + } + + fullCus.extra_customer_entitlements[i] = { + ...ce, + balance: update.balance, + entities: update.entities, + adjustment: update.adjustment, + replaceables, + }; + return; // Found and updated, exit early + } + } }; diff --git a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts index 718f42b0a..0a94ad877 100644 --- a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts +++ b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts @@ -1,6 +1,6 @@ import type { ApiBalance, Feature, FullCustomer } from "@autumn/shared"; import { - cusProductsToCusEnts, + fullCustomerToCustomerEntitlements, findCustomerEntitlementById, getRelevantFeatures, } from "@autumn/shared"; @@ -28,8 +28,8 @@ export const computeActualDeductions = ({ }): Record => { const actualDeductions: Record = {}; - const customerEntitlements = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, + const customerEntitlements = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, }); for (const cusEntId of Object.keys(updates)) { @@ -68,8 +68,8 @@ const findUnlimitedFeature = ({ }); for (const feature of relevantFeatures) { - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, featureIds: [feature.id], }); diff --git a/server/src/internal/balances/utils/deduction/logDeductionUpdates.ts b/server/src/internal/balances/utils/deduction/logDeductionUpdates.ts index 52a5216ff..56f947193 100644 --- a/server/src/internal/balances/utils/deduction/logDeductionUpdates.ts +++ b/server/src/internal/balances/utils/deduction/logDeductionUpdates.ts @@ -1,5 +1,5 @@ import { - cusProductsToCusEnts, + fullCustomerToCustomerEntitlements, type FullCustomer, findCustomerEntitlementById, } from "@autumn/shared"; @@ -22,8 +22,8 @@ export const logDeductionUpdates = ({ }): void => { if (Object.keys(updates).length === 0) return; - const customerEntitlements = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, + const customerEntitlements = fullCustomerToCustomerEntitlements({ + fullCustomer, }); for (const [cusEntId, update] of Object.entries(updates)) { diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index f79a49c83..fc08ac688 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -1,6 +1,6 @@ import { cusEntToStartingBalance, - cusProductsToCusEnts, + fullCustomerToCustomerEntitlements, type FullCustomer, getMaxOverage, getRelevantFeatures, @@ -47,9 +47,9 @@ export const prepareFeatureDeduction = ({ featureId: feature.id, }); - // Get customer entitlements for these features - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, + // Get customer entitlements for these features (includes both product and loose entitlements) + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, featureIds: relevantFeatures.map((f) => f.id), reverseOrder: org.config?.reverse_deduction_order, entity: fullCustomer.entity, diff --git a/server/src/internal/balances/utils/sync/syncItemV3.ts b/server/src/internal/balances/utils/sync/syncItemV3.ts index cb6270f40..049440d76 100644 --- a/server/src/internal/balances/utils/sync/syncItemV3.ts +++ b/server/src/internal/balances/utils/sync/syncItemV3.ts @@ -1,5 +1,5 @@ import { - cusProductsToCusEnts, + fullCustomerToCustomerEntitlements, type EntityBalance, type EntityRolloverBalance, type FullCustomer, @@ -93,8 +93,8 @@ const buildSyncEntries = ({ fullCustomer: FullCustomer; cusEntIds: string[]; }): SyncEntry[] => { - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, }); const entries: SyncEntry[] = []; diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts index 65a6822c1..180e0dcd4 100644 --- a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts @@ -68,6 +68,13 @@ export const fullCustomerToCustomerEntitlements = ({ ); } + // Filter out expired entitlements (applies to loose entitlements with expires_at) + // This is necessary because cached fullCustomer may contain entitlements that have since expired + const now = Date.now(); + cusEnts = cusEnts.filter( + (cusEnt) => !cusEnt.expires_at || cusEnt.expires_at > now, + ); + sortCusEntsForDeduction({ cusEnts, reverseOrder, From 164695c36c628ea9264a3a2cb4960d59fc08bc93 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 12 Jan 2026 12:23:11 +0000 Subject: [PATCH 23/59] fix: redeem referral doesn't try to clone object --- .../referrals/handleRedeemReferral.ts | 183 ------------------ .../referralUtils/triggerFreeProduct.ts | 4 +- 2 files changed, 2 insertions(+), 185 deletions(-) diff --git a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts index 8af45a668..3afab20c1 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts @@ -182,186 +182,3 @@ export const handleRedeemReferral = createRoute({ }); }, }); - -// export default async (req: any, res: any) => -// routeHandler({ -// req, -// res, -// action: "redeem referral code", -// handler: async (req, res) => { -// const { orgId, env, logger, db } = req; -// const { code, customer_id: customerId } = req.body; - -// // 1. Get redeemed by customer, and referral code -// const [customer, referralCode, org] = await Promise.all([ -// CusService.get({ -// db, -// orgId, -// env, -// idOrInternalId: customerId, -// }), -// RewardProgramService.getReferralCode({ -// db, -// orgId, -// env, -// code, -// withRewardProgram: true, -// }), -// OrgService.getFromReq(req), -// ]); - -// if (!customer) { -// throw new RecaseError({ -// message: "Customer not found", -// statusCode: 404, -// code: ErrCode.CustomerNotFound, -// }); -// } - -// // 2. Check that code has not reached max redemptions -// const redemptionCount = await RewardProgramService.getCodeRedemptionCount( -// { -// db, -// referralCodeId: referralCode.id, -// }, -// ); - -// if ( -// referralCode.reward_program.max_redemptions && -// redemptionCount >= referralCode.reward_program.max_redemptions -// ) { -// throw new RecaseError({ -// message: "Referral code has reached max redemptions", -// statusCode: 400, -// code: ErrCode.ReferralCodeMaxRedemptionsReached, -// }); -// } - -// // 3. Check that customer has not already redeemed a code in this referral program -// const existingRedemptions = await RewardRedemptionService.getByCustomer({ -// db, -// internalCustomerId: customer.internal_id, -// internalRewardProgramId: referralCode.internal_reward_program_id, -// }); - -// if (existingRedemptions.length > 0) { -// throw new RecaseError({ -// message: `Customer ${customer.id} has already redeemed a code in this referral program`, -// statusCode: 400, -// code: ErrCode.CustomerAlreadyRedeemedReferralCode, -// }); -// } - -// // Don't let customer redeem their own code -// const codeCustomer = await CusService.getByInternalId({ -// db: req.db, -// internalId: referralCode.internal_customer_id, -// }); - -// if (!codeCustomer) { -// throw new RecaseError({ -// message: "Referral code customer not found", -// statusCode: 404, -// code: ErrCode.CustomerNotFound, -// }); -// } - -// if ( -// codeCustomer.id === customer.id || -// (notNullish(codeCustomer.fingerprint) && -// codeCustomer.fingerprint === customer.fingerprint) -// ) { -// throw new RecaseError({ -// message: "Customer cannot redeem their own code", -// statusCode: 400, -// code: ErrCode.CustomerCannotRedeemOwnCode, -// }); -// } - -// // 4. Insert redemption into db -// let redemption: RewardRedemption = { -// id: generateId("rr"), -// referral_code_id: referralCode.id, -// internal_customer_id: customer.internal_id, // redeemed by customer -// internal_reward_program_id: referralCode.internal_reward_program_id, -// created_at: Date.now(), -// triggered: -// referralCode.reward_program.when === -// RewardTriggerEvent.CustomerCreation, -// applied: false, -// updated_at: Date.now(), -// redeemer_applied: false, -// }; - -// redemption = await RewardRedemptionService.insert({ -// db, -// rewardRedemption: redemption, -// }); - -// // 5. If reward trigger when is immediate: -// const { reward_program } = referralCode; -// const redeemRewardNow = -// referralCode.reward_program.when === -// RewardTriggerEvent.CustomerCreation; - -// if (redeemRewardNow) { -// const reward = await RewardService.get({ -// db, -// orgId, -// env, -// idOrInternalId: reward_program.internal_reward_id, -// }); - -// if (!reward) { -// throw new RecaseError({ -// message: `Reward ${reward_program.internal_reward_id} not found`, -// statusCode: 404, -// code: ErrCode.RewardNotFound, -// }); -// } - -// const rewardCat = getRewardCat(reward); -// if (rewardCat === RewardCategory.FreeProduct) { -// await triggerFreeProduct({ -// req: parseReqForAction(req) as ExtendedRequest, -// db, -// referralCode, -// redeemer: customer, -// rewardProgram: reward_program, -// org, -// env, -// logger, -// redemption, -// }); -// } else { -// await triggerRedemption({ -// db, -// referralCode, -// org, -// env, -// logger, -// reward, -// redemption, -// }); -// } -// } - -// return res.status(200).json({ -// id: redemption.id, -// customer_id: customer.id, -// reward_id: reward_program.reward.id, -// referrer: { -// id: codeCustomer.id, -// name: codeCustomer.name, -// email: codeCustomer.email, -// created_at: codeCustomer.created_at, -// }, -// redeemer: { -// id: customer.id, -// name: customer.name, -// email: customer.email, -// created_at: customer.created_at, -// }, -// }); -// }, -// }); diff --git a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts index 3b11ffaa4..1d4d3f7a5 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts @@ -116,7 +116,7 @@ export const triggerFreeProduct = async ({ if (addToRedeemer) { const redeemerAttachParams = { - ...structuredClone(attachParams), + ...attachParams, customer: fullRedeemer, cusProducts: fullRedeemer.customer_products, }; @@ -139,7 +139,7 @@ export const triggerFreeProduct = async ({ await createFullCusProduct({ db, attachParams: { - ...structuredClone(attachParams), + ...attachParams, customer: fullReferrer, cusProducts: fullReferrer.customer_products, }, From 8d86da67ec87fb1309352954c473450ca9eabaa8 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:54:25 +0000 Subject: [PATCH 24/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20broken=20prepaid=20?= =?UTF-8?q?expect()=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../check/prepaid/check-prepaid2.test.ts | 116 ++++++++++-------- 1 file changed, 66 insertions(+), 50 deletions(-) diff --git a/server/tests/balances/check/prepaid/check-prepaid2.test.ts b/server/tests/balances/check/prepaid/check-prepaid2.test.ts index 68af1c678..e788a1b46 100644 --- a/server/tests/balances/check/prepaid/check-prepaid2.test.ts +++ b/server/tests/balances/check/prepaid/check-prepaid2.test.ts @@ -109,9 +109,9 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per usage: 0, max_purchase: null, overage_allowed: false, - reset: { + reset: expect.objectContaining({ interval: "month", - }, + }), }; const expectedUsageBreakdown = { @@ -121,14 +121,18 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per usage: 0, max_purchase: 300, overage_allowed: true, - reset: { + reset: expect.objectContaining({ interval: "month", - }, + }), }; expect(res.balance?.breakdown).toHaveLength(2); - expect(res.balance?.breakdown?.[0]).toMatchObject(expectedPrepaidBreakdown); - expect(res.balance?.breakdown?.[1]).toMatchObject(expectedUsageBreakdown); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining(expectedPrepaidBreakdown), + ); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining(expectedUsageBreakdown), + ); }); test("should have correct v1 response for empty usage", async () => { @@ -173,13 +177,15 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per expect(balance?.usage).toBe(curUsage); expect(balance?.purchased_balance).toBe(500); - const prepaidBreakdown = res.balance?.breakdown?.[0]; - expect(prepaidBreakdown).toMatchObject({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: prepaidQuantity + prepaidItem.included_usage - curUsage, - usage: curUsage, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + prepaidItem.included_usage - curUsage, + usage: curUsage, + overage_allowed: false, + }), + ); }); // Balances at this point: @@ -206,21 +212,25 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per purchased_balance: prepaidQuantity + 200, }); - const prepaidBreakdown = res.balance?.breakdown?.[0]; - expect(prepaidBreakdown).toMatchObject({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: 0, - usage: 600, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + overage_allowed: false, + }), + ); - const usageBreakdown = res.balance?.breakdown?.[1]; - expect(usageBreakdown).toMatchObject({ - granted_balance: usageItem.included_usage, - purchased_balance: 200, - current_balance: 0, - usage: 400, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 200, + current_balance: 0, + usage: 400, + overage_allowed: true, + }), + ); }); test("should track another 200 and only 100 used due to usage limit", async () => { @@ -243,14 +253,16 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per purchased_balance: prepaidQuantity + 300, }); - const usageBreakdown = res.balance?.breakdown?.[1]; - expect(usageBreakdown).toMatchObject({ - granted_balance: usageItem.included_usage, - purchased_balance: 300, - current_balance: 0, - usage: 500, - max_purchase: 300, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: 300, + overage_allowed: true, + }), + ); }); test("should check that non-cached customer returns correct response", async () => { @@ -268,21 +280,25 @@ describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per purchased_balance: prepaidQuantity + 300, }); - const prepaidBreakdown = res.balance?.breakdown?.[0]; - expect(prepaidBreakdown).toMatchObject({ - granted_balance: prepaidItem.included_usage, - purchased_balance: prepaidQuantity, - current_balance: 0, - usage: 600, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + overage_allowed: false, + }), + ); - const usageBreakdown = res.balance?.breakdown?.[1]; - expect(usageBreakdown).toMatchObject({ - granted_balance: usageItem.included_usage, - purchased_balance: 300, - current_balance: 0, - usage: 500, - max_purchase: 300, - }); + expect(res.balance?.breakdown).toContainEqual( + expect.objectContaining({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: 300, + overage_allowed: true, + }), + ); }); }); From 81023fecd7649b345929ea44ca8debec6a21c0e4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:56:23 +0000 Subject: [PATCH 25/59] Update shared/api/balances/create/createBalanceParams.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- shared/api/balances/create/createBalanceParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index 5438a0266..195239022 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -34,7 +34,7 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ } if (data.feature.type === FeatureType.Metered) { - if (!data.granted_balance && !data.unlimited) { + if (data.granted_balance === undefined && !data.unlimited) { return false; } if (data.granted_balance && data.unlimited) { From fdd1c61c6949ff56a7ebdb6a84eceaf619def8b1 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:57:08 +0000 Subject: [PATCH 26/59] Update shared/api/balances/create/createBalanceParams.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- shared/api/balances/create/createBalanceParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index 195239022..7995e1fbf 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -37,7 +37,7 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ if (data.granted_balance === undefined && !data.unlimited) { return false; } - if (data.granted_balance && data.unlimited) { + if (data.granted_balance !== undefined && data.unlimited) { return false; } if (data.unlimited && data.reset?.interval) { From ab7b19c4f745013774839221535352713c147938 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:57:54 +0000 Subject: [PATCH 27/59] Update shared/api/balances/create/createBalanceParams.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- shared/api/balances/create/createBalanceParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index 7995e1fbf..fd98c85c2 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -28,7 +28,7 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ } if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval || data.expires_at) { + if (data.granted_balance !== undefined || data.unlimited || data.reset?.interval || data.expires_at) { return false; } } From fc5c5283c42dab17ff7148908b2279b9acac8dce Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:05:34 +0000 Subject: [PATCH 28/59] =?UTF-8?q?fix:=20=F0=9F=90=9B=20cleanups=20from=20c?= =?UTF-8?q?ubic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../balances/handlers/handleCreateBalance.ts | 17 ++++++++++------- .../api/balances/create/createBalanceParams.ts | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 4bcb5a197..4f5ceeba0 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,5 +1,6 @@ import { CreateBalanceSchema, EntityNotFoundError } from "@autumn/shared"; import { FeatureNotFoundError } from "@shared/index"; +import type { DrizzleCli } from "@/db/initDrizzle"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; @@ -69,14 +70,16 @@ export const handleCreateBalance = createRoute({ feature_id, }); - await EntitlementService.insert({ - db: ctx.db, - data: [newEntitlement], - }); + await ctx.db.transaction(async (tx) => { + await EntitlementService.insert({ + db: tx as unknown as DrizzleCli, + data: [newEntitlement], + }); - await CusEntService.insert({ - db: ctx.db, - data: [newCustomerEntitlement], + await CusEntService.insert({ + db: tx as unknown as DrizzleCli, + data: [newCustomerEntitlement], + }); }); await deleteCachedFullCustomer({ diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index fd98c85c2..c153aff9d 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -28,7 +28,7 @@ export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ } if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance !== undefined || data.unlimited || data.reset?.interval || data.expires_at) { + if (data.granted_balance !== undefined || data.unlimited || data.reset?.interval) { return false; } } From 22b8afebf0edcf3966c52ff4bcdf147c318fffe5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 12 Jan 2026 19:56:33 +0000 Subject: [PATCH 29/59] fix: redis floating point error --- .../deductFromCustomerEntitlements.lua | 13 ++++++++++++- .../balances/track/utils/handleRedisTrackError.ts | 1 + .../utils/deduction/executeRedisDeduction.ts | 12 +++++++----- .../verifyCacheConsistencyWorkflow.ts | 1 + 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index f4511682e..c2a63f73b 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -109,6 +109,14 @@ else remaining_amount = amount_to_deduct or 0 end +-- ============================================================================ +-- HELPER: Round number to eliminate floating point errors +-- ============================================================================ +local function round_to_precision(num, decimals) + local mult = 10 ^ (decimals or 10) + return math.floor(num * mult + 0.5) / mult +end + -- Determine if this is a refund (negative amount) local is_refund = remaining_amount < 0 @@ -258,6 +266,7 @@ process_pass({ context = context, }) + -- Pass 2: Exceed bounds -- For deductions: only usage_allowed entitlements can go below 0 (into overage) -- For refunds: ALL entitlements can go above 0 (up to max_balance) @@ -267,8 +276,10 @@ if remaining_amount ~= 0 then skip_if_not_usage_allowed = not is_refund, -- Only skip for deductions, not refunds context = context, }) + end +remaining_amount = round_to_precision(remaining_amount, 10) -- Throw error and don't apply updates if we're in reject mode and there's still remaining amount if remaining_amount > 0 and overage_behaviour == 'reject' then return cjson.encode({ @@ -278,7 +289,7 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then updates = {}, logs = context.logs }) -end +end -- Apply all pending writes to Redis (only after validation passes) apply_pending_writes(cache_key, context) diff --git a/server/src/internal/balances/track/utils/handleRedisTrackError.ts b/server/src/internal/balances/track/utils/handleRedisTrackError.ts index e70ca8d17..d13efbced 100644 --- a/server/src/internal/balances/track/utils/handleRedisTrackError.ts +++ b/server/src/internal/balances/track/utils/handleRedisTrackError.ts @@ -27,6 +27,7 @@ export const handleRedisTrackError = async ({ body: TrackParams; featureDeductions: FeatureDeduction[]; }): Promise => { + ctx.logger.warn(`Redis track error: ${error.message}`); if (!(error instanceof RedisDeductionError)) { throw error; } diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 7b3936c11..fcc6c5f8f 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -122,6 +122,12 @@ export const executeRedisDeduction = async ({ const resultJson = JSON.parse(result) as LuaDeductionResult; + // if (resultJson.logs && resultJson.logs.length > 0) { + // ctx.logger.debug( + // `[executeRedisDeduction] Logs: ${resultJson.logs.join("\n")}`, + // ); + // } + if (resultJson.error) { throw new RedisDeductionError({ message: `Redis deduction failed: ${resultJson.error}`, @@ -129,7 +135,7 @@ export const executeRedisDeduction = async ({ }); } - const { updates, rollover_updates, logs } = resultJson; + const { updates, rollover_updates } = resultJson; logDeductionUpdates({ ctx, fullCustomer, @@ -140,10 +146,6 @@ export const executeRedisDeduction = async ({ allUpdates = { ...allUpdates, ...updates }; allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates }; - // if (logs && logs.length > 0) { - // ctx.logger.debug(`[executeRedisDeduction] Logs: ${logs.join("\n")}`); - // } - // Handle paid allocated entitlements and update fullCus in memory try { // Apply rollover updates first diff --git a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts index ef08be1e9..e8984db23 100644 --- a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts +++ b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts @@ -51,6 +51,7 @@ const checkSubscriptionsMatch = ({ const cachedSubscription = cachedCustomer.subscriptions.find( (s) => s.plan_id === subscription.plan_id, ); + if (!cachedSubscription) { return { success: false, From 92308864b84dbdfb6ffc2a32385696431db8111a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 12 Jan 2026 20:27:38 +0000 Subject: [PATCH 30/59] added test for floating point edge case --- .../edge-cases/track-edge-case-1.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 server/tests/balances/track/edge-cases/track-edge-case-1.test.ts diff --git a/server/tests/balances/track/edge-cases/track-edge-case-1.test.ts b/server/tests/balances/track/edge-cases/track-edge-case-1.test.ts new file mode 100644 index 000000000..32f06fe7c --- /dev/null +++ b/server/tests/balances/track/edge-cases/track-edge-case-1.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 500, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature, creditsFeature], +}); + +const testCase = "track-edge-cases1"; + +describe(`${chalk.yellowBright("track-edge-case1: replicate floating point error with credits")}`, () => { + const customerId = "track-edge-case1"; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("should have initial balances", async () => { + const customer = await autumnV1.customers.get(customerId); + const creditsBalance = customer.features[TestFeature.Credits].balance; + + expect(creditsBalance).toBe(500); + }); + + test("should replicate floating point error with credits - track multiple weird decimals", async () => { + // Track several weird decimal values that might cause floating point errors + const value1 = 0.1; // Simple decimal + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: value1, + }); + + const value2 = 0.2; // Another simple decimal + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: value2, + }); + + // Now try to check with send_event=true for exactly the remaining balance + // After tracking 0.1 + 0.2 = 0.3, we should have 499.7 remaining + // But due to floating point: 0.1 + 0.2 = 0.30000000000000004 + + const checkValue = 10.3; + + // This should work, but might fail due to floating point errors in Lua + const checkRes = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: checkValue, + send_event: true, + }); + + expect(checkRes.allowed).toBe(true); + expect(checkRes.balance).toBeDefined(); + }); +}); From fb1fe442fe13e3cbcf1dad0351f382bd70a05007 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 12 Jan 2026 20:28:16 +0000 Subject: [PATCH 31/59] added test case to g1.sh --- scripts/testGroups/g1.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index ac8d0a7b2..1fb31f999 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -22,6 +22,7 @@ BUN_PARALLEL_COMPACT \ 'server/tests/balances/track/rollovers' \ 'server/tests/balances/track/race-condition' \ 'server/tests/balances/track/paid-allocated' \ + 'server/tests/balances/track/edge-cases' \ 'server/tests/balances/check/breakdown' \ 'server/tests/balances/check/basic' \ 'server/tests/balances/check/credit-systems' \ From 710bfc44aa4f6b47059c6caaac54b0ca1e72373a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 10:35:20 +0000 Subject: [PATCH 32/59] chore: updated conductor set up to copy drizzle migration files --- conductor-setup.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/conductor-setup.sh b/conductor-setup.sh index 3e452507e..d040e3df8 100644 --- a/conductor-setup.sh +++ b/conductor-setup.sh @@ -118,13 +118,13 @@ else echo "⚠️ Warning: $ROOT_PATH/server/shell directory not found" fi -# # Copy drizzle migration files -# if [ -d "$ROOT_PATH/shared/drizzle" ]; then -# echo "📋 Copying database migration files..." -# mkdir -p shared/drizzle -# cp -r "$ROOT_PATH/shared/drizzle/"* shared/drizzle/ -# echo "✅ Copied migration files" -# fi +# Copy drizzle migration files +if [ -d "$ROOT_PATH/shared/drizzle" ]; then + echo "📋 Copying database migration files..." + mkdir -p shared/drizzle + cp -r "$ROOT_PATH/shared/drizzle/"* shared/drizzle/ + echo "✅ Copied migration files" +fi # Shared workspace is now used directly from source (no build needed) From d7885329460cc9eb67571d7e5bd588b8b902abf9 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Tue, 13 Jan 2026 10:56:09 +0000 Subject: [PATCH 33/59] progress on onboarding flow v2 --- server/package.json | 4 +- .../handlers/handleSetupPreviewOrg.ts | 133 ++++++++ .../handlers/handleSyncPreviewPricing.ts | 168 ++++++++++ .../pricingAgent/pricingAgentRouter.ts | 309 ++++++++++++++++++ server/src/routers/internalRouter.ts | 2 + 5 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 server/src/internal/pricingAgent/handlers/handleSetupPreviewOrg.ts create mode 100644 server/src/internal/pricingAgent/handlers/handleSyncPreviewPricing.ts create mode 100644 server/src/internal/pricingAgent/pricingAgentRouter.ts diff --git a/server/package.json b/server/package.json index fe6799817..138364285 100644 --- a/server/package.json +++ b/server/package.json @@ -34,7 +34,7 @@ "author": "Recase Inc.", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/anthropic": "^1.2.10", + "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", "@autumn/shared": "workspace:*", "@aws-sdk/client-scheduler": "^3.958.0", @@ -69,7 +69,7 @@ "@upstash/ratelimit": "^2.0.7", "@upstash/redis": "^1.35.6", "@vercel/sdk": "^1.17.0", - "ai": "^4.3.10", + "ai": "^6.0.24", "arctic": "^3.7.0", "autumn-js": "^0.1.8", "axios": "^1.8.3", diff --git a/server/src/internal/pricingAgent/handlers/handleSetupPreviewOrg.ts b/server/src/internal/pricingAgent/handlers/handleSetupPreviewOrg.ts new file mode 100644 index 000000000..0f45a38ab --- /dev/null +++ b/server/src/internal/pricingAgent/handlers/handleSetupPreviewOrg.ts @@ -0,0 +1,133 @@ +import { + AppEnv, + member, + type Organization, + organizations, + RecaseError, + user as userTable, +} from "@autumn/shared"; +import { generateId } from "better-auth"; +import { eq } from "drizzle-orm"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js"; +import { createKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; + +/** + * Builds the deterministic preview org slug for a user + */ +export function buildPreviewOrgSlug({ + userId, + masterOrgId, +}: { + userId: string; + masterOrgId: string; +}): string { + return `preview|${userId}|${masterOrgId}`; +} + +/** + * Sets up a preview sandbox organization for the current user. + * - Creates a new preview org if one doesn't exist + * - Reuses existing preview org if found + * - Returns a sandbox API key for making checkout calls + */ +export const handleSetupPreviewOrg = createRoute({ + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger, userId } = ctx; + + if (!userId) { + throw new RecaseError({ + message: "User not authenticated", + code: "unauthenticated", + statusCode: 401, + }); + } + + // Fetch user from database + const user = await db.query.user.findFirst({ + where: eq(userTable.id, userId), + }); + if (!user) { + throw new RecaseError({ + message: "User not found", + code: "user_not_found", + statusCode: 404, + }); + } + + const previewSlug = buildPreviewOrgSlug({ + userId, + masterOrgId: masterOrg.id, + }); + + // Check if preview org already exists + const existingOrg = await OrgService.getBySlug({ db, slug: previewSlug }); + + let previewOrg: Organization; + + if (existingOrg) { + previewOrg = existingOrg; + logger.info( + `[Preview] Found existing preview org: ${previewOrg.id} (${previewSlug})`, + ); + } else { + // Create new preview organization + const orgId = generateId(); + + logger.info(`[Preview] Creating new preview org: ${orgId} (${previewSlug})`); + + const [insertedOrg] = await db + .insert(organizations) + .values({ + id: orgId, + slug: previewSlug, + name: `Preview - ${user.name || user.email}`, + logo: "", + createdAt: new Date(), + metadata: "", + created_by: masterOrg.id, + }) + .returning(); + + previewOrg = insertedOrg; + + // Create membership (user owns the preview org) + await db.insert(member).values({ + id: generateId(), + organizationId: orgId, + userId: userId, + role: "owner", + createdAt: new Date(), + }); + + // Initialize org (creates Stripe test account, svix apps, etc.) + await afterOrgCreated({ org: previewOrg, user }); + + logger.info(`[Preview] Created preview org: ${previewOrg.id} (${previewSlug})`); + } + + // Generate a new sandbox API key for this session + const apiKey = await createKey({ + db, + orgId: previewOrg.id, + env: AppEnv.Sandbox, + name: "Preview API Key", + prefix: "am_sk_test", + meta: { preview: true }, + }); + + console.log(`[Preview] Setup complete for user ${userId}:`); + console.log(` - Org ID: ${previewOrg.id}`); + console.log(` - Org Slug: ${previewSlug}`); + console.log(` - API Key: ${apiKey.substring(0, 20)}...`); + + return c.json({ + api_key: apiKey, + org_slug: previewSlug, + org_id: previewOrg.id, + }); + }, +}); + diff --git a/server/src/internal/pricingAgent/handlers/handleSyncPreviewPricing.ts b/server/src/internal/pricingAgent/handlers/handleSyncPreviewPricing.ts new file mode 100644 index 000000000..76eff6a16 --- /dev/null +++ b/server/src/internal/pricingAgent/handlers/handleSyncPreviewPricing.ts @@ -0,0 +1,168 @@ +import { + apiFeatureToDbFeature, + AppEnv, + CreateFeatureV0ParamsSchema, + CreateFreeTrialSchema, + CreateProductItemParamsSchema, + CreateProductSchema, + RecaseError, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { createFeature } from "@/internal/features/featureActions/createFeature.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { createProduct } from "@/internal/products/handlers/productActions/createProduct.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { buildPreviewOrgSlug } from "./handleSetupPreviewOrg.js"; + +const SyncPreviewPricingSchema = z.object({ + features: z.array(CreateFeatureV0ParamsSchema).optional().default([]), + products: z.array( + CreateProductSchema.extend({ + items: z.array(CreateProductItemParamsSchema).optional().default([]), + free_trial: CreateFreeTrialSchema.nullish().optional().default(null), + }), + ), +}); + +/** + * Syncs pricing configuration to the preview sandbox organization. + * - Nukes existing config (customers, products, features) + * - Pushes new config from the request body + */ +export const handleSyncPreviewPricing = createRoute({ + body: SyncPreviewPricingSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger, userId } = ctx; + const body = c.req.valid("json"); + + if (!userId) { + throw new RecaseError({ + message: "User not authenticated", + code: "unauthenticated", + statusCode: 401, + }); + } + + // Find the preview org + const previewSlug = buildPreviewOrgSlug({ + userId, + masterOrgId: masterOrg.id, + }); + + const previewOrg = await OrgService.getBySlug({ db, slug: previewSlug }); + + if (!previewOrg) { + throw new RecaseError({ + message: "Preview org not found. Call /preview/setup first.", + code: "preview_org_not_found", + statusCode: 404, + }); + } + + console.log(`[Preview Sync] Starting sync for preview org: ${previewOrg.id}`); + console.log(`[Preview Sync] Features to sync: ${body.features.length}`); + console.log(`[Preview Sync] Products to sync: ${body.products.length}`); + + // Step 1: Nuke existing config + console.log("[Preview Sync] Step 1: Nuking existing configuration..."); + + await CusService.deleteByOrgId({ + db, + orgId: previewOrg.id, + env: AppEnv.Sandbox, + }); + + await ProductService.deleteByOrgId({ + db, + orgId: previewOrg.id, + env: AppEnv.Sandbox, + }); + + await FeatureService.deleteByOrgId({ + db, + orgId: previewOrg.id, + env: AppEnv.Sandbox, + }); + + console.log("[Preview Sync] Nuke complete."); + + // Step 2: Push new config + console.log("[Preview Sync] Step 2: Pushing new configuration..."); + + // Build a context for the preview org + const previewCtx = { + ...ctx, + org: previewOrg, + env: AppEnv.Sandbox, + features: [] as Awaited>, + }; + + await db.transaction(async (tx) => { + const txDb = tx as unknown as DrizzleCli; + const txCtx = { ...previewCtx, db: txDb }; + + // Create features + for (const apiFeature of body.features) { + const dbFeature = apiFeatureToDbFeature({ apiFeature }); + + await createFeature({ + ctx: txCtx, + data: { + id: dbFeature.id, + name: dbFeature.name, + type: dbFeature.type, + config: dbFeature.config, + event_names: dbFeature.event_names, + }, + }); + console.log(`[Preview Sync] Created feature: ${dbFeature.id}`); + } + + // Get updated features for product creation + const updatedFeatures = await FeatureService.list({ + db: txDb, + orgId: previewOrg.id, + env: AppEnv.Sandbox, + }); + + // Create products + for (const apiProduct of body.products) { + await createProduct({ + ctx: { + ...txCtx, + features: updatedFeatures, + }, + data: { + id: apiProduct.id, + name: apiProduct.name, + is_add_on: apiProduct.is_add_on, + is_default: apiProduct.is_default, + group: apiProduct.group, + items: apiProduct.items, + free_trial: apiProduct.free_trial, + }, + }); + console.log(`[Preview Sync] Created product: ${apiProduct.id} (${apiProduct.name})`); + } + }); + + console.log("[Preview Sync] Sync complete!"); + console.log(`[Preview Sync] Summary:`); + console.log(` - Org ID: ${previewOrg.id}`); + console.log(` - Features created: ${body.features.length}`); + console.log(` - Products created: ${body.products.length}`); + + return c.json({ + success: true, + org_id: previewOrg.id, + features_count: body.features.length, + products_count: body.products.length, + }); + }, +}); + diff --git a/server/src/internal/pricingAgent/pricingAgentRouter.ts b/server/src/internal/pricingAgent/pricingAgentRouter.ts new file mode 100644 index 000000000..a6620a9f8 --- /dev/null +++ b/server/src/internal/pricingAgent/pricingAgentRouter.ts @@ -0,0 +1,309 @@ +import { anthropic } from "@ai-sdk/anthropic"; +import { InternalError } from "@autumn/shared"; +import { convertToModelMessages, streamText, type UIMessage } from "ai"; +import { Hono } from "hono"; +import { z } from "zod/v4"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleSetupPreviewOrg } from "./handlers/handleSetupPreviewOrg.js"; +import { handleSyncPreviewPricing } from "./handlers/handleSyncPreviewPricing.js"; + +// ============ SCHEMAS ============ +const ApiFeatureType = z.enum([ + "static", + "boolean", + "single_use", + "continuous_use", + "credit_system", +]); + +const ProductItemInterval = z.enum([ + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +]); + +const UsageModel = z.enum(["prepaid", "pay_per_use"]); +const FreeTrialDuration = z.enum(["day", "month", "year"]); + +const FeatureSchema = z + .object({ + id: z + .string() + .describe( + "Unique ID for the feature (lowercase, underscores, no spaces)", + ), + name: z.string().nullish().describe("Display name for the feature"), + type: ApiFeatureType.describe( + "Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off", + ), + display: z + .object({ + singular: z + .string() + .describe( + "Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')", + ), + plural: z + .string() + .describe( + "Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')", + ), + }) + .describe( + "REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.", + ), + credit_schema: z + .array( + z.object({ + metered_feature_id: z.string(), + credit_cost: z.number(), + }), + ) + .nullish(), + }) + .refine( + (data) => { + if (data.type === "credit_system") { + return data.credit_schema && data.credit_schema.length > 0; + } + return true; + }, + { + message: + "Credit system features require at least one metered feature in credit_schema.", + path: ["credit_schema"], + }, + ); + +const ProductItemSchema = z.object({ + feature_id: z + .string() + .nullish() + .describe( + "Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).", + ), + included_usage: z + .number() + .or(z.literal("inf")) + .nullish() + .describe( + "Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.", + ), + interval: ProductItemInterval.nullish().describe("Reset/billing interval"), + price: z + .number() + .nullish() + .describe( + "Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.", + ), + usage_model: UsageModel.nullish().describe( + "prepaid or pay_per_use. Required when pricing per unit of usage.", + ), + billing_units: z + .number() + .nullish() + .describe("Units per price (e.g., $1 per 30 credits)"), +}); + +const FreeTrialSchema = z + .object({ + length: z.number().describe("Length of free trial"), + duration: FreeTrialDuration.describe("Unit: day, month, or year"), + unique_fingerprint: z.boolean().default(false), + card_required: z.boolean().default(true), + }) + .nullish(); + +const ProductSchema = z + .object({ + id: z.string().describe("Unique ID (lowercase, hyphens allowed)"), + name: z.string().describe("Display name"), + is_add_on: z + .boolean() + .default(false) + .describe( + "Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).", + ), + is_default: z + .boolean() + .default(false) + .describe( + "Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.", + ), + group: z + .string() + .default("") + .describe("Group name for upgrade/downgrade logic"), + items: z.array(ProductItemSchema).default([]), + free_trial: FreeTrialSchema, + }) + .refine( + (data) => { + if (data.is_default) { + return data.items.every((item) => item.price == null); + } + return true; + }, + { + message: + "Default plans cannot have priced items. All items must have price: null or undefined.", + path: ["is_default"], + }, + ) + .refine( + (data) => { + const usageBasedFeatureIds = new Set( + data.items + .filter((item) => item.feature_id != null && item.usage_model != null) + .map((item) => item.feature_id), + ); + // Check if any other items reference the same feature_id + return !data.items.some( + (item) => + item.feature_id != null && + item.usage_model == null && + usageBasedFeatureIds.has(item.feature_id), + ); + }, + { + message: + "Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).", + path: ["items"], + }, + ) + .refine( + (data) => { + return !data.items.some( + (item) => item.usage_model === "pay_per_use" && item.interval == null, + ); + }, + { + message: + "Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.", + path: ["items"], + }, + ) + .refine( + (data) => { + return !data.items.some( + (item) => + item.price != null && + item.feature_id != null && + item.usage_model == null, + ); + }, + { + message: + "Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.", + path: ["items"], + }, + ); + +const OrganisationConfigurationSchema = z.object({ + features: z.array(FeatureSchema).default([]), + products: z.array(ProductSchema), +}); + +export type PricingConfig = z.infer; + +// ============ SYSTEM PROMPT ============ +const SYSTEM_PROMPT = `You are a helpful pricing configuration assistant for Autumn, a billing and entitlements platform. + +Your job is to help users design their pricing model through natural conversation. You should: +1. Ask clarifying questions to understand their needs +2. Generate and update the pricing configuration as you learn more + +**IMPORTANT**: Call the build_pricing tool EVERY time the user provides any information at all about their pricing, features, or products. This updates the live preview they see. Even partial information should trigger a tool call with your best interpretation. + + +## Feature Types +- **single_use**: Consumable resources (API calls, tokens, messages, credits, generations) +- **continuous_use**: Non-consumable resources (seats, workspaces, projects, team members) +- **boolean**: On/off features (advanced analytics, priority support, SSO) +- **credit_system**: A unified credit pool that maps to multiple single_use features + +## Item Types +Products contain an array of items. There are THREE distinct item patterns: + +1. **Flat Fee** (standalone price, no feature): + \`{ feature_id: null, price: 13, interval: "month" }\` + → Customer pays $13/month as a base subscription fee + +2. **Free Feature Allocation** (feature grant, no price): + \`{ feature_id: "credits", included_usage: 10000 }\` + → Customer gets 10,000 credits included + +3. **Metered/Usage-Based Pricing** (feature + price + usage_model): + \`{ feature_id: "credits", included_usage: 10000, price: 0.01, usage_model: "pay_per_use", interval: "month" }\` + → Customer can use 10,000 credits per month, and then pays $0.01 per credit used after that. + +4. **Prepaid Credit Purchase** (one-time purchase of usage): + \`{ feature_id: "credits", price: 10, usage_model: "prepaid", billing_units: 10000 }\` + → Customer pays $10 once to receive 10,000 credits + + +## Guidelines when building the config + +- If you identify more than 3 features from user input, build the 3 most important (prioritizing metered features) and ask the user to confirm if they want to add more. Tell them that you kept it simple to start with, but they can add more later. + +- Product and Feature IDs should be lowercase with underscores (e.g., "pro_plan", "chat_messages") + +- **NEVER** allow is_default: true for plans with prices. All prices MUST be null or undefined. + +- Ignore reference to "Enterprise" plans with custom pricing. They do not need to be generated here. Instead, inform the user that custom plans can be created for any customer within the Autumn dashboard. + + +## Guidelines when responding to the user + +- Do NOT tell the user what pricing you have built or describe the pricing in any way, as it is a waste to read (they can see it on the right). + +- If the user asks about changing currency, let them know they can do so in the Autumn dashboard, under Developer > Stripe. + +- Keep responses very concise and friendly.`; + +// ============ HONO ROUTER ============ +export const pricingAgentRouter = new Hono(); + +pricingAgentRouter.post("/chat", async (c) => { + const { messages }: { messages: UIMessage[] } = await c.req.json(); + + if (!process.env.ANTHROPIC_API_KEY) { + throw new InternalError({ + message: "ANTHROPIC_API_KEY not configured", + code: "anthropic_not_configured", + }); + } + + const result = streamText({ + model: anthropic("claude-sonnet-4-20250514"), + system: SYSTEM_PROMPT, + messages: await convertToModelMessages(messages), + tools: { + build_pricing: { + description: + "Generate the pricing configuration based on the conversation. Call this whenever you have new information about the user's pricing needs, even if partial. This updates their live preview.", + inputSchema: OrganisationConfigurationSchema, + }, + }, + }); + + return result.toUIMessageStreamResponse(); +}); + +// ============ PREVIEW ROUTES ============ +/** + * POST /preview/setup + * Creates or retrieves a persistent preview sandbox org for the current user + */ +pricingAgentRouter.post("/preview/setup", ...handleSetupPreviewOrg); + +/** + * POST /preview/sync + * Syncs pricing configuration to the preview sandbox org + */ +pricingAgentRouter.post("/preview/sync", ...handleSyncPreviewPricing); diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index ff6e56910..8a7ae140c 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -11,6 +11,7 @@ import { honoAdminRouter } from "../internal/admin/adminRouter"; import { internalCusRouter } from "../internal/customers/internalCusRouter"; import { internalDevRouter } from "../internal/dev/devRouter"; import { internalOrgRouter } from "../internal/orgs/orgRouter"; +import { pricingAgentRouter } from "../internal/pricingAgent/pricingAgentRouter"; import { internalProductRouter } from "../internal/products/internalProductRouter"; export const internalRouter = new Hono(); @@ -30,3 +31,4 @@ internalRouter.route("organization", internalOrgRouter); internalRouter.route("/products", internalProductRouter); internalRouter.route("/customers", internalCusRouter); internalRouter.route("/dev", internalDevRouter); +internalRouter.route("/pricing-agent", pricingAgentRouter); \ No newline at end of file From 31f1c9932a4169d891c130129ceaf6332ddb85cb Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Tue, 13 Jan 2026 10:56:56 +0000 Subject: [PATCH 34/59] progress on onboarding v1 --- bun.lock | 55 +-- shared/utils/productDisplayUtils.ts | 8 +- vite/package.json | 3 +- vite/src/components/ui/border-beam.tsx | 105 +++++ vite/src/components/ui/shine-border.tsx | 61 +++ .../components/v2/badges/PlanTypeBadge.tsx | 4 +- .../components/v2/badges/PlanTypeBadges.tsx | 15 +- vite/src/index.css | 27 +- vite/src/views/onboarding4/AIChatView.tsx | 394 ++++++++++++++---- .../src/views/onboarding4/OnboardingGuide.tsx | 22 +- .../views/onboarding4/PricingConfigSheet.tsx | 74 ++++ vite/src/views/onboarding4/PricingPreview.tsx | 154 ++++--- .../preview/PreviewCheckoutButton.tsx | 113 +++++ .../preview/PreviewCreditSchemaCard.tsx | 76 ++++ .../preview/PreviewFeatureIcon.tsx | 144 +++++++ .../onboarding4/preview/PreviewFeatureRow.tsx | 45 ++ .../onboarding4/preview/PreviewPlanCard.tsx | 53 +++ .../onboarding4/preview/PreviewPlanHeader.tsx | 40 ++ vite/src/views/onboarding4/preview/index.ts | 10 + .../views/onboarding4/preview/previewTypes.ts | 219 ++++++++++ .../views/onboarding4/pricingAgentUtils.ts | 137 ++++++ 21 files changed, 1557 insertions(+), 202 deletions(-) create mode 100644 vite/src/components/ui/border-beam.tsx create mode 100644 vite/src/components/ui/shine-border.tsx create mode 100644 vite/src/views/onboarding4/PricingConfigSheet.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewPlanCard.tsx create mode 100644 vite/src/views/onboarding4/preview/PreviewPlanHeader.tsx create mode 100644 vite/src/views/onboarding4/preview/index.ts create mode 100644 vite/src/views/onboarding4/preview/previewTypes.ts create mode 100644 vite/src/views/onboarding4/pricingAgentUtils.ts diff --git a/bun.lock b/bun.lock index 74d590ee5..f2b5cde00 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,7 @@ "name": "@autumn/server", "version": "1.0.0", "dependencies": { - "@ai-sdk/anthropic": "^1.2.10", + "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", "@autumn/shared": "workspace:*", "@aws-sdk/client-scheduler": "^3.958.0", @@ -77,7 +77,7 @@ "@upstash/ratelimit": "^2.0.7", "@upstash/redis": "^1.35.6", "@vercel/sdk": "^1.17.0", - "ai": "^4.3.10", + "ai": "^6.0.24", "arctic": "^3.7.0", "autumn-js": "^0.1.8", "axios": "^1.8.3", @@ -184,6 +184,7 @@ "name": "@autumn/vite", "version": "0.0.0", "dependencies": { + "@ai-sdk/react": "^3.0.25", "@amplitude/unified": "^1.0.0-beta.9", "@autumn/shared": "workspace:*", "@better-auth/stripe": "^1.2.12", @@ -243,7 +244,7 @@ "input-otp": "^1.4.2", "lodash": "^4.17.21", "lucide-react": "^0.562.0", - "motion": "^12.23.26", + "motion": "^12.26.1", "nanoid": "^5.1.6", "next-themes": "^0.4.6", "nuqs": "^2.4.3", @@ -306,17 +307,15 @@ "stripe": "19.3.0-beta.1", }, "packages": { - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.9", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.4" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QBD4qDnwIHd+N5PpjxXOaWJig1aRB43J0PM5ZUe6Yyl9Qq2bUmraQjvNznkuFKy+hMFDgj0AvgGogTiO5TC+qA=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.6", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.2", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oEpwjM0PIaSUErtZI8Ag+gQ+ZelysRWA96N5ahvOc5e9d7QkKJWF0POWx0nI1qBxvmUSw7ca0sLTVw+J5yn7Tg=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.10", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.4", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-sRlPMKd38+fdp2y11USW44c0o8tsIsT6T/pgyY04VXC3URjIRnkxugxd9AkU2ogfpPDMz50cBAGPnMxj+6663Q=="], - "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.4", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VxhX0B/dWGbpNHxrKCWUAJKXIXV015J4e7qYjdIU9lLWeptk0KMLGcqkB4wFxff5Njqur8dt8wRi1MN9lZtDqg=="], - "@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="], - - "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@ai-sdk/react": ["@ai-sdk/react@3.0.25", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.4", "ai": "6.0.24", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-84s0jE8EmAFgPNI2MJaEBow4e738wgrZ7U8yYQTby4KgoRJ/zogcuzcBQ3NIGWnXNSqDWWUACGNXqN3ZmHU+bw=="], "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ=="], @@ -1602,8 +1601,6 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1748,7 +1745,7 @@ "@upstash/redis": ["@upstash/redis@1.36.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-9zN2UV9QJGPnXfWU3yZBLVQaqqENDh7g+Y4J2vJuSxBCi9FQ0aUOtaXlzuFhnsiZvCqM+eS27ic+tgmkWUsfOg=="], - "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/sdk": ["@vercel/sdk@1.18.5", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-tzxGuUxYZQpKsf5WrImp4gnCZu3xhHA4j6KLSAQdXgpi/Lfk3JV5YGEDU6ZZBIwXDMdny5DozLd20tz/bKZsfg=="], @@ -1796,7 +1793,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@4.3.19", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q=="], + "ai": ["ai@6.0.24", "", { "dependencies": { "@ai-sdk/gateway": "3.0.10", "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.4", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-np5VIe32O1vgDnzWWKa34NX1OkhaiY84DSfVWcmswVHtxp97g6k9EKv5dts8OfO9lM73XKyd6a7JqflA0+DN7A=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -2188,8 +2185,6 @@ "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], - "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], - "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -2382,7 +2377,7 @@ "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], - "framer-motion": ["framer-motion@12.24.0", "", { "dependencies": { "motion-dom": "^12.24.0", "motion-utils": "^12.23.28", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ=="], + "framer-motion": ["framer-motion@12.26.1", "", { "dependencies": { "motion-dom": "^12.24.11", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -2636,8 +2631,6 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], - "jss": ["jss@10.10.0", "", { "dependencies": { "@babel/runtime": "^7.3.1", "csstype": "^3.0.2", "is-in-browser": "^1.1.3", "tiny-warning": "^1.0.2" } }, "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw=="], "jss-plugin-camel-case": ["jss-plugin-camel-case@10.10.0", "", { "dependencies": { "@babel/runtime": "^7.3.1", "hyphenate-style-name": "^1.0.3", "jss": "10.10.0" } }, "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw=="], @@ -2892,11 +2885,11 @@ "moo": ["moo@0.5.1", "", {}, "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w=="], - "motion": ["motion@12.24.0", "", { "dependencies": { "framer-motion": "^12.24.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-FAnpl/DhCFct3p+T2hCjAY95w+3EggUk3b8Ql4jQ6mmdRbEaGD1000goqWeEYVoD7mqx9H0As9ORmRADD4LQAw=="], + "motion": ["motion@12.26.1", "", { "dependencies": { "framer-motion": "^12.26.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg=="], - "motion-dom": ["motion-dom@12.24.0", "", { "dependencies": { "motion-utils": "^12.23.28" } }, "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig=="], + "motion-dom": ["motion-dom@12.24.11", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A=="], - "motion-utils": ["motion-utils@12.23.28", "", {}, "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ=="], + "motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -3652,13 +3645,7 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-2lR4w7mr9XrydzxBSjir4N6YMGdXD+Np1Sh0RXABh7tWdNFFwIeRI1Q+SaYZMbfL8Pg8RRLcrxQm51yxTLhokg=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KaykkuRBdF/ffpI5bwpL4aSCmO/99p8/ci+VeHwJO8tmvXtiVAb99QeyvvvXmL61e9Zrvv4GBGoajW19xdjkVQ=="], - - "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "@ai-sdk/provider-utils/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@amplitude/analytics-client-common/@amplitude/analytics-types": ["@amplitude/analytics-types@2.11.0", "", {}, "sha512-L1niBXYSWmbyHUE/GNuf6YBljbafaxWI3X5jjEIZDFCjQvdWO3DKalY1VPFUbhgYQgWw7+bC6I/AlUaporyfig=="], @@ -3678,8 +3665,6 @@ "@autumn/vite/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], - "@autumn/vite/ai": ["ai@6.0.7", "", { "dependencies": { "@ai-sdk/gateway": "3.0.6", "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kLzSXHdW6cAcb2mFSIfkbfzxYqqjrUnyhrB1sg855qlC+6XkLI8hmwFE8f/4SnjmtcTDOnkIaVjWoO5i5Ir0bw=="], - "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "@autumn/vite/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], @@ -4474,16 +4459,10 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@autumn/vite/ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-2lR4w7mr9XrydzxBSjir4N6YMGdXD+Np1Sh0RXABh7tWdNFFwIeRI1Q+SaYZMbfL8Pg8RRLcrxQm51yxTLhokg=="], - - "@autumn/vite/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KaykkuRBdF/ffpI5bwpL4aSCmO/99p8/ci+VeHwJO8tmvXtiVAb99QeyvvvXmL61e9Zrvv4GBGoajW19xdjkVQ=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -5168,8 +5147,6 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@autumn/vite/ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 572eda7d6..4592ae699 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -169,14 +169,16 @@ export const getFeaturePriceItemDisplay = ({ const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? ""; + // For "per X" display, use singular when billing_units is 1 or not specified + const billingUnits = item.billing_units ?? 1; const billingFeatureName = getFeatureName({ feature, - units: item.billing_units, + units: billingUnits, }); let priceStr2 = ""; - if (item.billing_units && item.billing_units > 1) { - priceStr2 = `${numberWithCommas(item.billing_units)} ${billingFeatureName}`; + if (billingUnits > 1) { + priceStr2 = `${numberWithCommas(billingUnits)} ${billingFeatureName}`; } else { priceStr2 = `${billingFeatureName}`; } diff --git a/vite/package.json b/vite/package.json index 3d63ce379..d117621e0 100644 --- a/vite/package.json +++ b/vite/package.json @@ -17,6 +17,7 @@ "author": "Recase Inc.", "license": "Apache-2.0", "dependencies": { + "@ai-sdk/react": "^3.0.25", "@amplitude/unified": "^1.0.0-beta.9", "@autumn/shared": "workspace:*", "@better-auth/stripe": "^1.2.12", @@ -76,7 +77,7 @@ "input-otp": "^1.4.2", "lodash": "^4.17.21", "lucide-react": "^0.562.0", - "motion": "^12.23.26", + "motion": "^12.26.1", "nanoid": "^5.1.6", "next-themes": "^0.4.6", "nuqs": "^2.4.3", diff --git a/vite/src/components/ui/border-beam.tsx b/vite/src/components/ui/border-beam.tsx new file mode 100644 index 000000000..0ae6bc3cf --- /dev/null +++ b/vite/src/components/ui/border-beam.tsx @@ -0,0 +1,105 @@ +import { motion, MotionStyle, Transition } from "motion/react" + +import { cn } from "@/lib/utils" + +interface BorderBeamProps { + /** + * The size of the border beam. + */ + size?: number + /** + * The duration of the border beam. + */ + duration?: number + /** + * The delay of the border beam. + */ + delay?: number + /** + * The color of the border beam from. + */ + colorFrom?: string + /** + * The color of the border beam to. + */ + colorTo?: string + /** + * The motion transition of the border beam. + */ + transition?: Transition + /** + * The class name of the border beam. + */ + className?: string + /** + * The style of the border beam. + */ + style?: React.CSSProperties + /** + * Whether to reverse the animation direction. + */ + reverse?: boolean + /** + * The initial offset position (0-100). + */ + initialOffset?: number + /** + * The border width of the beam. + */ + borderWidth?: number +} + +export const BorderBeam = ({ + className, + size = 50, + delay = 0, + duration = 6, + colorFrom = "#ffaa40", + colorTo = "#9c40ff", + transition, + style, + reverse = false, + initialOffset = 0, + borderWidth = 1, +}: BorderBeamProps) => { + return ( +
+ +
+ ) +} diff --git a/vite/src/components/ui/shine-border.tsx b/vite/src/components/ui/shine-border.tsx new file mode 100644 index 000000000..40b1fe8cf --- /dev/null +++ b/vite/src/components/ui/shine-border.tsx @@ -0,0 +1,61 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +interface ShineBorderProps extends React.HTMLAttributes { + /** + * Width of the border in pixels + * @default 1 + */ + borderWidth?: number + /** + * Duration of the animation in seconds + * @default 14 + */ + duration?: number + /** + * Color of the border, can be a single color or an array of colors + * @default "#000000" + */ + shineColor?: string | string[] +} + +/** + * Shine Border + * + * An animated background border effect component with configurable properties. + */ +export function ShineBorder({ + borderWidth = 1, + duration = 14, + shineColor = "#000000", + className, + style, + ...props +}: ShineBorderProps) { + return ( +
+ ) +} diff --git a/vite/src/components/v2/badges/PlanTypeBadge.tsx b/vite/src/components/v2/badges/PlanTypeBadge.tsx index 1b8a7564d..7588d5583 100644 --- a/vite/src/components/v2/badges/PlanTypeBadge.tsx +++ b/vite/src/components/v2/badges/PlanTypeBadge.tsx @@ -32,12 +32,14 @@ const badgeVariants = cva( export interface PlanTypeBadgeProps extends VariantProps { className?: string; iconOnly?: boolean; + noIcon?: boolean; } export const PlanTypeBadge = ({ variant, className, iconOnly, + noIcon, }: PlanTypeBadgeProps) => { const getIcon = () => { switch (variant) { @@ -107,7 +109,7 @@ export const PlanTypeBadge = ({
- {getIcon()} + {!noIcon && getIcon()} {!iconOnly && {getLabel()}}
diff --git a/vite/src/components/v2/badges/PlanTypeBadges.tsx b/vite/src/components/v2/badges/PlanTypeBadges.tsx index 8e24bbbb2..e24e7dcfb 100644 --- a/vite/src/components/v2/badges/PlanTypeBadges.tsx +++ b/vite/src/components/v2/badges/PlanTypeBadges.tsx @@ -1,16 +1,23 @@ -import type { ProductV2 } from "@autumn/shared"; import { PlanTypeBadge } from "./PlanTypeBadge"; +interface PlanTypeBadgesProduct { + is_default?: boolean; + free_trial?: unknown; + is_add_on?: boolean; +} + interface PlanTypeBadgesProps { - product: ProductV2; + product: PlanTypeBadgesProduct; className?: string; iconOnly?: boolean; + noIcon?: boolean; } export const PlanTypeBadges = ({ product, className, iconOnly = false, + noIcon = false, }: PlanTypeBadgesProps) => { const badges = []; @@ -22,6 +29,7 @@ export const PlanTypeBadges = ({ variant="autoTrial" className={className} iconOnly={iconOnly} + noIcon={noIcon} />, ); } else { @@ -33,6 +41,7 @@ export const PlanTypeBadges = ({ variant="default" className={className} iconOnly={iconOnly} + noIcon={noIcon} />, ); } @@ -44,6 +53,7 @@ export const PlanTypeBadges = ({ variant="freeTrial" className={className} iconOnly={iconOnly} + noIcon={noIcon} />, ); } @@ -56,6 +66,7 @@ export const PlanTypeBadges = ({ variant="addon" className={className} iconOnly={iconOnly} + noIcon={noIcon} />, ); } diff --git a/vite/src/index.css b/vite/src/index.css index 7543980f3..e93757ff6 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -8,10 +8,8 @@ @import "tailwind-scrollbar-hide/v4"; @import "@squircle/tailwindcss"; -/* ---break--- */ @plugin "tailwindcss-animate"; -/* ---break--- */ @custom-variant dark (&:is(.dark *)); html { @@ -55,7 +53,6 @@ html { --interactive-secondary: #fff; --interactive-secondary-hover: #FCFAFF; - --muted-foreground: #444; --t1: #121212; --t2: #444; @@ -166,23 +163,17 @@ html { --muted: #1c1c1d; /* --card: oklch(0.141 0.005 285.823); */ - - --t1: #ddd; --t2: #ccc; --t3: #999; - - --card: #121212; /* --border: #262626; */ --border: #2c2c2c; - --chart-grid-stroke: #262626; - --card-foreground: oklch(0.985 0 0); --popover: oklch(0.141 0.005 285.823); --popover-foreground: white; @@ -359,6 +350,18 @@ html { /* --breakpoint-xl: 85rem; */ /* --breakpoint-2xl: 100rem; --breakpoint-3xl: 120rem; */ + --animate-shine: shine var(--duration) infinite linear; + @keyframes shine { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + to { + background-position: 0% 0%; + } + } } /* Hide number input spinners globally */ @@ -483,9 +486,6 @@ input[type="number"]::-webkit-inner-spin-button { } } */ - - - .shimmer-hover { position: relative; overflow: hidden; @@ -509,7 +509,6 @@ input[type="number"]::-webkit-inner-spin-button { transition: opacity 0.2s; } - .shimmer-hover:hover::after { animation: shimmer-once 0.8s; opacity: 1; @@ -524,4 +523,4 @@ input[type="number"]::-webkit-inner-spin-button { left: 150%; opacity: 1; } -} +} \ No newline at end of file diff --git a/vite/src/views/onboarding4/AIChatView.tsx b/vite/src/views/onboarding4/AIChatView.tsx index 63979f6e2..b390813fe 100644 --- a/vite/src/views/onboarding4/AIChatView.tsx +++ b/vite/src/views/onboarding4/AIChatView.tsx @@ -1,12 +1,16 @@ -import { ArrowLeft, MessageSquareText } from "lucide-react"; -import { useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai"; +import { ArrowLeft, ImageIcon, MessageSquareText } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { Conversation, ConversationContent, ConversationEmptyState, } from "@/components/ai-elements/conversation"; -import { Loader } from "@/components/ai-elements/loader"; import { Message, MessageContent, @@ -14,99 +18,239 @@ import { } from "@/components/ai-elements/message"; import { PromptInput, + PromptInputAttachment, + PromptInputAttachments, PromptInputBody, + PromptInputButton, PromptInputFooter, + PromptInputHeader, type PromptInputMessage, PromptInputSubmit, PromptInputTextarea, + usePromptInputAttachments, } from "@/components/ai-elements/prompt-input"; +import { Shimmer } from "@/components/ai-elements/shimmer"; import { Button } from "@/components/v2/buttons/Button"; import { pushPage } from "@/utils/genUtils"; +import { PricingConfigSheet } from "./PricingConfigSheet"; import { PricingPreview } from "./PricingPreview"; -import type { PricingTier } from "./templateConfigs"; - -interface ChatMessage { - id: string; - role: "user" | "assistant"; - content: string; -} +import type { AgentPricingConfig } from "./pricingAgentUtils"; interface AIChatViewProps { onBack: () => void; } -// Mock pricing tiers that would be generated by the AI -const MOCK_GENERATED_TIERS: PricingTier[] = [ - { - name: "Free", - price: "Free", - description: "For individuals", - features: ["Basic features", "Community support", "Limited usage"], - }, - { - name: "Pro", - price: "$29", - interval: "month", - description: "For professionals", - features: [ - "All free features", - "Priority support", - "Advanced analytics", - "Unlimited usage", - ], - highlighted: true, - }, - { - name: "Enterprise", - price: "Custom", - description: "For teams", - features: [ - "Everything in Pro", - "Dedicated support", - "Custom integrations", - "SLA guarantees", - ], - }, -]; +function ImageUploadButton({ disabled }: { disabled?: boolean }) { + const attachments = usePromptInputAttachments(); + + return ( + attachments.openFileDialog()} + disabled={disabled} + title="Add image" + > + + + ); +} + +// Type for the build_pricing tool part +type BuildPricingToolPart = { + type: "tool-build_pricing"; + toolCallId: string; + toolName: "build_pricing"; + state: + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + input?: AgentPricingConfig; + output?: unknown; + errorText?: string; +}; + +interface PreviewOrg { + apiKey: string; + orgId: string; + orgSlug: string; +} export function AIChatView({ onBack }: AIChatViewProps) { const navigate = useNavigate(); const [input, setInput] = useState(""); - const [messages, setMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [pricingTiers, setPricingTiers] = useState([]); + const [pricingConfig, setPricingConfig] = useState( + null, + ); + const [jsonSheetConfig, setJsonSheetConfig] = + useState(null); + const [previewOrg, setPreviewOrg] = useState(null); + const [isPreviewSyncing, setIsPreviewSyncing] = useState(false); + const previewSetupRef = useRef | null>(null); + + /** Setup the preview org (called once, memoized) */ + const setupPreviewOrg = useCallback(async (): Promise => { + // If already setting up, return the existing promise + if (previewSetupRef.current) { + return previewSetupRef.current; + } + + const setupPromise = (async () => { + try { + console.log("[Preview] Setting up preview org..."); + const response = await fetch( + `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`, + { + method: "POST", + credentials: "include", + headers: { + "x-client-type": "dashboard", + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const error = await response.json(); + console.error("[Preview] Setup failed:", error); + return null; + } + + const data = await response.json(); + const org: PreviewOrg = { + apiKey: data.api_key, + orgId: data.org_id, + orgSlug: data.org_slug, + }; + console.log("[Preview] Setup complete:", { + orgId: org.orgId, + orgSlug: org.orgSlug, + }); + setPreviewOrg(org); + return org; + } catch (error) { + console.error("[Preview] Setup error:", error); + return null; + } + })(); + + previewSetupRef.current = setupPromise; + return setupPromise; + }, []); + + /** Sync pricing config to the preview org */ + const syncPreviewPricing = useCallback( + async (config: AgentPricingConfig) => { + // Ensure preview org is set up + let org = previewOrg; + if (!org) { + org = await setupPreviewOrg(); + if (!org) { + console.error("[Preview] Cannot sync - preview org not available"); + return; + } + } + + setIsPreviewSyncing(true); + try { + console.log("[Preview] Syncing pricing config..."); + console.log("[Preview] Features:", config.features.length); + console.log("[Preview] Products:", config.products.length); + + const response = await fetch( + `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/sync`, + { + method: "POST", + credentials: "include", + headers: { + "x-client-type": "dashboard", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + features: config.features, + products: config.products, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + console.error("[Preview] Sync failed:", error); + return; + } + + const result = await response.json(); + console.log("[Preview] Sync complete:", result); + } catch (error) { + console.error("[Preview] Sync error:", error); + } finally { + setIsPreviewSyncing(false); + } + }, + [previewOrg, setupPreviewOrg], + ); + + const { messages, sendMessage, status, addToolOutput } = useChat({ + transport: new DefaultChatTransport({ + api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`, + credentials: "include", + headers: { + "x-client-type": "dashboard", + }, + }), + + // Auto-submit when all tool results are available (for multi-step if needed) + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + + // Handle client-side tool execution + onToolCall: async ({ toolCall }) => { + // Check for dynamic tools first + if (toolCall.dynamic) { + return; + } + + if (toolCall.toolName === "build_pricing") { + const config = toolCall.input as AgentPricingConfig; + + // Update the pricing preview + setPricingConfig(config); + + // Sync to preview org (fire and forget) + syncPreviewPricing(config); + + // Return the tool result (no await to avoid deadlocks) + addToolOutput({ + tool: "build_pricing", + toolCallId: toolCall.toolCallId, + output: { + success: true, + productsCount: config.products.length, + featuresCount: config.features.length, + }, + }); + } + }, + }); const handleCopyPlans = () => { pushPage({ path: "/products", navigate }); }; const handleSubmit = (message: PromptInputMessage) => { - if (!message.text.trim()) return; + if ( + (!message.text.trim() && message.files.length === 0) || + status !== "ready" + ) + return; - const userMessage: ChatMessage = { - id: crypto.randomUUID(), - role: "user", - content: message.text, - }; - - setMessages((prev) => [...prev, userMessage]); + sendMessage({ + text: message.text, + files: message.files, + }); setInput(""); - setIsLoading(true); - - // Simulate AI response and pricing generation - setTimeout(() => { - const assistantMessage: ChatMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: - "Based on your description, I've created a pricing model with three tiers. The **Free** tier includes basic features for individuals getting started. The **Pro** tier at $29/month is designed for professionals who need advanced capabilities. Finally, the **Enterprise** tier offers custom pricing for teams with dedicated support.\n\nWould you like me to adjust any of these tiers?", - }; - setMessages((prev) => [...prev, assistantMessage]); - setPricingTiers(MOCK_GENERATED_TIERS); - setIsLoading(false); - }, 1500); }; + const isLoading = status === "streaming" || status === "submitted"; + return (
{/* Header */} @@ -140,23 +284,102 @@ export function AIChatView({ onBack }: AIChatViewProps) { {messages.map((message) => ( - {message.content} + {message.parts.map((part, partIndex) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + + case "file": { + const isImage = + part.mediaType?.startsWith("image/"); + if (!isImage || !part.url) return null; + return ( +
+ {part.filename +
+ ); + } + + case "tool-build_pricing": { + const toolPart = part as BuildPricingToolPart; + return ( +
+ {toolPart.state === "input-streaming" || + toolPart.state === "input-available" ? ( + + Building pricing configuration + + ) : toolPart.state === "output-error" ? ( + + Error generating pricing + + ) : ( + <> + + Generated{" "} + {toolPart.input?.products.length ?? 0}{" "} + product(s) and{" "} + {toolPart.input?.features.length ?? 0}{" "} + feature(s) + + + + )} +
+ ); + } + + default: + return null; + } + })}
))} - {isLoading && ( -
- - Generating pricing... -
- )} + {isLoading && + messages.length > 0 && + messages[messages.length - 1]?.role === "user" && ( +
+ Planning next steps +
+ )} )}
- + + + + {(attachment) => } + + - - + + +
@@ -177,8 +401,12 @@ export function AIChatView({ onBack }: AIChatViewProps) {

Preview

- - {pricingTiers.length > 0 && ( + + {pricingConfig && pricingConfig.products.length > 0 && (
+ + !open && setJsonSheetConfig(null)} + config={jsonSheetConfig} + />
); } diff --git a/vite/src/views/onboarding4/OnboardingGuide.tsx b/vite/src/views/onboarding4/OnboardingGuide.tsx index 8780a2e63..413d14199 100644 --- a/vite/src/views/onboarding4/OnboardingGuide.tsx +++ b/vite/src/views/onboarding4/OnboardingGuide.tsx @@ -7,18 +7,21 @@ import { CheckCircleIcon, CreditCard, CubeIcon, + SparkleIcon, UserCircle, } from "@phosphor-icons/react"; import { X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import type { ReactNode } from "react"; import { useState } from "react"; +import { useNavigate } from "react-router"; import { Skeleton } from "@/components/ui/skeleton"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; import { IconButton } from "@/components/v2/buttons/IconButton"; import type { StepId } from "@/lib/snippets"; import { cn } from "@/lib/utils"; import { useEnv } from "@/utils/envUtils"; +import { pushPage } from "@/utils/genUtils"; import CreateProductSheet from "@/views/products/products/components/CreateProductSheet"; import { CodeSheet } from "./CodeSheet"; import { @@ -92,6 +95,7 @@ function StepCard({ onClick: () => void; }) { const { getPrompt } = useOnboardingPrompt(); + const navigate = useNavigate(); const isPlansStep = step.id === "plans"; const [createProductOpen, setCreateProductOpen] = useState(false); @@ -184,13 +188,29 @@ function StepCard({ variant="secondary" className="ml-auto gap-2" size="sm" + icon={} + onClick={(e) => { + e.stopPropagation(); + pushPage({ + path: "/quickstart", + navigate, + preserveParams: false, + }); + }} + > + AI builder + + } onClick={(e) => { e.stopPropagation(); setCreateProductOpen(true); }} > - Create Plan + Create plan ) : ( diff --git a/vite/src/views/onboarding4/PricingConfigSheet.tsx b/vite/src/views/onboarding4/PricingConfigSheet.tsx new file mode 100644 index 000000000..73b5e3c34 --- /dev/null +++ b/vite/src/views/onboarding4/PricingConfigSheet.tsx @@ -0,0 +1,74 @@ +import { + CodeGroup, + CodeGroupCode, + CodeGroupCopyButton, + CodeGroupList, + CodeGroupTab, +} from "@/components/v2/CodeGroup"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/v2/sheets/Sheet"; +import type { AgentPricingConfig } from "./pricingAgentUtils"; + +interface PricingConfigSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: AgentPricingConfig | null; +} + +/** Strip out display fields from features for cleaner JSON output */ +function filterConfigForDisplay({ + config, +}: { + config: AgentPricingConfig; +}): AgentPricingConfig { + return { + ...config, + // features: config.features.map((feature) => { + // const { display, ...rest } = feature; + // return rest as AgentFeature; + // }), + }; +} + +export function PricingConfigSheet({ + open, + onOpenChange, + config, +}: PricingConfigSheetProps) { + if (!config) return null; + + const filteredConfig = filterConfigForDisplay({ config }); + const formattedJson = JSON.stringify(filteredConfig, null, 2); + + return ( + + + + Pricing Configuration +

+ Generated configuration with {config.products.length} product(s) and{" "} + {config.features.length} feature(s) +

+
+ +
+ + + JSON + navigator.clipboard.writeText(formattedJson)} + /> + +
+ {formattedJson} +
+
+
+
+
+ ); +} diff --git a/vite/src/views/onboarding4/PricingPreview.tsx b/vite/src/views/onboarding4/PricingPreview.tsx index 066e67b34..810dcacfd 100644 --- a/vite/src/views/onboarding4/PricingPreview.tsx +++ b/vite/src/views/onboarding4/PricingPreview.tsx @@ -1,73 +1,105 @@ -import { Check } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { PricingTier } from "./templateConfigs"; +import { PreviewCreditSchemaCard } from "./preview/PreviewCreditSchemaCard"; +import { PreviewPlanCard } from "./preview/PreviewPlanCard"; +import { transformToPreviewProducts } from "./preview/previewTypes"; +import type { AgentPricingConfig } from "./pricingAgentUtils"; -interface PricingCardProps { - tier: PricingTier; -} - -function PricingCard({ tier }: PricingCardProps) { - return ( -
-
- {tier.name} - {tier.description && ( - {tier.description} - )} -
- -
- - {tier.price} - - {tier.interval && ( - /{tier.interval} - )} -
- -
- {tier.features.map((feature) => ( -
- - {feature} -
- ))} -
-
- ); +interface PreviewOrg { + apiKey: string; + orgId: string; + orgSlug: string; } interface PricingPreviewProps { - tiers: PricingTier[]; + config: AgentPricingConfig | null; + previewOrg: PreviewOrg | null; + isSyncing: boolean; } -export function PricingPreview({ tiers }: PricingPreviewProps) { - if (tiers.length === 0) { - return ( -
-
-

- Your pricing tiers will appear here as you describe them -

-
-
- ); - } +export function PricingPreview({ + config, + previewOrg, + isSyncing, +}: PricingPreviewProps) { + const hasProducts = config && config.products.length > 0; + + const previewProducts = hasProducts + ? transformToPreviewProducts({ + products: config.products, + features: config.features, + }) + : []; + + // Find credit system features to display their schemas + const creditSystemFeatures = hasProducts + ? config.features.filter( + (f) => + f.type === "credit_system" && + f.credit_schema && + f.credit_schema.length > 0, + ) + : []; return ( -
-
- {tiers.map((tier) => ( - - ))} +
+ {/* Mac window header */} +
+
+
+
+
+
+
+ + your-app.com/pricing + +
+ {/* Spacer to balance the layout */} +
+
+ + {/* Content area with dotted grid background */} +
+ {hasProducts ? ( + <> +
+ {previewProducts.map((product) => ( + + ))} +
+ + {/* Credit system schema cards */} + {creditSystemFeatures.length > 0 && ( +
+ {creditSystemFeatures.map((creditFeature) => ( + + ))} +
+ )} + + ) : ( +
+

+ Your pricing tiers will appear here as you describe them +

+
+ )}
); } - - diff --git a/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx new file mode 100644 index 000000000..333d09e1a --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx @@ -0,0 +1,113 @@ +import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; + +type CheckoutState = "idle" | "waiting_for_sync" | "creating_checkout"; + +interface PreviewCheckoutButtonProps { + productId: string; + previewApiKey: string; + isSyncing: boolean; +} + +export function PreviewCheckoutButton({ + productId, + previewApiKey, + isSyncing, +}: PreviewCheckoutButtonProps) { + const [checkoutState, setCheckoutState] = useState("idle"); + + const createCheckout = useCallback(async () => { + setCheckoutState("creating_checkout"); + try { + console.log( + `[Preview Checkout] Starting checkout for product: ${productId}`, + ); + + const response = await fetch( + `${import.meta.env.VITE_BACKEND_URL}/v1/checkout`, + { + method: "POST", + headers: { + Authorization: `Bearer ${previewApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + customer_id: "preview_customer", + product_id: productId, + success_url: window.location.href, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + console.error("[Preview Checkout] Failed:", error); + setCheckoutState("idle"); + return; + } + + const result = await response.json(); + console.log("[Preview Checkout] Result:", result); + + if (result.url) { + window.open(result.url, "_blank"); + } else { + console.error("[Preview Checkout] No URL in response"); + } + } catch (error) { + console.error("[Preview Checkout] Error:", error); + } finally { + setCheckoutState("idle"); + } + }, [productId, previewApiKey]); + + // When syncing finishes and we were waiting for it, create checkout + useEffect(() => { + if (checkoutState === "waiting_for_sync" && !isSyncing) { + createCheckout(); + } + }, [checkoutState, isSyncing, createCheckout]); + + const handleClick = () => { + if (isSyncing) { + // Wait for sync to complete + setCheckoutState("waiting_for_sync"); + } else { + // Sync is done, create checkout immediately + createCheckout(); + } + }; + + const isLoading = checkoutState !== "idle"; + + const getButtonText = () => { + switch (checkoutState) { + case "waiting_for_sync": + return "Creating Stripe products..."; + case "creating_checkout": + return "Redirecting to checkout..."; + default: + return "Preview Checkout"; + } + }; + + return ( + + ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx new file mode 100644 index 000000000..0db09dd2c --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx @@ -0,0 +1,76 @@ +import { Coins } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/v2/cards/Card"; +import type { AgentFeature } from "../pricingAgentUtils"; + +interface PreviewCreditSchemaCardProps { + creditFeature: AgentFeature; + allFeatures: AgentFeature[]; +} + +/** + * Displays a card showing how a credit system maps to underlying metered features + */ +export function PreviewCreditSchemaCard({ + creditFeature, + allFeatures, +}: PreviewCreditSchemaCardProps) { + const creditSchema = creditFeature.credit_schema; + + if (!creditSchema || creditSchema.length === 0) { + return null; + } + + const creditDisplayName = + creditFeature.name ?? creditFeature.display?.plural ?? creditFeature.id; + const creditSingular = creditFeature.display?.singular ?? "credit"; + + return ( + + +
+
+ +
+ {creditDisplayName} +
+

Credit cost per action

+
+ + +
+ {creditSchema.map((mapping) => { + const targetFeature = allFeatures.find( + (f) => f.id === mapping.metered_feature_id, + ); + const targetName = + targetFeature?.name ?? + targetFeature?.display?.singular ?? + mapping.metered_feature_id; + + return ( +
+ + {targetName} + + + {mapping.credit_cost}{" "} + {mapping.credit_cost === 1 + ? creditSingular + : (creditFeature.display?.plural ?? "credits")} + +
+ ); + })} +
+
+
+ ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx new file mode 100644 index 000000000..48e7c69c8 --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx @@ -0,0 +1,144 @@ +import { + BatteryHighIcon, + BoxArrowDownIcon, + CoinsIcon, + MoneyWavyIcon, + TicketIcon, + ToggleRightIcon, + WalletIcon, +} from "@phosphor-icons/react"; +import type React from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; +import type { AgentFeature } from "../pricingAgentUtils"; +import type { PreviewProductItem } from "./previewTypes"; + +interface PreviewFeatureIconProps { + item: PreviewProductItem; + position: "left" | "right"; + size?: number; +} + +type FeatureTypeKey = AgentFeature["type"]; +type BillingType = "included" | "prepaid" | "paid"; + +/** + * Get icon for feature type (left position) + */ +function getFeatureTypeIcon({ + featureType, + size, +}: { + featureType: FeatureTypeKey; + size: number; +}): { icon: React.ReactNode; color: string; label: string } { + const weight = "duotone"; + + switch (featureType) { + case "boolean": + case "static": + return { + icon: , + color: "text-red-500", + label: "Boolean", + }; + + case "credit_system": + return { + icon: , + color: "text-pink-500", + label: "Credit System", + }; + + case "continuous_use": + return { + icon: , + color: "text-blue-500", + label: "Non-consumable", + }; + + default: + return { + icon: , + color: "text-fuchsia-500", + label: "Consumable", + }; + } +} + +/** + * Determine billing type from item properties + */ +function getBillingType(item: PreviewProductItem): BillingType { + if (item.usageModel === "prepaid") { + return "prepaid"; + } + + if ( + item.usageModel === "pay_per_use" || + (item.price != null && item.price > 0) + ) { + return "paid"; + } + + return "included"; +} + +/** + * Get icon for billing type (right position) + */ +function getBillingTypeIcon({ + billingType, + size, +}: { + billingType: BillingType; + size: number; +}): { icon: React.ReactNode; color: string; label: string } { + const weight = "duotone"; + + switch (billingType) { + case "included": + return { + icon: , + color: "text-green-500", + label: "Included", + }; + + case "prepaid": + return { + icon: , + color: "text-orange-500", + label: "Prepaid", + }; + + case "paid": + return { + icon: , + color: "text-yellow-500", + label: "Usage-based", + }; + } +} + +export function PreviewFeatureIcon({ + item, + position, + size = 14, +}: PreviewFeatureIconProps) { + const iconData = + position === "left" + ? getFeatureTypeIcon({ featureType: item.featureType, size }) + : getBillingTypeIcon({ billingType: getBillingType(item), size }); + + return ( + + +
{iconData.icon}
+
+ {iconData.label} +
+ ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx new file mode 100644 index 000000000..71c93f39e --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx @@ -0,0 +1,45 @@ +import { PreviewFeatureIcon } from "./PreviewFeatureIcon"; +import type { PreviewProductItem } from "./previewTypes"; + +interface PreviewFeatureRowProps { + item: PreviewProductItem; +} + +/** Compact dot separator between icons */ +function DotIcon() { + return
; +} + +export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) { + // Use column layout only if item has both pricing AND included usage + const hasPricing = item.price != null && item.price > 0; + const hasIncludedUsage = + item.includedUsage != null && + (item.includedUsage === "inf" || item.includedUsage > 0); + const useColumnLayout = hasPricing && hasIncludedUsage; + + return ( +
+
+
+ {/* Feature icons */} +
+ + + +
+ + {item.display.primaryText} + +
+ {item.display.secondaryText && ( + + {item.display.secondaryText} + + )} +
+
+ ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx new file mode 100644 index 000000000..ca3344ba4 --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx @@ -0,0 +1,53 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/v2/cards/Card"; +import { PreviewCheckoutButton } from "./PreviewCheckoutButton"; +import { PreviewFeatureRow } from "./PreviewFeatureRow"; +import { PreviewPlanHeader } from "./PreviewPlanHeader"; +import type { PreviewProduct } from "./previewTypes"; + +interface PreviewPlanCardProps { + product: PreviewProduct; + previewApiKey?: string; + isSyncing: boolean; +} + +export function PreviewPlanCard({ + product, + previewApiKey, + isSyncing, +}: PreviewPlanCardProps) { + return ( + + + + + + + {product.items.length > 0 && ( +
+ {product.items.map((item, index) => ( + + ))} +
+ )} +
+ + {previewApiKey && ( + + + + )} +
+ ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewPlanHeader.tsx b/vite/src/views/onboarding4/preview/PreviewPlanHeader.tsx new file mode 100644 index 000000000..6b5de8781 --- /dev/null +++ b/vite/src/views/onboarding4/preview/PreviewPlanHeader.tsx @@ -0,0 +1,40 @@ +import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges"; +import type { PreviewProduct } from "./previewTypes"; + +interface PreviewPlanHeaderProps { + product: PreviewProduct; +} + +export function PreviewPlanHeader({ product }: PreviewPlanHeaderProps) { + const { basePrice } = product; + + return ( +
+ {/* Name row with badges */} +
+ + {product.name} + + +
+ + {/* Price */} +
+ + {basePrice.formattedAmount ?? basePrice.displayText} + + {basePrice.intervalText && ( + {basePrice.intervalText} + )} +
+
+ ); +} diff --git a/vite/src/views/onboarding4/preview/index.ts b/vite/src/views/onboarding4/preview/index.ts new file mode 100644 index 000000000..ac9fef011 --- /dev/null +++ b/vite/src/views/onboarding4/preview/index.ts @@ -0,0 +1,10 @@ +export { PreviewCreditSchemaCard } from "./PreviewCreditSchemaCard"; +export { PreviewFeatureIcon } from "./PreviewFeatureIcon"; +export { PreviewFeatureRow } from "./PreviewFeatureRow"; +export { PreviewPlanCard } from "./PreviewPlanCard"; +export { PreviewPlanHeader } from "./PreviewPlanHeader"; +export { + type PreviewProduct, + type PreviewProductItem, + transformToPreviewProducts, +} from "./previewTypes"; diff --git a/vite/src/views/onboarding4/preview/previewTypes.ts b/vite/src/views/onboarding4/preview/previewTypes.ts new file mode 100644 index 000000000..a10305a01 --- /dev/null +++ b/vite/src/views/onboarding4/preview/previewTypes.ts @@ -0,0 +1,219 @@ +import { + AppEnv, + type Feature, + FeatureType, + getProductItemDisplay, + Infinite, + type ProductItem, + type ProductV2, + productV2ToFrontendProduct, +} from "@autumn/shared"; +import { + type BasePriceDisplayResult, + getBasePriceDisplay, +} from "@/utils/product/basePriceDisplayUtils"; +import type { + AgentFeature, + AgentProduct, + AgentProductItem, +} from "../pricingAgentUtils"; + +/** + * Preview-friendly product format derived from AgentPricingConfig + */ +export interface PreviewProduct { + id: string; + name: string; + description?: string; + isAddOn?: boolean; + isDefault?: boolean; + basePrice: BasePriceDisplayResult; + items: PreviewProductItem[]; + freeTrial?: { + length: number; + duration: string; + }; +} + +export interface PreviewProductItem { + featureId: string; + featureName: string; + featureType: AgentFeature["type"]; + includedUsage?: number | "inf"; + price?: number; + usageModel?: "prepaid" | "pay_per_use"; + billingUnits?: number; + interval?: string; + display: { + primaryText: string; + secondaryText?: string; + }; +} + +/** + * Transform AgentPricingConfig products into preview-ready format + */ +export function transformToPreviewProducts({ + products, + features, +}: { + products: AgentProduct[]; + features: AgentFeature[]; +}): PreviewProduct[] { + // Convert agent features to shared Feature type for display function + const sharedFeatures = features.map(agentFeatureToFeature); + + return products.map((product) => { + // Convert to ProductV2 → FrontendProduct to use existing getBasePriceDisplay + const productV2 = agentProductToProductV2(product); + const frontendProduct = productV2ToFrontendProduct({ product: productV2 }); + const basePrice = getBasePriceDisplay({ product: frontendProduct }); + + // Transform feature items + const featureItems = (product.items ?? []) + .filter((item) => item.feature_id) + .map((item) => + transformToPreviewItem({ item, features, sharedFeatures }), + ); + + return { + id: product.id, + name: product.name, + isAddOn: product.is_add_on, + isDefault: product.is_default, + basePrice, + items: featureItems, + freeTrial: product.free_trial + ? { + length: product.free_trial.length, + duration: product.free_trial.duration, + } + : undefined, + }; + }); +} + +/** + * Convert AgentProduct to ProductV2 for use with shared utilities + */ +function agentProductToProductV2(product: AgentProduct): ProductV2 { + const items: ProductItem[] = (product.items ?? []).map( + agentItemToProductItem, + ); + + return { + internal_id: product.id, + id: product.id, + name: product.name, + description: null, + is_add_on: product.is_add_on ?? false, + is_default: product.is_default ?? false, + version: 1, + group: product.group ?? null, + env: AppEnv.Sandbox, + free_trial: null, // Free trial display handled separately in PreviewProduct + items, + created_at: Date.now(), + }; +} + +/** + * Map AgentFeature type string to FeatureType enum + */ +function mapFeatureType(agentType: AgentFeature["type"]): FeatureType { + switch (agentType) { + case "boolean": + case "static": + return FeatureType.Boolean; + case "credit_system": + return FeatureType.CreditSystem; + default: + return FeatureType.Metered; + } +} + +/** + * Convert AgentFeature to shared Feature type + */ +function agentFeatureToFeature(agentFeature: AgentFeature): Feature { + return { + internal_id: agentFeature.id, + org_id: "", + created_at: Date.now(), + env: AppEnv.Sandbox, + id: agentFeature.id, + name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id, + type: mapFeatureType(agentFeature.type), + config: null, + display: agentFeature.display + ? { + singular: agentFeature.display.singular, + plural: agentFeature.display.plural, + } + : undefined, + archived: false, + event_names: [], + }; +} + +/** + * Convert AgentProductItem to shared ProductItem type + */ +function agentItemToProductItem(item: AgentProductItem): ProductItem { + return { + feature_id: item.feature_id, + included_usage: + item.included_usage === "inf" + ? Infinite + : (item.included_usage ?? undefined), + interval: item.interval as ProductItem["interval"], + price: item.price, + billing_units: item.billing_units, + usage_model: item.usage_model as ProductItem["usage_model"], + tiers: + item.price != null ? [{ to: Infinite, amount: item.price }] : undefined, + }; +} + +function transformToPreviewItem({ + item, + features, + sharedFeatures, +}: { + item: AgentProductItem; + features: AgentFeature[]; + sharedFeatures: Feature[]; +}): PreviewProductItem { + const agentFeature = features.find((f) => f.id === item.feature_id); + const featureName = + agentFeature?.name ?? + agentFeature?.display?.plural ?? + item.feature_id ?? + "Feature"; + const featureType = agentFeature?.type ?? "single_use"; + + // Use shared getProductItemDisplay function + const productItem = agentItemToProductItem(item); + const displayResult = getProductItemDisplay({ + item: productItem, + features: sharedFeatures, + currency: "USD", + fullDisplay: true, + amountFormatOptions: { currencyDisplay: "narrowSymbol" }, + }); + + return { + featureId: item.feature_id ?? "", + featureName, + featureType, + includedUsage: item.included_usage ?? undefined, + price: item.price ?? undefined, + usageModel: item.usage_model ?? undefined, + billingUnits: item.billing_units ?? undefined, + interval: item.interval ?? undefined, + display: { + primaryText: displayResult.primary_text, + secondaryText: displayResult.secondary_text ?? undefined, + }, + }; +} diff --git a/vite/src/views/onboarding4/pricingAgentUtils.ts b/vite/src/views/onboarding4/pricingAgentUtils.ts new file mode 100644 index 000000000..d778d43e2 --- /dev/null +++ b/vite/src/views/onboarding4/pricingAgentUtils.ts @@ -0,0 +1,137 @@ +import type { PricingTier } from "./templateConfigs"; + +/** + * Types for the pricing config returned by the AI agent's build_pricing tool + */ +export interface AgentFeature { + id: string; + name?: string | null; + type: + | "static" + | "boolean" + | "single_use" + | "continuous_use" + | "credit_system"; + display?: { + singular: string; + plural: string; + } | null; + credit_schema?: Array<{ + metered_feature_id: string; + credit_cost: number; + }> | null; +} + +export interface AgentProductItem { + feature_id?: string | null; + included_usage?: number | "inf" | null; + interval?: string | null; + price?: number | null; + usage_model?: "prepaid" | "pay_per_use" | null; + billing_units?: number | null; +} + +export interface AgentFreeTrial { + length: number; + duration: "day" | "month" | "year"; + unique_fingerprint?: boolean; + card_required?: boolean; +} + +export interface AgentProduct { + id: string; + name: string; + is_add_on?: boolean; + is_default?: boolean; + group?: string; + items?: AgentProductItem[]; + free_trial?: AgentFreeTrial | null; +} + +export interface AgentPricingConfig { + features: AgentFeature[]; + products: AgentProduct[]; +} + +/** + * Transform an AgentPricingConfig (from the AI) into PricingTier[] (for the UI) + */ +export function transformConfigToTiers({ + config, + features, +}: { + config: AgentPricingConfig; + features: AgentFeature[]; +}): PricingTier[] { + return config.products.map((product) => { + // Find the base price (item without feature_id, or first priced item) + const basePrice = product.items?.find( + (item) => !item.feature_id && item.price != null, + ); + const fixedPrice = basePrice?.price ?? 0; + const interval = basePrice?.interval ?? "month"; + + // Determine price display + let priceDisplay: string; + if ( + fixedPrice === 0 && + !product.items?.some((i) => i.price && i.price > 0) + ) { + priceDisplay = "Free"; + } else if (fixedPrice > 0) { + priceDisplay = `$${fixedPrice}`; + } else { + // Usage-based only + priceDisplay = "Usage-based"; + } + + // Build feature list for the card + const featureList: string[] = []; + + for (const item of product.items ?? []) { + if (item.feature_id) { + const feature = features.find((f) => f.id === item.feature_id); + const featureName = + feature?.name ?? feature?.display?.plural ?? item.feature_id; + + if (item.included_usage === "inf") { + featureList.push(`Unlimited ${featureName}`); + } else if (item.included_usage != null && item.included_usage > 0) { + featureList.push( + `${item.included_usage.toLocaleString()} ${featureName}`, + ); + } else if (item.price != null && item.price > 0) { + featureList.push( + `${featureName} at $${item.price}${item.billing_units ? `/${item.billing_units}` : "/unit"}`, + ); + } + } else if (item.price != null && item.price > 0 && !basePrice) { + // It's a standalone price item + featureList.push(`$${item.price}/${item.interval ?? "month"} base`); + } + } + + // Add free trial info if present + if (product.free_trial) { + featureList.push( + `${product.free_trial.length} ${product.free_trial.duration} free trial`, + ); + } + + // Determine if this tier should be highlighted + // Typically the "Pro" or middle tier, or explicitly named + const isHighlighted = + product.name.toLowerCase().includes("pro") || + product.name.toLowerCase().includes("plus") || + product.name.toLowerCase().includes("premium"); + + return { + name: product.name, + price: priceDisplay, + interval: fixedPrice > 0 ? interval : undefined, + description: product.is_add_on ? "Add-on" : undefined, + features: featureList.length > 0 ? featureList : ["Basic features"], + highlighted: isHighlighted, + }; + }); +} From 271af8cb8d5813324362936012b376240134224d Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 13:33:09 +0000 Subject: [PATCH 35/59] cleanup --- .../src/_luaScriptsV2/IMPLEMENTATION_PLAN.md | 228 ----- server/src/_luaScriptsV2/TODO.md | 9 - server/src/cron/cronInit.ts | 14 +- server/src/cron/cronUtils.ts | 2 +- server/src/errors/errCodes.ts | 75 -- server/src/errors/errMessages.ts | 3 - server/src/external/supabase/storageUtils.ts | 2 +- server/src/external/supabaseUtils.ts | 13 - server/src/external/webhooks/webhookUtils.ts | 49 -- .../honoMiddlewares/refreshCacheMiddleware.ts | 4 +- server/src/internal/api/check/handleCheck.ts | 47 -- .../internal/api/check/runCheckWithTrack.ts | 10 +- server/src/internal/api/events/usageRouter.ts | 216 ----- server/src/internal/api/trmnl/trmnlUtils.ts | 11 - .../prepareNewBalanceForInsertion.ts | 58 +- .../createBalance/validateCreateBalance.ts | 60 +- .../balances/handlers/handleCreateBalance.ts | 52 +- .../internal/balances/handlers/handleTrack.ts | 6 +- .../balances/handlers/handleUpdateBalance.ts | 14 +- .../setUsage/getSetUsageDeductions.ts | 18 +- .../balances/setUsage/handleSetUsage.ts | 24 +- .../track/redisTrackUtils/BatchingManager.ts | 217 ----- .../redisTrackUtils/executeBatchDeduction.ts | 111 --- .../redisTrackUtils/runRedisDeduction.ts | 255 ------ .../src/internal/balances/track/runTrack.ts | 131 --- .../trackUtils/executePostgresTracking.ts | 149 ---- .../trackUtils/getTrackBalancesResponse.ts | 74 -- .../track/trackUtils/runDeductionTx.ts | 384 --------- .../trackUtils/validateDeductionPossible.ts | 184 ----- .../getFeatureDeductions.ts | 0 .../updateGrantedBalance.ts | 0 .../deduction/executePostgresDeduction.ts | 19 +- .../utils/deduction/executeRedisDeduction.ts | 16 +- .../balances/utils}/handleThresholdReached.ts | 6 +- .../utils/legacy/performDeduction.ts} | 71 +- .../utils/legacy/performDeductionOnCusEnt.ts | 185 +++++ .../paidAllocatedFeature}/adjustAllowance.ts | 6 +- .../createUpgradeProrationInvoice.ts | 2 +- .../handleProratedDowngrade.ts | 0 .../handleProratedUpgrade.ts | 0 .../handlePaidAllocatedCusEnt.ts | 10 +- .../rollbackDeduction.ts | 9 +- .../utils/sync/legacy/runSyncBalanceBatch.ts | 47 -- .../balances/utils/sync/legacy/syncItem.ts | 291 ------- .../balances/utils/sync/legacy/syncItemV2.ts | 275 ------ .../createUsageInvoiceItems.ts | 12 +- .../getContUseItems/getContUseUpgradeItems.ts | 42 +- .../cusEnts/CusEntitlementService.ts | 98 +-- .../cusEnts/cusEntUtils/getExistingUsage.ts | 2 +- .../cusRollovers/rolloverDeductionUtils.ts | 130 --- server/src/internal/customers/cusRouter.ts | 5 - .../apiCusCacheUtils/BatchingManager.ts | 217 ----- .../apiCusCacheUtils/executeBatchDeduction.ts | 44 - .../getApiBalance/apiBalanceUtils.ts | 2 + .../getApiBalance/getApiBalance.ts | 14 +- .../getApiBalance/getApiBalances.ts | 2 - .../handlers/handleUpdateBalances.ts | 295 ------- .../handlers/handleUpdateBalancesV2.ts | 13 +- .../handlers/handleUpdateCusEntitlementV2.ts | 77 -- .../handlers/handleUpdateEntitlement.ts | 178 ---- .../createEntityForCusProduct.ts | 4 +- .../handleDeleteEntity/handleDeleteEntity.ts | 2 +- server/src/queue/bullmq/initBullMqWorkers.ts | 12 +- server/src/queue/initWorkers.ts | 23 - server/src/scan/runScan.ts | 405 --------- server/src/trigger/updateBalanceTask.ts | 781 ------------------ server/src/trigger/updateUsageTask.ts | 602 -------------- server/tests/_temp/temp1.test.ts | 178 ---- server/tests/_temp/temp2.test.ts | 57 -- server/tests/_temp/temp3.test.ts | 277 ------- server/tests/attach/entities/entity4.test.ts | 3 + .../tests/balances/check/basic/check6.test.ts | 2 + .../tests/balances/cron/loose-reset.test.ts | 181 ++++ .../balances/set-usage/set-usage1.test.ts | 28 +- .../balances/track/loose/loose-expiry.test.ts | 3 - .../simulate-verify-cache.test.ts | 2 +- .../simulate-verify-cache2.test.ts | 2 +- .../balances/utils/findCustomerEntitlement.ts | 35 + .../balances/utils/fullCusEntToResetCusEnt.ts | 22 + .../balances/utils/getCustomerEntitlement.ts | 27 + .../balances/utils/getCustomerEntitlements.ts | 27 + .../tests/utils/expectUtils/expectAttach.ts | 4 +- .../expectUtils/expectProductAttached.ts | 9 +- shared/api/_openapi2.0_/balancesOpenApi.ts | 4 +- .../balances/create/createBalanceParams.ts | 96 +-- .../api/customers/cusFeatures/apiBalance.ts | 2 +- .../cusEntModels/cusEntWithProduct.ts | 2 +- .../cusEntUtils/sortCusEntsForDeduction.ts | 42 +- .../cusProductUtils/convertCusProduct.ts | 54 +- .../fullCustomerToCustomerEntitlements.ts | 3 +- shared/utils/featureUtils/findFeatureUtils.ts | 72 ++ shared/utils/index.ts | 5 +- .../planFeatureUtils/planFeaturesToItems.ts | 21 +- .../entUtils/enrichEntitlement.ts | 33 + .../entUtils/enrichEntitlementUtils.ts | 27 + shared/utils/productUtils/entUtils/index.ts | 2 + shared/utils/utils.ts | 8 + .../hooks/useFeatureUsageBalance.ts | 2 +- 98 files changed, 966 insertions(+), 6581 deletions(-) delete mode 100644 server/src/_luaScriptsV2/IMPLEMENTATION_PLAN.md delete mode 100644 server/src/_luaScriptsV2/TODO.md delete mode 100644 server/src/errors/errCodes.ts delete mode 100644 server/src/errors/errMessages.ts delete mode 100644 server/src/external/supabaseUtils.ts delete mode 100644 server/src/external/webhooks/webhookUtils.ts delete mode 100644 server/src/internal/api/events/usageRouter.ts delete mode 100644 server/src/internal/api/trmnl/trmnlUtils.ts delete mode 100644 server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts delete mode 100644 server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts delete mode 100644 server/src/internal/balances/track/redisTrackUtils/runRedisDeduction.ts delete mode 100644 server/src/internal/balances/track/runTrack.ts delete mode 100644 server/src/internal/balances/track/trackUtils/executePostgresTracking.ts delete mode 100644 server/src/internal/balances/track/trackUtils/getTrackBalancesResponse.ts delete mode 100644 server/src/internal/balances/track/trackUtils/runDeductionTx.ts delete mode 100644 server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts rename server/src/internal/balances/track/{trackUtils => utils}/getFeatureDeductions.ts (100%) rename server/src/internal/balances/{updateGrantedBalance => updateBalance}/updateGrantedBalance.ts (100%) rename server/src/{trigger => internal/balances/utils}/handleThresholdReached.ts (93%) rename server/src/{trigger/deductUtils.ts => internal/balances/utils/legacy/performDeduction.ts} (52%) create mode 100644 server/src/internal/balances/utils/legacy/performDeductionOnCusEnt.ts rename server/src/{trigger => internal/balances/utils/paidAllocatedFeature}/adjustAllowance.ts (93%) rename server/src/{trigger/arrearProratedUsage => internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice}/createUpgradeProrationInvoice.ts (98%) rename server/src/{trigger/arrearProratedUsage => internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice}/handleProratedDowngrade.ts (100%) rename server/src/{trigger/arrearProratedUsage => internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice}/handleProratedUpgrade.ts (100%) rename server/src/internal/balances/{track/trackUtils => utils/paidAllocatedFeature}/handlePaidAllocatedCusEnt.ts (82%) rename server/src/internal/balances/{track/trackUtils => utils/paidAllocatedFeature}/rollbackDeduction.ts (82%) delete mode 100644 server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts delete mode 100644 server/src/internal/balances/utils/sync/legacy/syncItem.ts delete mode 100644 server/src/internal/balances/utils/sync/legacy/syncItemV2.ts delete mode 100644 server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts delete mode 100644 server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts delete mode 100644 server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts delete mode 100644 server/src/internal/customers/handlers/handleUpdateBalances.ts delete mode 100644 server/src/internal/customers/handlers/handleUpdateCusEntitlementV2.ts delete mode 100644 server/src/internal/customers/handlers/handleUpdateEntitlement.ts delete mode 100644 server/src/scan/runScan.ts delete mode 100644 server/src/trigger/updateBalanceTask.ts delete mode 100644 server/src/trigger/updateUsageTask.ts delete mode 100644 server/tests/_temp/temp1.test.ts delete mode 100644 server/tests/_temp/temp2.test.ts delete mode 100644 server/tests/_temp/temp3.test.ts create mode 100644 server/tests/balances/cron/loose-reset.test.ts create mode 100644 server/tests/balances/utils/findCustomerEntitlement.ts create mode 100644 server/tests/balances/utils/fullCusEntToResetCusEnt.ts create mode 100644 server/tests/balances/utils/getCustomerEntitlement.ts create mode 100644 server/tests/balances/utils/getCustomerEntitlements.ts create mode 100644 shared/utils/featureUtils/findFeatureUtils.ts create mode 100644 shared/utils/productUtils/entUtils/enrichEntitlement.ts create mode 100644 shared/utils/productUtils/entUtils/enrichEntitlementUtils.ts create mode 100644 shared/utils/productUtils/entUtils/index.ts diff --git a/server/src/_luaScriptsV2/IMPLEMENTATION_PLAN.md b/server/src/_luaScriptsV2/IMPLEMENTATION_PLAN.md deleted file mode 100644 index bb93a76dd..000000000 --- a/server/src/_luaScriptsV2/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,228 +0,0 @@ -# Redis JSON Cache Layer - Implementation Plan - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ CACHE LAYER │ -├─────────────────────────────────────────────────────────────────┤ -│ Storage: Redis JSON (FullCustomer object) │ -│ Master: us-east (writes from attach/webhooks) │ -│ Replicas: all regions (reads for track/check) │ -├─────────────────────────────────────────────────────────────────┤ -│ Write Patterns: │ -│ - Attach: JSON.SET full object │ -│ - Webhooks: JSON.SET with JSONPath (targeted updates) │ -│ - Track: JSON.NUMINCRBY via Lua (atomic balance deductions) │ -├─────────────────────────────────────────────────────────────────┤ -│ Read Pattern: │ -│ - getCachedFullCustomer: cache hit → return, miss → DB + set │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Phase 1: Cache Read/Write Foundation - -**Goal**: Replace current cache layer with Redis JSON storing `FullCustomer` - -### 1.1 New Lua Scripts - -``` -server/src/_luaScriptsV2/ -├── fullCustomer/ -│ ├── getFullCustomer.lua # JSON.GET with fallback handling -│ ├── setFullCustomer.lua # JSON.SET with version/guard checks -│ └── deleteFullCustomer.lua # JSON.DEL with guard marker -``` - -### 1.2 New TypeScript Functions - -| Function | Location | Purpose | -|----------|----------|---------| -| `getCachedFullCustomer` | `server/src/internal/customers/cusUtils/fullCusCacheUtils/` | Read from cache or DB+set | -| `setCachedFullCustomer` | same | Write FullCustomer to cache | -| `deleteCachedFullCustomer` | same | Invalidate cache | -| `getOrCreateCachedFullCustomer` | same | Get or create, returns FullCustomer | - -### 1.3 Integration Points - -| Endpoint | Current | New | -|----------|---------|-----| -| `handleGetCustomerV2` | `getCachedApiCustomer` → ApiCustomer | `getCachedFullCustomer` → `fullCusToApiCustomer` | -| `handleCheck` | `getOrCreateApiCustomer` | `getOrCreateCachedFullCustomer` | -| `handleTrack` | `getOrCreateCustomer` | `getOrCreateCachedFullCustomer` | - -### 1.4 Mapping Function - -```typescript -// New: fullCusToApiCustomer.ts -// Takes FullCustomer, returns ApiCustomer -// Replaces cache-read logic in getApiCustomer.ts -``` - ---- - -## Phase 2: Atomic Balance Deductions (Track) - -**Goal**: Lua script for atomic deductions matching `performDeduction.sql` logic - -### 2.1 Lua Script Structure - -``` -server/src/_luaScriptsV2/ -├── deduction/ -│ ├── performDeduction.lua # Main deduction orchestrator -│ ├── deductFromRollovers.lua # Step 1: Rollover deduction -│ ├── deductFromAdditional.lua # Step 2: Additional balance -│ └── deductFromMain.lua # Step 3: Main balance (2 passes) -``` - -### 2.2 Deduction Lua Script Outline - -```lua --- performDeduction.lua --- Input: customerId, featureId, amount, entitlementIds[], overageBehavior --- Output: { updates: {entId: {balance, adjustment, deducted}}, remaining } - --- 1. Get current balances via JSON.GET $.customer_products[*].customer_entitlements[?(@.id in entIds)] --- 2. Calculate deductions (same logic as SQL) --- 3. Apply via JSON.NUMINCRBY for each affected balance --- 4. Return updated values + breakdown IDs for sync -``` - -### 2.3 Sync Function - -```typescript -// syncItemV3.ts -// Input: customerId, updatedEntitlementIds[], region -// 1. Read balances from Redis for those entitlement IDs -// 2. UPDATE customer_entitlements SET balance=X, adjustment=Y, entities=Z WHERE id IN (...) -``` - -### 2.4 Integration - -| Current | New | -|---------|-----| -| `runRedisDeduction` → Lua scripts | `runRedisDeductionV2` → new Lua | -| `syncItemV2` | `syncItemV3` (reads from Redis JSON) | - ---- - -## Phase 3: Cache Invalidation (Initial) - -**Goal**: Simple invalidation on structural changes (attach/webhooks) - -### 3.1 Invalidation Points - -| Operation | Action | -|-----------|--------| -| Attach (success) | `deleteCachedFullCustomer` | -| Stripe webhook (structural) | `deleteCachedFullCustomer` | -| Reset (cron) | `deleteCachedFullCustomer` | - -### 3.2 Future: Targeted Updates (Phase 4) - -```typescript -// Later: Use JSONPath for surgical updates instead of full invalidation -await redis.json.set(key, '$.invoices[?(@.stripe_id=="inv_123")].status', '"paid"'); -``` - ---- - -## Data Flow Summary - -``` -GET CUSTOMER: - getCachedFullCustomer() - → cache hit? return FullCustomer - → cache miss? CusService.getFull() → setCachedFullCustomer() → return - → fullCusToApiCustomer() - → return ApiCustomer - -CHECK/TRACK: - getOrCreateCachedFullCustomer() - → getCachedFullCustomer() or create new - → performDeduction.lua (atomic in Redis) - → queue syncItemV3 job - → return response - -ATTACH: - [existing attach logic] - → deleteCachedFullCustomer() - -WEBHOOK: - [existing webhook logic] - → deleteCachedFullCustomer() -``` - ---- - -## File Checklist - -### New Lua Scripts (`server/src/_luaScriptsV2/`) - -- [ ] `fullCustomer/getFullCustomer.lua` -- [ ] `fullCustomer/setFullCustomer.lua` -- [ ] `fullCustomer/deleteFullCustomer.lua` -- [ ] `deduction/performDeduction.lua` -- [ ] `deduction/deductFromRollovers.lua` -- [ ] `deduction/deductFromAdditional.lua` -- [ ] `deduction/deductFromMain.lua` - -### New TypeScript (`server/src/internal/customers/cusUtils/fullCusCacheUtils/`) - -- [ ] `getCachedFullCustomer.ts` -- [ ] `setCachedFullCustomer.ts` -- [ ] `deleteCachedFullCustomer.ts` -- [ ] `getOrCreateCachedFullCustomer.ts` -- [ ] `fullCusToApiCustomer.ts` - -### New Sync (`server/src/internal/balances/utils/sync/`) - -- [ ] `syncItemV3.ts` - -### New Track (`server/src/internal/balances/track/`) - -- [ ] `runRedisDeductionV2.ts` - ---- - -## Notes - -- Do NOT modify existing functions (except replacing them in top-level callers) -- All new Lua scripts go in `_luaScriptsV2/` -- Phase 3 uses simple invalidation; targeted JSONPath updates come later - ---- - -## ⚠️ Important Notes - -### Use RedisJSON Commands, NOT Regular SET/GET - -When storing FullCustomer in Redis, **you MUST use RedisJSON commands** (`JSON.SET`, `JSON.GET`), not regular `SET`/`GET` with `JSON.stringify`. - -**Why?** -- Phase 2 requires JSONPath operations (`JSON.NUMINCRBY`, `JSON.GET $.path`) for atomic deductions -- Regular `SET` stores JSON as a string blob - you cannot use JSONPath on it -- RedisJSON stores JSON natively, enabling partial reads/writes - -**Correct:** -```typescript -// Write -await redis.call("JSON.SET", cacheKey, "$", JSON.stringify(fullCustomer)); -await redis.expire(cacheKey, TTL_SECONDS); - -// Read -const result = await redis.call("JSON.GET", cacheKey); - -// Check exists -const exists = await redis.call("JSON.TYPE", cacheKey); -``` - -**Wrong:** -```typescript -// ❌ This stores JSON as a string - JSONPath won't work! -await redis.set(cacheKey, JSON.stringify(fullCustomer), "EX", TTL_SECONDS); -await redis.get(cacheKey); -``` diff --git a/server/src/_luaScriptsV2/TODO.md b/server/src/_luaScriptsV2/TODO.md deleted file mode 100644 index 3f041a7fb..000000000 --- a/server/src/_luaScriptsV2/TODO.md +++ /dev/null @@ -1,9 +0,0 @@ -# TODOs - -## Cache Invalidation -- [ ] `cusUtils.ts:66-71` - Add `deleteCachedFullCustomer` call when `updateCustomerDetails` updates a customer (currently only invalidates ApiCustomer cache) - -## Lua Scripts -- [x] `deduction/deductFromFullCustomer.lua` - Main deduction script (mirrors `performDeduction.sql`) -- [x] `luaScriptsV2.ts` - TypeScript loader for Lua scripts -- [x] `executeRedisDeduction.ts` - Calls the Lua script diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 66ba04592..86a6c3847 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -19,11 +19,15 @@ const { db, client } = initDrizzle(); export const cronTask = async () => { try { - const [productCusEnts, looseCusEnts] = await Promise.all([ - CusEntService.getActiveResetPassed({ db, batchSize: 500 }), - CusEntService.getLooseResetPassed({ db, batchSize: 500 }), - ]); - const cusEnts: ResetCusEnt[] = [...productCusEnts, ...looseCusEnts]; + // const [productCusEnts, looseCusEnts] = await Promise.all([ + // CusEntService.getActiveResetPassed({ db, batchSize: 500 }), + // CusEntService.getLooseResetPassed({ db, batchSize: 500 }), + // ]); + // const cusEnts: ResetCusEnt[] = [...productCusEnts, ...looseCusEnts]; + const cusEnts = await CusEntService.getActiveResetPassed({ + db, + batchSize: 500, + }); const batchSize = 100; for (let i = 0; i < cusEnts.length; i += batchSize) { diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index 1ce8c271c..cbe435d4a 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -51,7 +51,7 @@ const checkSubAnchor = async ({ // 1. Get the customer product const cusProduct = await CusProductService.getByIdForReset({ db, - id: cusEnt.customer_product_id, + id: cusEnt.customer_product_id ?? "", }); // Get org and env diff --git a/server/src/errors/errCodes.ts b/server/src/errors/errCodes.ts deleted file mode 100644 index cdd1d2512..000000000 --- a/server/src/errors/errCodes.ts +++ /dev/null @@ -1,75 +0,0 @@ -// export const ErrCode = { -// // General -// InvalidRequest: "invalid_request", -// InvalidId: "invalid_id", - -// // Org -// CreateClerkOrgFailed: "create_clerk_org_failed", -// AssignUserToOrgFailed: "assign_user_to_org_failed", - -// // Feature -// FeatureNotFound: "feature_not_found", -// InvalidFeature: "invalid_feature", -// DuplicateFeatureId: "duplicate_feature_id", -// UpdateFeatureFailed: "update_feature_failed", - -// // Internal -// InternalError: "internal_error", -// DuplicateCustomerId: "duplicate_customer_id", -// StripeKeyNotFound: "stripe_key_not_found", - -// // Stripe -// StripeKeyInvalid: "stripe_key_invalid", -// StripeConfigNotFound: "stripe_config_not_found", -// StripeDeleteCustomerFailed: "stripe_delete_customer_failed", -// StripeCreateCustomerFailed: "stripe_create_customer_failed", -// StripeCreateProductFailed: "stripe_create_product_failed", -// StripeCancelSubscriptionFailed: "stripe_cancel_subscription_failed", - -// // Price -// PriceNotFound: "price_not_found", -// CreatePriceFailed: "create_price_failed", -// InvalidPrice: "invalid_price", -// InvalidPriceId: "invalid_price_id", -// InvalidPriceOptions: "invalid_price_options", -// InvalidPriceConfig: "invalid_price_config", - -// // Customer -// InvalidCustomer: "invalid_customer", -// CreateCustomerFailed: "create_customer_failed", -// CustomerNotFound: "customer_not_found", -// CustomerAlreadyHasProduct: "customer_already_has_product", -// CustomerHasNoPaymentMethod: "customer_has_no_payment_method", -// CustomerHasNoBaseProduct: "customer_has_no_base_product", -// AttachProductToCustomerFailed: "attach_product_to_customer_failed", -// MultipleProductsFound: "multiple_products_found", -// MultipleCustomersFound: "multiple_customers_found", -// GetCusWithProductsFailed: "get_cus_with_products_failed", - -// // Product - -// CreateStripeProductFailed: "create_stripe_product_failed", -// DeleteStripeProductFailed: "delete_stripe_product_failed", -// CreateStripeSubscriptionFailed: "create_stripe_subscription_failed", -// UpdateCusProductFailed: "update_customer_product_failed", -// DefaultProductNotAllowedPrice: "default_product_not_allowed_price", -// InvalidOptions: "invalid_options", - -// // Entitlements -// InvalidEntitlement: "invalid_entitlement", -// CreateEntitlementFailed: "create_entitlement_failed", - -// // Invoice -// CreateInvoiceFailed: "create_invoice_failed", -// PayInvoiceFailed: "pay_invoice_failed", - -// // Payment errors -// CardDeclinedError: "card_declined_error", - -// // Entity -// EntityNotFound: "entity_not_found", - -// // Analytics -// NoEventsFound: "no_events_found", -// ClickHouseNotEnabled: "clickhouse_not_enabled", -// }; diff --git a/server/src/errors/errMessages.ts b/server/src/errors/errMessages.ts deleted file mode 100644 index bcc6c2ce1..000000000 --- a/server/src/errors/errMessages.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum ErrorMessages { - InternalError = "Internal error...please try again or contact us at +44 7498317257!", -} diff --git a/server/src/external/supabase/storageUtils.ts b/server/src/external/supabase/storageUtils.ts index 7df7c08b3..c4a41591e 100644 --- a/server/src/external/supabase/storageUtils.ts +++ b/server/src/external/supabase/storageUtils.ts @@ -1,4 +1,4 @@ -import { createSupabaseClient } from "../supabaseUtils.js"; +import { createSupabaseClient } from "./createSupabaseClient"; export const readFile = async ({ bucket = "autumn", diff --git a/server/src/external/supabaseUtils.ts b/server/src/external/supabaseUtils.ts deleted file mode 100644 index 4624c2397..000000000 --- a/server/src/external/supabaseUtils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; - -export const createSupabaseClient = () => { - try { - return createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_SERVICE_KEY!, - ); - } catch (error) { - console.error("Error creating Supabase client:", error); - throw error; - } -}; diff --git a/server/src/external/webhooks/webhookUtils.ts b/server/src/external/webhooks/webhookUtils.ts deleted file mode 100644 index 5517a16a2..000000000 --- a/server/src/external/webhooks/webhookUtils.ts +++ /dev/null @@ -1,49 +0,0 @@ -// export const verifySvixSignature = async (req: any, res: any) => { -// const SIGNING_SECRET = process.env.CLERK_SIGNING_SECRET; - -// if (!SIGNING_SECRET) { -// throw new Error( -// "Error: Please add SIGNING_SECRET from Clerk Dashboard to .env", -// ); -// } - -// const headers = req.headers; -// const svix_id = headers["svix-id"]; -// const svix_timestamp = headers["svix-timestamp"]; -// const svix_signature = headers["svix-signature"]; - -// // Verify all headers are presen3t -// if (!svix_id || !svix_timestamp || !svix_signature) { -// throw new Error("Error: Missing svix headers"); -// } - -// // Verify timestamp is within tolerance (5 minutes) -// const timestamp = parseInt(svix_timestamp); -// const now = Math.floor(Date.now() / 1000); -// if (Math.abs(now - timestamp) > 300) { -// throw new Error("Error: Message timestamp too old"); -// } - -// const body = JSON.stringify(req.body); -// const signedContent = `${svix_id}.${svix_timestamp}.${body}`; - -// // Need to base64 decode the secret -// const secretBytes = Buffer.from(SIGNING_SECRET.split("_")[1], "base64"); -// const signature = crypto -// .createHmac("sha256", secretBytes) -// .update(signedContent) -// .digest("base64"); - -// // Get the actual signature from the header (removing the v1, prefix) -// const svixSignature = svix_signature.split(" ")[0].split(",")[1]; - -// try { -// // Use constant-time comparison to prevent timing attacks -// return crypto.timingSafeEqual( -// Buffer.from(signature), -// Buffer.from(svixSignature), -// ); -// } catch (err) { -// return false; -// } -// }; diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index e4498c4a2..766d5b63f 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -56,7 +56,7 @@ const coreUrls: { method: string; url: string; source?: string }[] = [ method: "POST", url: "/balances/create", source: "handleCreateBalance", - } + }, ]; /** @@ -76,7 +76,7 @@ export const refreshCacheMiddleware = async ( } const ctx = c.get("ctx"); - const { logger, org, env, skipCacheDeletion } = ctx; + const { logger, skipCacheDeletion } = ctx; if (skipCacheDeletion) { return; diff --git a/server/src/internal/api/check/handleCheck.ts b/server/src/internal/api/check/handleCheck.ts index fd0d7be88..f8f9c57a2 100644 --- a/server/src/internal/api/check/handleCheck.ts +++ b/server/src/internal/api/check/handleCheck.ts @@ -28,7 +28,6 @@ export const handleCheck = createRoute({ const { customer_id, - feature_id, product_id, entity_id, required_quantity, @@ -98,49 +97,3 @@ export const handleCheck = createRoute({ }); }, }); - -// await handleEventSent({ -// req: { -// ...ctx, -// body: { -// ...body, -// value: requiredBalance, -// }, -// }, -// customer_id: customer_id, -// customer_data: customer_data, -// event_data: { -// customer_id: customer_id, -// feature_id: feature_id, -// value: requiredBalance, -// entity_id: entity_id, -// }, -// }); - -// if (v2Response.allowed && ctx.isPublic !== true) { -// if (send_event && feature_id) { -// // console.log( -// // `Allowed is true, sending event for customer ${customer_id}, feature ${feature_id}`, -// // ); -// const featureDeductions = getTrackFeatureDeductions({ -// ctx, -// featureId: feature_id, -// value: requiredBalance, -// }); - -// await runTrack({ -// ctx, -// body: { -// customer_id, -// entity_id, -// feature_id, -// value: requiredBalance, -// properties: body.properties, -// skip_event: body.skip_event, -// } satisfies TrackParams, -// featureDeductions, -// }); -// } -// } - -// Apply version transformations based on API version diff --git a/server/src/internal/api/check/runCheckWithTrack.ts b/server/src/internal/api/check/runCheckWithTrack.ts index 8129af803..a0c550175 100644 --- a/server/src/internal/api/check/runCheckWithTrack.ts +++ b/server/src/internal/api/check/runCheckWithTrack.ts @@ -9,11 +9,11 @@ import { RecaseError, type TrackParams, } from "@autumn/shared"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv"; -import { runTrackV2 } from "../../balances/track/runTrackV2"; -import { getTrackFeatureDeductions } from "../../balances/track/trackUtils/getFeatureDeductions"; -import { featureToCreditSystem } from "../../features/creditSystemUtils"; -import type { CheckData } from "./checkTypes/CheckData"; +import type { AutumnContext } from "@server/honoUtils/HonoEnv.js"; +import { runTrackV2 } from "@server/internal/balances/track/runTrackV2"; +import { getTrackFeatureDeductions } from "@server/internal/balances/track/utils/getFeatureDeductions.js"; +import { featureToCreditSystem } from "@server/internal/features/creditSystemUtils.js"; +import type { CheckData } from "./checkTypes/CheckData.js"; export const runCheckWithTrack = async ({ ctx, diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts deleted file mode 100644 index f5f074cdb..000000000 --- a/server/src/internal/api/events/usageRouter.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - CusProductStatus, - ErrCode, - type EventInsert, - FeatureType, - type FullCustomer, -} from "@autumn/shared"; -import { Router } from "express"; -import { StatusCodes } from "http-status-codes"; -import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; -import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js"; -import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { generateId, nullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { EventService } from "./EventService.js"; -import { getEventTimestamp } from "./eventUtils.js"; - -export const eventsRouter: Router = Router(); -export const usageRouter: Router = Router(); - -const getCusFeatureAndOrg = async ({ - req, - customerId, - featureId, - entityId, - customerData, -}: { - req: ExtendedRequest; - customerId: string; - featureId: string; - entityId: string; - customerData: any; -}) => { - // 1. Get customer - const { org, features } = req; - - const customer = await getOrCreateCustomer({ - ctx: req as unknown as AutumnContext, - customerId, - customerData, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - entityData: req.body.entity_data, - withEntities: true, - }); - - const feature = features.find((f) => f.id === featureId); - const creditSystems = features.filter( - (f) => - f.type === FeatureType.CreditSystem && - creditSystemContainsFeature({ - creditSystem: f, - meteredFeatureId: featureId, - }), - ); - - if (!feature) { - throw new RecaseError({ - message: `Feature ${featureId} not found`, - code: ErrCode.FeatureNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - return { customer, org, feature, creditSystems }; -}; - -const createAndInsertEvent = async ({ - req, - customer, - featureId, - value, - set_usage, - properties, - idempotencyKey, -}: { - req: any; - customer: FullCustomer; - featureId: string; - value?: number; - set_usage?: boolean; - properties: any; - idempotencyKey?: string; -}) => { - if (!customer.id) { - throw new RecaseError({ - message: "Customer ID is required", - code: ErrCode.InvalidInputs, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const timestamp = getEventTimestamp(req.body.timestamp); - - const entityId = req.body.entity_id; - let internalEntityId = null; - if (entityId) { - internalEntityId = customer.entity?.internal_id; - } - - const newEvent: EventInsert = { - id: generateId("evt"), - org_id: req.org.id, - org_slug: req.org.slug, - env: req.env, - internal_customer_id: customer.internal_id, - - created_at: timestamp.getTime(), - timestamp: timestamp, - - idempotency_key: idempotencyKey, - customer_id: customer.id, - event_name: featureId, - 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 }); -}; - -export const handleUsageEvent = async ({ - req, - setUsage = false, -}: { - req: any; - setUsage?: boolean; -}) => { - let { - customer_id, - customer_data, - properties, - feature_id, - value, - entity_id, - idempotency_key, - } = req.body; - const { logger } = req; - - if (!customer_id || !feature_id) { - throw new RecaseError({ - message: "customer_id and feature_id are required", - code: ErrCode.InvalidInputs, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - properties = properties || {}; - - const { customer, feature, creditSystems } = await getCusFeatureAndOrg({ - req, - customerId: customer_id, - featureId: feature_id, - customerData: customer_data, - entityId: entity_id, - }); - - const newEvent = await createAndInsertEvent({ - req, - customer, - featureId: feature_id, - value, - set_usage: setUsage, - properties, - idempotencyKey: idempotency_key, - }); - - const features = [feature, ...creditSystems]; - - if (nullish(value) || Number.isNaN(parseFloat(value))) { - value = 1; - } else { - value = parseFloat(value); - } - - const payload = { - customerId: customer.id, - internalCustomerId: customer.internal_id, - eventId: newEvent.id, - features, - allFeatures: req.features, - org: req.org, - env: req.env, - properties, - value, - set_usage: setUsage, - entityId: entity_id, - }; - - await runUpdateUsageTask({ - payload, - logger: console, - db: req.db, - throwError: true, - }); - - return { event: newEvent, affectedFeatures: features, org: req.org }; -}; - -usageRouter.post("", async (req: any, res: any) => { - try { - await handleUsageEvent({ req, setUsage: true }); - res.status(StatusCodes.OK).json({ success: true }); - } catch (error) { - return handleRequestError({ - req, - res, - error, - action: "handleUsageEvent", - }); - } -}); diff --git a/server/src/internal/api/trmnl/trmnlUtils.ts b/server/src/internal/api/trmnl/trmnlUtils.ts deleted file mode 100644 index ca311d1f9..000000000 --- a/server/src/internal/api/trmnl/trmnlUtils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { readFile } from "@/external/supabase/storageUtils.js"; -import { createSupabaseClient } from "@/external/supabaseUtils.js"; - -export const getTrmnlJson = async () => { - let sb = createSupabaseClient(); - const file = await readFile({ bucket: "private", path: "trmnl.json" }); - const fileString = await file.text(); - const fileJson = JSON.parse(fileString); - - return fileJson as Record; -}; diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index 8b5d3b70b..450e48ee6 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -1,13 +1,11 @@ import { - type CreateBalanceSchema, + type CreateBalanceParams, type CustomerEntitlement, - type Entity, + enrichEntitlementWithFeature, type Feature, type FullCustomer, planFeaturesToItems, - type ResetInterval, -} from "@shared/index"; -import type z from "zod/v4"; +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; import { initNextResetAt } from "@/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt"; @@ -15,41 +13,18 @@ import { toFeature } from "@/internal/products/product-items/productItemUtils/it export const prepareNewBalanceForInsertion = async ({ ctx, + fullCustomer, feature, - granted_balance, - unlimited, - reset, - expires_at, - fullCus, - feature_id, - entity, + params, }: { ctx: AutumnContext; feature: Feature; - granted_balance: number | undefined; - unlimited: boolean | undefined; - reset: z.infer["reset"]; - expires_at: number | undefined; - fullCus: FullCustomer; - feature_id: string; - entity?: Entity; + fullCustomer: FullCustomer; + params: CreateBalanceParams; }) => { const inputAsItem = planFeaturesToItems({ features: [feature], - planFeatures: [ - { - feature_id, - granted_balance: granted_balance, - unlimited, - reset: reset - ? { - interval: reset.interval as ResetInterval, - interval_count: reset.interval_count, - reset_when_enabled: true, - } - : undefined, - }, - ], + planFeatures: [params], }); const { ent: newEntitlement } = toFeature({ @@ -59,19 +34,20 @@ export const prepareNewBalanceForInsertion = async ({ internalFeatureId: feature.internal_id!, }); + const entity = fullCustomer.entity; + if (entity) { newEntitlement.entity_feature_id = entity.feature_id; } - const newEntitlementWithFeature = { - ...newEntitlement, + const newEntitlementWithFeature = enrichEntitlementWithFeature({ + entitlement: newEntitlement, feature, - feature_id: feature.id, - }; + }); const newCustomerEntitlement = initCusEntitlement({ entitlement: newEntitlementWithFeature, - customer: fullCus, + customer: fullCustomer, cusProductId: null, freeTrial: null, nextResetAt: @@ -84,7 +60,7 @@ export const prepareNewBalanceForInsertion = async ({ replaceables: [], now: Date.now(), productOptions: undefined, - expires_at: expires_at ?? null, + expires_at: params.expires_at ?? null, }) satisfies CustomerEntitlement; // If entity is provided, assign balance to entity instead of customer-level @@ -93,8 +69,8 @@ export const prepareNewBalanceForInsertion = async ({ } // Set expiry if provided (mutually exclusive with reset interval) - if (expires_at) { - newCustomerEntitlement.expires_at = expires_at; + if (params.expires_at) { + newCustomerEntitlement.expires_at = params.expires_at ?? null; // Clear next_reset_at since expiring entitlements don't reset newCustomerEntitlement.next_reset_at = null; } diff --git a/server/src/internal/balances/createBalance/validateCreateBalance.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts index 2a89df3e4..f3bcc7670 100644 --- a/server/src/internal/balances/createBalance/validateCreateBalance.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -1,88 +1,66 @@ import { + type CreateBalanceParams, ErrCode, type Feature, FeatureType, type FullCustomer, RecaseError, ValidateCreateBalanceParamsSchema, -} from "@shared/index"; +} from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import type { z } from "zod/v4"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase"; export const validateCreateBalanceParams = async ({ ctx, + params, feature, - internalCustomerId, - granted_balance, - unlimited, - reset, - expires_at, fullCustomer, - entity_id, }: { ctx: AutumnContext; + params: CreateBalanceParams; feature: Feature; - internalCustomerId: string; - granted_balance: number | undefined; - unlimited: boolean | undefined; - reset: z.infer["reset"]; - expires_at: number | undefined; fullCustomer: FullCustomer; - entity_id?: string; }) => { ValidateCreateBalanceParamsSchema.parse({ + ...params, feature, - granted_balance, - unlimited, - reset, - expires_at, - customer_id: internalCustomerId, - feature_id: feature.id, - entity_id, }); await validateBooleanEntitlementConflict({ ctx, feature, - internalCustomerId: fullCustomer.internal_id, + fullCustomer, }); // Entity cannot receive a balance of its own feature type - if (entity_id) { - const entity = fullCustomer.entities.find((e) => e.id === entity_id); - if (entity && feature.id === entity.feature_id) { - throw new RecaseError({ - message: `Cannot give an entity a balance of its own feature type`, - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } + const entity = fullCustomer.entity; + if (entity && feature.id === entity.feature_id) { + throw new RecaseError({ + message: `Cannot give an entity a balance of its own feature type`, + }); } }; export const validateBooleanEntitlementConflict = async ({ ctx, feature, - internalCustomerId, + fullCustomer, }: { ctx: AutumnContext; feature: Feature; - internalCustomerId: string; + fullCustomer: FullCustomer; }) => { if (feature.type === FeatureType.Boolean) { - const existingBooleanEntitlement = await CusEntService.getByFeature({ - db: ctx.db, - internalFeatureId: feature.internal_id!, - internalCustomerId, + const { apiCustomer } = await getApiCustomerBase({ + ctx, + fullCus: fullCustomer, }); - if (existingBooleanEntitlement.length > 0) { + if (apiCustomer.balances?.[feature.id]) { throw new RecaseError({ - message: `A boolean entitlement ${feature.id} already exists for customer ${internalCustomerId}`, - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, + message: `A boolean entitlement ${feature.id} already exists for customer ${fullCustomer.internal_id}`, }); } } diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 4f5ceeba0..ade5f6ec2 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,28 +1,21 @@ -import { CreateBalanceSchema, EntityNotFoundError } from "@autumn/shared"; +import { CreateBalanceParamsSchema } from "@autumn/shared"; import { FeatureNotFoundError } from "@shared/index"; import type { DrizzleCli } from "@/db/initDrizzle"; import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { prepareNewBalanceForInsertion } from "@/internal/balances/createBalance/prepareNewBalanceForInsertion"; +import { validateCreateBalanceParams } from "@/internal/balances/createBalance/validateCreateBalance"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; -import { prepareNewBalanceForInsertion } from "../createBalance/prepareNewBalanceForInsertion"; -import { validateCreateBalanceParams } from "../createBalance/validateCreateBalance"; export const handleCreateBalance = createRoute({ - body: CreateBalanceSchema, + body: CreateBalanceParamsSchema, handler: async (c) => { const ctx = c.get("ctx"); const { org, env } = ctx; - const { - feature_id, - customer_id, - entity_id, - granted_balance, - unlimited, - reset, - expires_at, - } = c.req.valid("json"); + + const createBalanceParams = c.req.valid("json"); + const { feature_id, customer_id, entity_id } = createBalanceParams; const feature = ctx.features.find((f) => f.id === feature_id); if (!feature) { @@ -34,40 +27,23 @@ export const handleCreateBalance = createRoute({ idOrInternalId: customer_id, orgId: org.id, env: env, + entityId: entity_id, withEntities: true, }); - if (entity_id && !fullCustomer.entities.find((e) => e.id === entity_id)) { - throw new EntityNotFoundError({ - entityId: entity_id, - }); - } - await validateCreateBalanceParams({ ctx, feature, - internalCustomerId: fullCustomer.internal_id, - granted_balance, - unlimited, - reset, - expires_at, + params: createBalanceParams, fullCustomer, - entity_id, }); const { newEntitlement, newCustomerEntitlement } = await prepareNewBalanceForInsertion({ ctx, feature, - granted_balance, - unlimited, - reset, - expires_at, - fullCus: fullCustomer, - entity: entity_id - ? fullCustomer.entities.find((e) => e.id === entity_id) - : undefined, - feature_id, + fullCustomer, + params: createBalanceParams, }); await ctx.db.transaction(async (tx) => { @@ -82,12 +58,6 @@ export const handleCreateBalance = createRoute({ }); }); - await deleteCachedFullCustomer({ - customerId: customer_id, - ctx, - source: "handleCreateBalance", - }); - return c.json({ success: true }); }, }); diff --git a/server/src/internal/balances/handlers/handleTrack.ts b/server/src/internal/balances/handlers/handleTrack.ts index a09107f1d..3a92e54b1 100644 --- a/server/src/internal/balances/handlers/handleTrack.ts +++ b/server/src/internal/balances/handlers/handleTrack.ts @@ -1,10 +1,10 @@ import { TrackParamsSchema, TrackQuerySchema } from "@autumn/shared"; -import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; -import { runTrackV2 } from "../track/runTrackV2.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { runTrackV2 } from "@/internal/balances/track/runTrackV2.js"; import { getTrackEventNameDeductions, getTrackFeatureDeductions, -} from "../track/trackUtils/getFeatureDeductions.js"; +} from "@/internal/balances/track/utils/getFeatureDeductions.js"; export const handleTrack = createRoute({ query: TrackQuerySchema, diff --git a/server/src/internal/balances/handlers/handleUpdateBalance.ts b/server/src/internal/balances/handlers/handleUpdateBalance.ts index 8013901de..74b24b2b1 100644 --- a/server/src/internal/balances/handlers/handleUpdateBalance.ts +++ b/server/src/internal/balances/handlers/handleUpdateBalance.ts @@ -6,13 +6,13 @@ import { UpdateBalanceParamsSchema, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; -import { CusService } from "../../customers/CusService.js"; -import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import { runUpdateBalanceV2 } from "../updateBalance/runUpdateBalanceV2.js"; -import { updateGrantedBalance } from "../updateGrantedBalance/updateGrantedBalance.js"; -import { buildCustomerEntitlementFilters } from "../utils/buildCustomerEntitlementFilters.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { runUpdateBalanceV2 } from "@/internal/balances/updateBalance/runUpdateBalanceV2"; +import { updateGrantedBalance } from "@/internal/balances/updateBalance/updateGrantedBalance"; +import { buildCustomerEntitlementFilters } from "@/internal/balances/utils/buildCustomerEntitlementFilters"; +import { CusService } from "@/internal/customers/CusService"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; export const handleUpdateBalance = createRoute({ body: UpdateBalanceParamsSchema.extend({}), diff --git a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts index b0db40efe..7cad1feab 100644 --- a/server/src/internal/balances/setUsage/getSetUsageDeductions.ts +++ b/server/src/internal/balances/setUsage/getSetUsageDeductions.ts @@ -5,6 +5,7 @@ import { type Feature, FeatureNotFoundError, FeatureType, + type FullCustomer, type FullCustomerEntitlement, fullCustomerToCustomerEntitlements, orgToInStatuses, @@ -46,22 +47,15 @@ const cusEntsHasFeatureBalance = ({ export const getSetUsageDeductions = async ({ ctx, setUsageParams, + fullCustomer, }: { ctx: AutumnContext; setUsageParams: SetUsageParams; + fullCustomer: FullCustomer; }): Promise => { const { org, features: allFeatures } = ctx; const { value, entity_id } = setUsageParams; - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: setUsageParams.customer_id, - orgId: ctx.org.id, - env: ctx.env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId: setUsageParams.entity_id, - }); - const feature = allFeatures.find((f) => f.id === setUsageParams.feature_id); if (!feature) { throw new FeatureNotFoundError({ @@ -70,7 +64,7 @@ export const getSetUsageDeductions = async ({ } const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, + fullCustomer, reverseOrder: org.config?.reverse_deduction_order, featureId: feature.id, inStatuses: orgToInStatuses({ org }), @@ -142,7 +136,7 @@ export const getSetUsageDeductions = async ({ // ========================================== const deductionCusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, + fullCustomer, reverseOrder: org.config?.reverse_deduction_order, featureId: deductionFeature.id, inStatuses: orgToInStatuses({ org }), @@ -163,8 +157,6 @@ export const getSetUsageDeductions = async ({ ), ); - console.log("totalAllowance", totalAllowance); - // ========================================== // TARGET BALANCE CALCULATION // ========================================== diff --git a/server/src/internal/balances/setUsage/handleSetUsage.ts b/server/src/internal/balances/setUsage/handleSetUsage.ts index 80cd25234..c70929e5a 100644 --- a/server/src/internal/balances/setUsage/handleSetUsage.ts +++ b/server/src/internal/balances/setUsage/handleSetUsage.ts @@ -1,6 +1,7 @@ -import { SetUsageParamsSchema } from "@autumn/shared"; +import { ACTIVE_STATUSES, SetUsageParamsSchema } from "@autumn/shared"; +import { CusService } from "@/internal/customers/CusService.js"; import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; -import { runDeductionTx } from "../track/trackUtils/runDeductionTx.js"; +import { executePostgresDeduction } from "../utils/deduction/executePostgresDeduction.js"; import { getSetUsageDeductions } from "./getSetUsageDeductions.js"; export const handleSetUsage = createRoute({ @@ -10,25 +11,30 @@ export const handleSetUsage = createRoute({ const body = c.req.valid("json"); const ctx = c.get("ctx"); + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: body.customer_id, + entityId: body.entity_id, + orgId: ctx.org.id, + env: ctx.env, + inStatuses: ACTIVE_STATUSES, + }); + // Build feature deductions const featureDeductions = await getSetUsageDeductions({ ctx, setUsageParams: body, + fullCustomer, }); - const start = Date.now(); - await runDeductionTx({ + await executePostgresDeduction({ ctx, + fullCustomer, customerId: body.customer_id, - entityId: body.entity_id, deductions: featureDeductions, - refreshCache: true, }); - const elapsed = Date.now() - start; - ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`); - return c.json({ success: true }); }, }); diff --git a/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts b/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts deleted file mode 100644 index 5e757261c..000000000 --- a/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { ApiBalance } from "@autumn/shared"; -import { redis } from "../../../../external/redis/initRedis.js"; -import { buildCachedApiCustomerKey } from "../../../customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; -import { buildCachedApiEntityKey } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; -import { executeBatchDeduction } from "./executeBatchDeduction.js"; - -interface FeatureDeduction { - featureId: string; - amount: number; -} - -export interface DeductionResult { - success: boolean; - error?: string; - customerChanged?: boolean; - changedEntityIds?: string[]; - balances?: Record; // Object of changed balances keyed by featureId - modifiedBreakdownIds?: string[]; -} - -interface BatchRequest { - featureDeductions: FeatureDeduction[]; - overageBehavior: "cap" | "reject"; - resolve: (result: DeductionResult) => void; - reject: (error: Error) => void; -} - -interface Batch { - requests: BatchRequest[]; - timer: NodeJS.Timeout | null; - customerId: string; - orgId: string; - env: string; - entityId?: string; -} - -/** - * Batching manager for Redis track deductions - * Collects multiple deduction requests within a time window and processes them atomically in a single Lua script - * - * Benefits: - * - Massive performance improvements for high-concurrency scenarios - * - Atomic deductions across multiple requests - * - Reduced Redis round trips - */ -export class BatchingManager { - private batches = new Map(); - private readonly BATCH_WINDOW_MS = 10; // 10ms batching window - private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests - - /** - * Request a deduction with automatic batching - * Returns a promise that resolves when the batch is processed - */ - async deduct({ - customerId, - featureDeductions, - orgId, - env, - entityId, - overageBehavior = "cap", - }: { - customerId: string; - featureDeductions: FeatureDeduction[]; - orgId: string; - env: string; - entityId?: string; - overageBehavior?: "cap" | "reject"; - }): Promise { - // CRITICAL: Batch by customer AND entity (if entity-level deduction) - // This ensures entity-level deductions are atomic per entity - // Customer-level: {orgId}:env:customer:{customerId} - // Entity-level: {orgId}:env:customer:{customerId}:entity:{entityId} - const batchKey = entityId - ? buildCachedApiEntityKey({ entityId, customerId, orgId, env }) - : buildCachedApiCustomerKey({ customerId, orgId, env }); - - return new Promise((resolve, reject) => { - // Create batch if it doesn't exist - if (!this.batches.has(batchKey)) { - this.batches.set(batchKey, { - requests: [], - timer: null, - customerId, - orgId, - env, - entityId, - }); - - // Schedule batch execution - this.scheduleBatch(batchKey); - } - - const batch = this.batches.get(batchKey); - if (!batch) { - reject(new Error("Failed to get batch")); - return; - } - - // Add request to batch - batch.requests.push({ - featureDeductions, - overageBehavior, - resolve, - reject, - }); - - // Force flush if batch is full - if (batch.requests.length >= this.MAX_BATCH_SIZE) { - this.executeBatch(batchKey); - } - }); - } - - /** - * Schedule batch execution after window expires - */ - private scheduleBatch(batchKey: string): void { - const batch = this.batches.get(batchKey); - if (!batch) return; - - batch.timer = setTimeout(() => { - this.executeBatch(batchKey); - }, this.BATCH_WINDOW_MS); - } - - /** - * Execute the batch - process all requests in one Lua script - */ - private async executeBatch(batchKey: string): Promise { - // CRITICAL: Remove batch from map FIRST to prevent race condition - // New requests will create a new batch instead of adding to this one - const batch = this.batches.get(batchKey); - if (!batch || batch.requests.length === 0) { - return; - } - - // Clear timer and remove from map IMMEDIATELY - if (batch.timer) { - clearTimeout(batch.timer); - batch.timer = null; - } - this.batches.delete(batchKey); - - const requests = batch.requests; - - try { - // Execute batch Lua script (Lua builds cache key internally) - // All requests in this batch have the same entityId (batch-level) - const result = await executeBatchDeduction({ - redis, - requests: requests.map((r) => ({ - featureDeductions: r.featureDeductions, - overageBehavior: r.overageBehavior, - entityId: batch.entityId, // Use batch-level entityId (same for all requests) - })), - orgId: batch.orgId, - env: batch.env, - customerId: batch.customerId, - }); - - // Resolve each request based on its individual result - if (result.success && result.results) { - // Match each request with its result - // All requests in this batch get the same customerChanged/changedEntityIds - for (let i = 0; i < requests.length; i++) { - const requestResult = result.results[i]; - requests[i].resolve({ - success: requestResult?.success || false, - error: requestResult?.error, - customerChanged: result.customerChanged, - changedEntityIds: result.changedEntityIds, - balances: result.balances, - modifiedBreakdownIds: result.modifiedBreakdownIds, - }); - } - } else { - // Batch failed entirely (e.g., customer not found) - for (const request of requests) { - request.resolve({ - success: false, - error: result.error || "BATCH_FAILED", - }); - } - } - } catch (error) { - console.error(`❌ Batch execution error:`, error); - // Reject all requests on error - for (const request of requests) { - request.reject( - error instanceof Error ? error : new Error(String(error)), - ); - } - } - } - - /** - * Get current batch statistics (for monitoring) - */ - getStats(): { - activeBatches: number; - totalPendingRequests: number; - } { - let totalPendingRequests = 0; - for (const batch of this.batches.values()) { - totalPendingRequests += batch.requests.length; - } - - return { - activeBatches: this.batches.size, - totalPendingRequests, - }; - } -} - -// Singleton instance -export const globalBatchingManager = new BatchingManager(); diff --git a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts deleted file mode 100644 index 2e354e839..000000000 --- a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { ApiBalance } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { logger } from "../../../../external/logtail/logtailUtils"; - -interface FeatureDeduction { - featureId: string; - amount: number; -} - -export interface BatchRequestFilters { - id?: string; // Match breakdown.id (customer_entitlement_id) - interval?: string; // Match breakdown.reset.interval (e.g., "month", "week") -} - -interface BatchRequest { - featureDeductions: FeatureDeduction[]; - overageBehavior: "cap" | "reject" | "allow"; - syncMode?: boolean; // If true, sync cache to target balance instead of deducting - targetBalance?: number; // Target balance for sync mode (per feature) - entityId?: string; - filters?: BatchRequestFilters; // Filter which breakdown items to consider -} - -interface RequestResult { - success: boolean; - error?: string; -} - -interface BatchDeductionResult { - success: boolean; - results: RequestResult[]; - error?: string; - customerChanged?: boolean; // True if customer-level features were modified - changedEntityIds?: string[]; // Array of entity IDs that were modified - balances?: Record; // Object of changed balances keyed by featureId - featureDeductions?: Record; // Actual amounts deducted per feature - modifiedBreakdownIds?: string[]; // Array of breakdown.id (customer_entitlement_id) values that were modified - debug?: unknown; // For debugging purposes -} - -/** - * Execute batch deduction Lua script - * Processes multiple track requests atomically in a single Redis call - * Each request can deduct from multiple features - */ -export const executeBatchDeduction = async ({ - redis, - requests, - orgId, - env, - customerId, - adjustGrantedBalance = false, -}: { - redis: Redis; - requests: BatchRequest[]; - orgId: string; - env: string; - customerId: string; - adjustGrantedBalance?: boolean; -}): Promise => { - try { - // Execute Lua script (hot reload in dev) - const result = await redis.batchDeduction( - JSON.stringify(requests), // ARGV[1] - orgId, // ARGV[2] - env, // ARGV[3] - customerId, // ARGV[4] - adjustGrantedBalance ? "true" : "false", // ARGV[5] - ); - - // Parse result - const parsed = JSON.parse(result as string) as BatchDeductionResult; - - // Log debug info if present - if (parsed.debug) { - console.log("🔍 Lua debug info:", JSON.stringify(parsed.debug, null, 2)); - } - - // // Log actual feature deductions - // if ( - // parsed.featureDeductions && - // Object.keys(parsed.featureDeductions).length > 0 - // ) { - // console.log( - // "✅ Feature deductions from Redis:", - // parsed.featureDeductions, - // ); - // } - - return parsed; - } catch (error) { - console.error("Error executing batch deduction:", error); - - logger.error(`Error executing batch deduction: ${error}`, { - data: { - orgId, - env, - customerId, - requests, - }, - error: { - message: error instanceof Error ? error.message : "UNKNOWN_ERROR", - }, - }); - return { - success: false, - results: [], - error: error instanceof Error ? error.message : "UNKNOWN_ERROR", - }; - } -}; diff --git a/server/src/internal/balances/track/redisTrackUtils/runRedisDeduction.ts b/server/src/internal/balances/track/redisTrackUtils/runRedisDeduction.ts deleted file mode 100644 index 74b6d3906..000000000 --- a/server/src/internal/balances/track/redisTrackUtils/runRedisDeduction.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { - type ApiBalance, - ApiBalanceSchema, - type ApiCustomer, - InsufficientBalanceError, - type TrackParams, - type TrackQuery, -} from "@autumn/shared"; -import { currentRegion } from "@/external/redis/initRedis.js"; -import { - normalizeFromSchema, - normalizeToArray, -} from "@/utils/cacheUtils/normalizeFromSchema.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; -import { getOrCreateApiCustomer } from "../../../customers/cusUtils/getOrCreateApiCustomer.js"; -import { globalEventBatchingManager } from "../../events/EventBatchingManager.js"; -import { type EventInfo, initEvent } from "../../events/initEvent.js"; -import { globalSyncBatchingManager } from "../../utils/sync/SyncBatchingManager.js"; -import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; -import { - type DeductionResult, - globalBatchingManager, -} from "./BatchingManager.js"; - -type RunRedisDeductionParams = { - ctx: AutumnContext; - // customerId: string; - // customerData?: CustomerData; - // entityId?: string; - // entityData?: EntityData; - query: TrackQuery; - trackParams: TrackParams; - featureDeductions: FeatureDeduction[]; - overageBehavior: "cap" | "reject"; - eventInfo?: EventInfo; -}; - -interface RunRedisDeductionResult { - fallback: boolean; - code: - | "success" - | "insufficient_balance" - | "idempotency_key" - | "allocated_feature" - | "skip_cache" - | "redis_write_failed"; - - internalCustomerId?: string; - internalEntityId?: string; - balances?: Record; // Object of changed balances keyed by featureId -} - -const queueSyncAndEvent = ({ - ctx, - trackParams, - featureDeductions, - eventInfo, - result, - apiCustomer, -}: RunRedisDeductionParams & { - result: DeductionResult; - apiCustomer: ApiCustomer; -}) => { - const { customer_id, entity_id } = trackParams; - const { org, env } = ctx; - - ctx.logger.info( - `[queueSync] (${customer_id}): customer changed: ${result.customerChanged}, changed entity ids: ${Array.isArray(result.changedEntityIds) ? result.changedEntityIds.join(", ") : "none"}`, - ); - - for (const deduction of featureDeductions) { - // If customer was changed, queue customer-level sync - if (result.customerChanged) { - globalSyncBatchingManager.addSyncPair({ - customerId: customer_id, - featureId: deduction.feature.id, - orgId: org.id, - env, - entityId: undefined, // Customer-level sync - region: currentRegion, - breakdownIds: result.modifiedBreakdownIds || [], - }); - } - - // For each changed entity, queue entity-level sync - if (result.changedEntityIds && result.changedEntityIds.length > 0) { - for (const changedEntityId of result.changedEntityIds) { - globalSyncBatchingManager.addSyncPair({ - customerId: customer_id, - featureId: deduction.feature.id, - orgId: org.id, - env, - entityId: changedEntityId, - region: currentRegion, - breakdownIds: result.modifiedBreakdownIds || [], - }); - } - } - } - - if (!trackParams.skip_event && apiCustomer?.autumn_id && eventInfo) { - globalEventBatchingManager.addEvent( - initEvent({ - ctx, - eventInfo: eventInfo, - internalCustomerId: apiCustomer?.autumn_id, - - internalEntityId: - apiCustomer?.entities?.find((entity) => entity.id === entity_id) - ?.autumn_id ?? undefined, - - customerId: customer_id, - entityId: entity_id, - }), - ); - } -}; - -/** - * Executes deductions against cached customer data in Redis - * Uses batching manager to efficiently process multiple deductions - */ -export const runRedisDeduction = async ({ - ctx, - query, - trackParams, - featureDeductions, - overageBehavior, - eventInfo, -}: RunRedisDeductionParams): Promise => { - const { org, env, skipCache } = ctx; - - if (query.skip_cache || skipCache) { - return { - fallback: true, - code: "skip_cache", - }; - } - - const { - customer_id: customerId, - customer_data: customerData, - entity_id: entityId, - entity_data: entityData, - } = trackParams; - - const { apiCustomer } = await getOrCreateApiCustomer({ - ctx, - customerId, - customerData, - entityId, - entityData, - }); - - const result = await tryRedisWrite(async () => { - // Map feature deductions to the format expected by batching manager - const mappedDeductions = featureDeductions.map( - ({ feature, deduction }) => ({ - featureId: feature.id, - amount: deduction, - }), - ); - - const result = await globalBatchingManager.deduct({ - customerId, - featureDeductions: mappedDeductions, - orgId: org.id, - env, - entityId, - overageBehavior, - }); - - if (result.balances) { - result.balances = Object.fromEntries( - Object.entries(result.balances).map(([featureId, balance]) => [ - featureId, - normalizeFromSchema({ schema: ApiBalanceSchema, data: balance }), - ]), - ); - } - - if (result.modifiedBreakdownIds) { - result.modifiedBreakdownIds = normalizeToArray( - result.modifiedBreakdownIds, - ); - } - - if (result.success) { - try { - queueSyncAndEvent({ - ctx, - query, - trackParams, - featureDeductions, - overageBehavior, - eventInfo, - result, - apiCustomer, - }); - } catch (error) { - ctx.logger.error(`Failed to queue sync and event! ${error}`); - } - } - - // Handle PAID_ALLOCATED error - fallback to Postgres - if (result.error === "PAID_ALLOCATED") { - ctx.logger.info( - `Paid allocated feature detected, falling back to Postgres: ${featureDeductions.map((d) => d.feature.id).join(", ")}`, - ); - return { - fallback: true, - code: "allocated_feature", - }; - } - - // Handle CUSTOMER_NOT_FOUND - fallback to Postgres (cache may be blocked by guard) - if (result.error === "CUSTOMER_NOT_FOUND") { - ctx.logger.info(`Customer not found in cache, falling back to Postgres`); - return { - fallback: true, - code: "redis_write_failed", - }; - } - - return { - fallback: false, - code: - result.error === "INSUFFICIENT_BALANCE" - ? "insufficient_balance" - : !result.success - ? "redis_write_failed" - : "success", - - balances: result.balances, - }; - }); - - if (result === null) { - return { - fallback: true, - code: "redis_write_failed", - }; - } - - if (result.code === "insufficient_balance") { - throw new InsufficientBalanceError({ - value: trackParams.value ?? 1, - featureId: trackParams.feature_id, - eventName: trackParams.event_name, - }); - } - - return result; -}; diff --git a/server/src/internal/balances/track/runTrack.ts b/server/src/internal/balances/track/runTrack.ts deleted file mode 100644 index 829b41f8c..000000000 --- a/server/src/internal/balances/track/runTrack.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - AffectedResource, - type ApiVersion, - ApiVersionClass, - applyResponseVersionChanges, - type CheckExpand, - ErrCode, - RecaseError, - type TrackParams, - type TrackResponseV2, -} from "@autumn/shared"; -import { db } from "../../../db/initDrizzle"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv"; -import { EventService } from "../../api/events/EventService"; -import { CusService } from "../../customers/CusService"; -import { type EventInfo, initEvent } from "../events/initEvent"; -import type { FeatureDeduction } from "../utils/types/featureDeduction"; -import { runRedisDeduction } from "./redisTrackUtils/runRedisDeduction"; -import { executePostgresTracking } from "./trackUtils/executePostgresTracking"; -import { getTrackBalancesResponse } from "./trackUtils/getTrackBalancesResponse"; - -export const runTrack = async ({ - ctx, - body, - featureDeductions, - apiVersion, -}: { - ctx: AutumnContext; - body: TrackParams; - featureDeductions: FeatureDeduction[]; - apiVersion?: ApiVersion; -}) => { - // Validate: event_name cannot be used with overage_behavior: "reject" - if (body.event_name && body.overage_behavior === "reject") { - throw new RecaseError({ - message: - 'overage_behavior "reject" is not supported with event_name. Use feature_id or set overage_behavior to "cap".', - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - - // Clean properties - - const eventInfo: EventInfo = { - event_name: body.feature_id || body.event_name || "", - value: body.value ?? 1, - properties: body.properties, - timestamp: body.timestamp, - idempotency_key: body.idempotency_key, - }; - - // If idempotency key is provided, insert event first - if (body.idempotency_key) { - const customer = await CusService.getFull({ - db, - idOrInternalId: body.customer_id, - orgId: ctx.org.id, - env: ctx.env, - entityId: body.entity_id, - }); - - const newEvent = initEvent({ - ctx, - eventInfo, - internalCustomerId: customer?.internal_id ?? "", - internalEntityId: customer?.entity?.internal_id ?? undefined, - customerId: body.customer_id, - entityId: body.entity_id, - }); - - await EventService.insert({ - db, - event: newEvent, - }); - - body.skip_event = true; - } - - const { fallback, balances } = await runRedisDeduction({ - ctx, - query: { - expand: ctx.expand as CheckExpand[], - skip_cache: ctx.skipCache, - }, - trackParams: body, - featureDeductions, - overageBehavior: body.overage_behavior || "cap", - eventInfo, - }); - - let response: TrackResponseV2; - if (fallback) { - response = await executePostgresTracking({ - ctx, - body, - featureDeductions, - }); - } else { - // Clean balances - - // console.log("Balances:", balances); - const finalBalances = getTrackBalancesResponse({ - featureDeductions, - features: ctx.features, - balances, - }); - - response = { - customer_id: body.customer_id, - entity_id: body.entity_id, - event_name: body.event_name, - value: body.value ?? 1, - balance: finalBalances.balance, - balances: finalBalances.balances, - }; - } - - const transformedResponse = applyResponseVersionChanges({ - input: response, - targetVersion: apiVersion - ? new ApiVersionClass(apiVersion) - : ctx.apiVersion, - resource: AffectedResource.Track, - legacyData: { - feature_id: body.feature_id || body.event_name, - }, - ctx, - }); - return transformedResponse; -}; diff --git a/server/src/internal/balances/track/trackUtils/executePostgresTracking.ts b/server/src/internal/balances/track/trackUtils/executePostgresTracking.ts deleted file mode 100644 index 9a83323b3..000000000 --- a/server/src/internal/balances/track/trackUtils/executePostgresTracking.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - ApiBalance, - FullCustomer, - TrackParams, - TrackResponseV2, -} from "@autumn/shared"; -import { InsufficientBalanceError } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { getApiCustomerBase } from "../../../customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; -import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer.js"; -import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; -import { getTrackBalancesResponse } from "./getTrackBalancesResponse.js"; -import { runDeductionTx } from "./runDeductionTx.js"; - -const catchInsufficientBalanceError = ({ - error, - body, -}: { - error: unknown; - body: TrackParams; -}) => { - // Check if it's an insufficient balance error from PostgreSQL - if ( - error instanceof Error && - error.message?.includes("INSUFFICIENT_BALANCE") - ) { - // Parse the error message: INSUFFICIENT_BALANCE|featureId:{id}|value:{amount}|remaining:{remaining} - const parts = error.message.split("|"); - const featureIdMatch = parts[1]?.match(/featureId:(.*)/); - const valueMatch = parts[2]?.match(/value:(.*)/); - - const featureId = featureIdMatch?.[1] || body.feature_id; - const value = valueMatch?.[1] - ? Number.parseFloat(valueMatch[1]) - : (body.value ?? 1); - - throw new InsufficientBalanceError({ - value, - featureId, - }); - } - - throw error; -}; - -/** - * Execute PostgreSQL-based tracking with full transaction support - */ -export const executePostgresTracking = async ({ - ctx, - body, - featureDeductions, -}: { - ctx: AutumnContext; - body: TrackParams; - featureDeductions: FeatureDeduction[]; -}) => { - const response: TrackResponseV2 = { - // id: "", - // code: SuccessCode.EventReceived, - customer_id: body.customer_id, - entity_id: body.entity_id, - value: body.value ?? 1, - // feature_id: body.feature_id, - event_name: body.event_name, - balance: null, - }; - - const fullCus = await getOrCreateCustomer({ - ctx, - customerId: body.customer_id, - customerData: body.customer_data, - entityId: body.entity_id, - entityData: body.entity_data, - withEntities: true, - }); - - let updatedFullCus: FullCustomer | undefined | null; - let actualDeductions: Record = {}; - - try { - const result = await runDeductionTx({ - ctx, - customerId: body.customer_id, - entityId: body.entity_id, - deductions: featureDeductions, - overageBehaviour: body.overage_behavior, - eventInfo: body.idempotency_key - ? undefined - : { - event_name: body.feature_id || body.event_name || "", - value: body.value ?? 1, - properties: body.properties, - timestamp: body.timestamp, - idempotency_key: body.idempotency_key, - }, - refreshCache: true, - fullCus, - skipAdditionalBalance: true, - }); - updatedFullCus = result.fullCus; - actualDeductions = result.actualDeductions; - } catch (error) { - catchInsufficientBalanceError({ error, body }); - } - - if (updatedFullCus) { - const { apiCustomer } = await getApiCustomerBase({ - ctx, - fullCus: updatedFullCus, - }); - - // Build balances response matching Lua batchDeduction logic: - // 1. Always include primary features from featureDeductions (they were requested) - // 2. Only include credit systems if they were actually used (in actualDeductions) - const balancesRes: Record = {}; - - // Add primary features (always - they were requested to be tracked) - for (const deduction of featureDeductions) { - const balance = apiCustomer.balances[deduction.feature.id]; - if (balance) { - balancesRes[deduction.feature.id] = balance; - } - - // If a feature is unlimited, add it to the balances response - } - - // Add credit systems only if they were actually used - for (const featureId of Object.keys(actualDeductions)) { - if (!balancesRes[featureId]) { - const balance = apiCustomer.balances[featureId]; - if (balance) { - balancesRes[featureId] = balance; - } - } - } - - const finalBalances = getTrackBalancesResponse({ - featureDeductions, - features: ctx.features, - balances: balancesRes, - }); - - response.balance = finalBalances.balance; - response.balances = finalBalances.balances; - } - - return response; -}; diff --git a/server/src/internal/balances/track/trackUtils/getTrackBalancesResponse.ts b/server/src/internal/balances/track/trackUtils/getTrackBalancesResponse.ts deleted file mode 100644 index 24f315687..000000000 --- a/server/src/internal/balances/track/trackUtils/getTrackBalancesResponse.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - type ApiBalance, - CheckExpand, - expandIncludes, - type Feature, - getRelevantFeatures, -} from "@autumn/shared"; -import type { FeatureDeduction } from "../../utils/types/featureDeduction"; - -export const getTrackBalancesResponse = ({ - featureDeductions, - features, - balances, - expand, -}: { - featureDeductions: FeatureDeduction[]; - features: Feature[]; - balances?: Record; - expand?: CheckExpand[]; -}) => { - if (!balances) { - return { - balance: null, - balances: undefined, - }; - } - // For each feature deduction - const finalBalances: Record = {}; - for (const deduction of featureDeductions) { - let finalBalance: ApiBalance | undefined; - const relevantFeatures = getRelevantFeatures({ - features, - featureId: deduction.feature.id, - }); - - for (const feature of relevantFeatures) { - if (balances[feature.id]) { - finalBalance = balances[feature.id]; - } - } - - if (finalBalance) { - finalBalances[finalBalance.feature_id] = finalBalance; - } - } - - if ( - !expandIncludes({ - expand: expand || [], - includes: [CheckExpand.BalanceFeature], - }) - ) { - for (const featureId in finalBalances) { - finalBalances[featureId].feature = undefined; - } - } - - if (Object.keys(finalBalances).length === 0) { - return { - balance: null, - balances: undefined, - }; - } else if (Object.keys(finalBalances).length === 1) { - return { - balance: Object.values(finalBalances)[0], - balances: undefined, - }; - } else { - return { - balance: null, - balances: finalBalances, - }; - } -}; diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts deleted file mode 100644 index 1fbf67fe3..000000000 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ /dev/null @@ -1,384 +0,0 @@ -import type { CustomerEntitlementFilters, Event } from "@autumn/shared"; -import { - CusProductStatus, - cusEntToCusPrice, - FeatureUsageType, - type FullCustomer, - fullCustomerToCustomerEntitlements, - getMaxOverage, - getRelevantFeatures, - InternalError, - notNullish, - nullish, - orgToInStatuses, -} from "@autumn/shared"; -import { cusEntToStartingBalance } from "@shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.js"; -import { Decimal } from "decimal.js"; -import { sql } from "drizzle-orm"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { EventService } from "../../../api/events/EventService.js"; -import { CusService } from "../../../customers/CusService.js"; -import { getUnlimitedAndUsageAllowed } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js"; -import { deleteCachedApiCustomer } from "../../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import { getCreditCost } from "../../../features/creditSystemUtils.js"; -import { isPaidContinuousUse } from "../../../features/featureUtils.js"; -import { type EventInfo, initEvent } from "../../events/initEvent.js"; -import { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js"; -import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; -import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; -import { handlePaidAllocatedCusEnt } from "./handlePaidAllocatedCusEnt.js"; -import { rollbackDeduction } from "./rollbackDeduction.js"; - -export type DeductionTxParams = { - ctx: AutumnContext; - customerId: string; - entityId?: string; - deductions: FeatureDeduction[]; - eventInfo?: EventInfo; - overageBehaviour?: "cap" | "reject" | "allow"; - addToAdjustment?: boolean; - skipAdditionalBalance?: boolean; - alterGrantedBalance?: boolean; - fullCus?: FullCustomer; // if provided from function above! - refreshCache?: boolean; // Whether to refresh Redis cache after deduction (default: true for track, false for sync) - - customerEntitlementFilters?: CustomerEntitlementFilters; -}; - -export const deductFromCusEnts = async ({ - ctx, - customerId, - entityId, - deductions, - overageBehaviour = "cap", - addToAdjustment = false, - skipAdditionalBalance = true, - alterGrantedBalance = false, - fullCus, - customerEntitlementFilters, -}: DeductionTxParams): Promise<{ - oldFullCus: FullCustomer; - fullCus: FullCustomer | undefined; - isPaidAllocated: boolean; - actualDeductions: Record; - remainingAmounts: Record; -}> => { - const { db, org, env } = ctx; - - // Need to getOrCreateCustomer here too... - if (!fullCus) { - fullCus = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - withSubs: true, - }); - } - const oldFullCus = structuredClone(fullCus); - - const printLogs = false; - - const isPaidAllocated = deductions.some((d) => - isPaidContinuousUse({ - feature: d.feature, - fullCus: fullCus!, - }), - ); - - if (isPaidAllocated) { - overageBehaviour = "reject"; - skipAdditionalBalance = true; - } - - // Track actual deductions per feature - const actualDeductions: Record = {}; - const remainingAmounts: Record = {}; - - // Need to deduct from customer entitlement... - for (const deduction of deductions) { - const { feature, deduction: toDeduct, targetBalance } = deduction; - - const relevantFeatures = notNullish(targetBalance) - ? [feature] - : getRelevantFeatures({ - features: ctx.features, - featureId: feature.id, - }); - - // For refunds (negative amounts), reverse usage_allowed sorting - // so overage entitlements are processed first (to recover negative balance) - const isRefund = (toDeduct ?? 0) < 0; - - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, - featureIds: relevantFeatures.map((f) => f.id), - reverseOrder: org.config?.reverse_deduction_order, - entity: fullCus.entity, - inStatuses: orgToInStatuses({ org }), - customerEntitlementFilters, - isRefund, - }); - - // Debug: log sort order - console.log(`[runDeductionTx] toDeduct=${toDeduct}, isRefund=${isRefund}`); - console.log( - `[runDeductionTx] CusEnts:`, - cusEnts.map((ce) => ({ - id: ce.id.slice(-8), - usage_allowed: ce.usage_allowed, - balance: ce.balance, - interval: ce.entitlement.interval, - })), - ); - - // Check if ANY relevant feature (primary or credit system) is unlimited - // Add unlimited features to actualDeductions with value 0 (like Lua's changedCustomerFeatureIds) - let unlimited = false; - for (const rf of relevantFeatures) { - const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ - cusEnts, - internalFeatureId: rf.internal_id!, - }); - if (featureUnlimited) { - unlimited = true; - // Add to actualDeductions with 0 so balance gets returned - if (actualDeductions[rf.id] === undefined) { - actualDeductions[rf.id] = 0; - } - } - } - - if (cusEnts.length === 0 || unlimited) continue; - - const cusEntInput = cusEnts.map((ce) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: ce.entitlement.feature, - }); - - const maxOverage = getMaxOverage({ cusEnt: ce }); - - const cusPrice = cusEntToCusPrice({ cusEnt: ce }); - const isFreeAllocated = - ce.entitlement.feature.config?.usage_type === - FeatureUsageType.Continuous && nullish(cusPrice); - - // NOTE: WE USE STARTING BALANCE BECAUSE ADJUSTMENT IS ADDED IN performDeduction.sql function - const startingBalance = cusEntToStartingBalance({ cusEnt: ce }); - - return { - customer_entitlement_id: ce.id, - credit_cost: creditCost, - entity_feature_id: ce.entitlement.entity_feature_id, - usage_allowed: - ce.usage_allowed || - (isFreeAllocated && overageBehaviour !== "reject"), - min_balance: notNullish(maxOverage) ? -maxOverage : undefined, - add_to_adjustment: addToAdjustment, - max_balance: startingBalance, - }; - }); - - // Collect and sort rollovers by expires_at (oldest first) - const sortedRollovers = cusEnts - .flatMap((ce) => ce.rollovers || []) - .sort((a, b) => { - if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - - const rolloverIds = sortedRollovers.map((r) => r.id); - - // Extract entitlement IDs for locking - const cusEntIds = cusEntInput.map((ce) => ce.customer_entitlement_id); - - // Call the stored function to deduct from entitlements with credit costs - const result = await db.execute( - sql`SELECT * FROM deduct_from_cus_ents( - ${JSON.stringify({ - sorted_entitlements: cusEntInput, - amount_to_deduct: toDeduct ?? null, - target_balance: targetBalance ?? null, - target_entity_id: entityId || null, - rollover_ids: rolloverIds.length > 0 ? rolloverIds : null, - cus_ent_ids: cusEntIds.length > 0 ? cusEntIds : null, - skip_additional_balance: skipAdditionalBalance, - alter_granted_balance: alterGrantedBalance, - overage_behaviour: overageBehaviour ?? "cap", - feature_id: feature.id, - })}::jsonb - )`, - ); - - // Parse the JSONB result - const resultJson = result[0]?.deduct_from_cus_ents as { - updates: Record; - remaining: number; - }; - - // log updates - if (printLogs) { - console.log(`📊 Postgres updates for ${feature.id}:`, resultJson.updates); - } - - if (!resultJson) { - throw new InternalError({ - message: "Failed to deduct from entitlements", - }); - } - - const { updates, remaining: featureRemaining } = resultJson; - - // Track the maximum remaining amount across all deductions - remainingAmounts[feature.id] = featureRemaining; - - // Calculate total deducted from the updates (sum of all deducted amounts) - const totalDeducted = Object.values(updates).reduce( - (sum, update) => sum + update.deducted, - 0, - ); - - // Convert updates to actual deduction - for (const [cusEntId, update] of Object.entries(updates)) { - const cusEnt = cusEnts.find((ce) => ce.id === cusEntId); - const deductedFeature = cusEnt?.entitlement.feature; - if (!deductedFeature) continue; - - const currentDeduction = actualDeductions[deductedFeature.id] || 0; - actualDeductions[deductedFeature.id] = new Decimal(update.deducted) - .add(currentDeduction) - .toNumber(); - } - - // Log deduction details - if (targetBalance !== undefined) { - const entityInfo = entityId - ? `; Entity: ${entityId}` - : "Entity: customer-level"; - ctx.logger.info(`[Sync]; Feature ${feature.id} | ${entityInfo}`, { - data: { - featureId: feature.id, - entityInfo, - totalDeducted, - updates: Object.keys(updates).length, - remaining: featureRemaining, - }, - }); - } else { - ctx.logger.info( - `[Track]; Deducted ${totalDeducted} from feature ${feature.id}. Updated ${Object.keys(updates).length} entitlements. Remaining: ${featureRemaining}`, - ); - } - - // Bill on Stripe for each updated entitlement - - try { - for (const cusEntId of Object.keys(updates)) { - const update = updates[cusEntId]; - const cusEnt = cusEnts.find((ce) => ce.id === cusEntId); - - if (!cusEnt) continue; - - await handlePaidAllocatedCusEnt({ - ctx, - cusEnt, - fullCus, - updates, - }); - - applyDeductionUpdateToFullCustomer({ - fullCus, - cusEntId, - update, - }); - } - } catch (error) { - if (error instanceof Error && !error?.message?.includes("declined")) { - ctx.logger.error( - `[deductFromCusEnts] Attempting rollback due to error: ${error}`, - ); - } - await rollbackDeduction({ - ctx, - oldFullCus, - updates, - }); - throw error; - } - } - - // Log summary of all Postgres deductions - if (printLogs && Object.keys(actualDeductions).length > 0) { - console.log("📊 Total Postgres deductions:", actualDeductions); - } - - return { - oldFullCus, - fullCus, - actualDeductions, - remainingAmounts, - isPaidAllocated, - }; -}; - -export const runDeductionTx = async ( - params: DeductionTxParams, -): Promise<{ - fullCus: FullCustomer | undefined; - event: Event | undefined; - actualDeductions: Record; -}> => { - const ctx = params.ctx; - const { db } = ctx; - - let fullCus: FullCustomer | undefined; - let event: Event | undefined; - let actualDeductions: Record = {}; - - const result = await deductFromCusEnts(params); - fullCus = result.fullCus; - actualDeductions = result.actualDeductions; - - if (!fullCus) { - return { - fullCus, - event, - actualDeductions, - }; - } - - if (params.eventInfo) { - const newEvent = initEvent({ - ctx, - eventInfo: params.eventInfo, - internalCustomerId: fullCus.internal_id, - internalEntityId: fullCus.entity?.internal_id, - customerId: fullCus.id ?? "", - entityId: fullCus.entity?.id, - }); - - event = await EventService.insert({ - db, - event: newEvent, - }); - } - - if (params?.refreshCache && fullCus) { - await deleteCachedApiCustomer({ - customerId: fullCus.id ?? "", - ctx, - source: `runDeductionTx, refreshing cache`, - }); - } - - return { - fullCus, - event, - actualDeductions, - }; -}; diff --git a/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts b/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts deleted file mode 100644 index 9b4ef272c..000000000 --- a/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { - ErrCode, - type Feature, - FeatureType, - FeatureUsageType, - type FullCusEntWithFullCusProduct, - type FullCustomerEntitlement, - RecaseError, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { StatusCodes } from "http-status-codes"; -import { getFeatureBalance } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js"; -import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; - -/** - * Calculate total available rollover balance for a feature - */ -const calculateAvailableRolloverBalance = ({ - cusEnts, - feature, - entityId, -}: { - cusEnts: FullCustomerEntitlement[]; - feature: Feature; - entityId?: string; -}) => { - const featureCusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.internal_feature_id === feature.internal_id, - ); - - if (!entityId) { - // Non-entity: sum rollover.balance - return featureCusEnts.reduce((sum, cusEnt) => { - const rolloverSum = cusEnt.rollovers.reduce( - (rSum, rollover) => - new Decimal(rSum).add(rollover.balance || 0).toNumber(), - 0, - ); - return new Decimal(sum).add(rolloverSum).toNumber(); - }, 0); - } else { - // Entity: sum rollover.entities[entityId].balance - return featureCusEnts.reduce((sum, cusEnt) => { - const rolloverSum = cusEnt.rollovers.reduce((rSum, rollover) => { - const entityRollover = rollover.entities?.[entityId]; - if (entityRollover) { - return new Decimal(rSum).add(entityRollover.balance || 0).toNumber(); - } - return rSum; - }, 0); - return new Decimal(sum).add(rolloverSum).toNumber(); - }, 0); - } -}; - -export const validateDeductionPossible = ({ - cusEnts, - deductions, - entityId, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - deductions: FeatureDeduction[]; - entityId?: string; -}) => { - for (const { feature, deduction } of deductions) { - const featureCusEnts = cusEnts.filter( - (customerEntitlement) => - customerEntitlement.entitlement.internal_feature_id === - feature.internal_id, - ); - - // CONSTRAINT 1: Insufficient balance without usage_allowed - const cusEntBalance = getFeatureBalance({ - cusEnts: featureCusEnts, - internalFeatureId: feature.internal_id!, - entityId, - }); - - // If unlimited, skip validation - if (cusEntBalance === null) { - continue; - } - const rolloverBalance = calculateAvailableRolloverBalance({ - cusEnts, - feature, - entityId, - }); - const totalBalance = new Decimal(cusEntBalance) - .add(rolloverBalance) - .toNumber(); - - const hasUsageAllowed = featureCusEnts.some( - (customerEntitlement) => customerEntitlement.usage_allowed, - ); - - // Check if this is a "free" feature (single-use with included_usage but no pricing) - // Only apply to SingleUse features; ContinuousUse (allocated) features should reject - const isFreeFeature = - feature.type === FeatureType.Metered && - feature.config?.usage_type === FeatureUsageType.Single && - featureCusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance && cusEnt.entitlement.allowance > 0, - ) && - !hasUsageAllowed; - - // For free SingleUse features, allow tracking beyond balance (will cap at 0 in performDeduction) - // For prepaid/allocated/other features without usage_allowed, reject insufficient balance - if (totalBalance < deduction && !hasUsageAllowed && !isFreeFeature) { - throw new RecaseError({ - message: `Insufficient balance for feature ${feature.id}. Available: ${totalBalance} (${cusEntBalance} + ${rolloverBalance} rollover), Required: ${deduction}`, - code: ErrCode.InsufficientBalance, - statusCode: StatusCodes.BAD_REQUEST, - data: { - feature_id: feature.id, - available: totalBalance, - cus_ent_balance: cusEntBalance, - rollover_balance: rolloverBalance, - required: deduction, - }, - }); - } - - // CONSTRAINT 2: Usage limit exceeded for customer entitlements with usage_allowed - const entitlementDeduction = - new Decimal(deduction).sub(rolloverBalance).toNumber() > 0 - ? new Decimal(deduction).sub(rolloverBalance).toNumber() - : 0; - - if (entitlementDeduction > 0) { - const featureCusEntsWithUsageAllowed = featureCusEnts.filter( - (customerEntitlement) => customerEntitlement.usage_allowed, - ); - - const totalRemainingLimit = featureCusEntsWithUsageAllowed.reduce( - (sum, cusEnt) => { - const usageLimit = cusEnt.entitlement.usage_limit; - if (!usageLimit) { - return sum; - } - - const featureBalance = getFeatureBalance({ - cusEnts: [cusEnt], - internalFeatureId: feature.internal_id!, - entityId, - }); - - // Skip if unlimited - if (featureBalance === null) { - return sum; - } - - const allowance = new Decimal(cusEnt.entitlement.allowance || 0); - const currentBalance = new Decimal(featureBalance); - const currentUsed = allowance.sub(currentBalance); - const remainingLimit = new Decimal(usageLimit).sub(currentUsed); - - return new Decimal(sum) - .add(Decimal.max(0, remainingLimit)) - .toNumber(); - }, - 0, - ); - - if ( - featureCusEntsWithUsageAllowed.length > 0 && - entitlementDeduction > totalRemainingLimit - ) { - throw new RecaseError({ - message: `Usage limit exceeded for feature ${feature.id}. Total remaining capacity: ${totalRemainingLimit}, Requested from entitlement: ${entitlementDeduction} (${rolloverBalance} covered by rollovers)`, - code: ErrCode.InsufficientBalance, - statusCode: StatusCodes.BAD_REQUEST, - data: { - feature_id: feature.id, - total_remaining_capacity: totalRemainingLimit, - requested_from_entitlement: entitlementDeduction, - covered_by_rollovers: rolloverBalance, - total_requested: deduction, - }, - }); - } - } - } -}; diff --git a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts b/server/src/internal/balances/track/utils/getFeatureDeductions.ts similarity index 100% rename from server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts rename to server/src/internal/balances/track/utils/getFeatureDeductions.ts diff --git a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts b/server/src/internal/balances/updateBalance/updateGrantedBalance.ts similarity index 100% rename from server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts rename to server/src/internal/balances/updateBalance/updateGrantedBalance.ts diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 1059d244c..d617dff97 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -1,18 +1,20 @@ import { + ACTIVE_STATUSES, CusProductStatus, type FullCustomer, InternalError, } from "@autumn/shared"; import { sql } from "drizzle-orm"; +import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js"; +import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { CusService } from "../../../customers/CusService.js"; import type { EventInfo } from "../../events/initEvent.js"; -import { handlePaidAllocatedCusEnt } from "../../track/trackUtils/handlePaidAllocatedCusEnt.js"; -import { rollbackDeduction } from "../../track/trackUtils/rollbackDeduction.js"; import { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js"; import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; +import { handleThresholdReached } from "../handleThresholdReached.js"; import type { DeductionOptions } from "../types/deductionTypes.js"; import { logDeductionUpdates } from "./logDeductionUpdates.js"; import { prepareDeductionOptions } from "./prepareDeductionOptions.js"; @@ -59,7 +61,7 @@ export const executePostgresDeduction = async ({ idOrInternalId: customerId, orgId: org.id, env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + inStatuses: ACTIVE_STATUSES, entityId, withSubs: true, }); @@ -165,6 +167,17 @@ export const executePostgresDeduction = async ({ }); throw error; } + + handleThresholdReached({ + ctx, + oldFullCus, + newFullCus: fullCustomer, + feature: deduction.feature, + }).catch((error) => { + ctx.logger.error( + `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, + ); + }); } if (refreshCache) { diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 7b3936c11..37da6cafe 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -4,10 +4,11 @@ import type { } from "@autumn/shared"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js"; +import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { handlePaidAllocatedCusEnt } from "../../track/trackUtils/handlePaidAllocatedCusEnt.js"; -import { rollbackDeduction } from "../../track/trackUtils/rollbackDeduction.js"; +import { handleThresholdReached } from "../handleThresholdReached.js"; import type { DeductionOptions } from "../types/deductionTypes.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; @@ -187,6 +188,17 @@ export const executeRedisDeduction = async ({ }); throw error; } + + handleThresholdReached({ + ctx, + oldFullCus, + newFullCus: fullCustomer, + feature: deduction.feature, + }).catch((error) => { + ctx.logger.error( + `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, + ); + }); } return { diff --git a/server/src/trigger/handleThresholdReached.ts b/server/src/internal/balances/utils/handleThresholdReached.ts similarity index 93% rename from server/src/trigger/handleThresholdReached.ts rename to server/src/internal/balances/utils/handleThresholdReached.ts index 6f87307ea..aaf85102d 100644 --- a/server/src/trigger/handleThresholdReached.ts +++ b/server/src/internal/balances/utils/handleThresholdReached.ts @@ -12,9 +12,9 @@ import { WebhookEventType, } from "@autumn/shared"; import { sendSvixEvent } from "@/external/svix/svixHelpers.js"; -import type { AutumnContext } from "../honoUtils/HonoEnv.js"; -import { apiBalanceToAllowed } from "../internal/api/check/checkUtils/apiBalanceToAllowed.js"; -import { getApiCustomerBase } from "../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { apiBalanceToAllowed } from "@/internal/api/check/checkUtils/apiBalanceToAllowed.js"; +import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; const cleanApiCustomer = ({ ctx, diff --git a/server/src/trigger/deductUtils.ts b/server/src/internal/balances/utils/legacy/performDeduction.ts similarity index 52% rename from server/src/trigger/deductUtils.ts rename to server/src/internal/balances/utils/legacy/performDeduction.ts index f22123e74..681d60134 100644 --- a/server/src/trigger/deductUtils.ts +++ b/server/src/internal/balances/utils/legacy/performDeduction.ts @@ -1,75 +1,6 @@ -import type { Entitlement, Event, Feature } from "@autumn/shared"; +import type { Entitlement } from "@autumn/shared"; import { Decimal } from "decimal.js"; -import { notNullish } from "@/utils/genUtils.js"; -const DEFAULT_VALUE = 1; - -export const getMeteredDeduction = (_meteredFeature: Feature, event: Event) => { - // const config = meteredFeature.config; - // const aggregate = config.aggregate; - - // if (aggregate.type === AggregateType.Count) { - // return 1; - // } - - const value = notNullish(event.value) - ? event.value - : notNullish(event.properties?.value) - ? event.properties?.value - : DEFAULT_VALUE; - - const floatVal = parseFloat(value); - if (Number.isNaN(floatVal)) return 0; - - return floatVal; - // if ( - // meteredFeature.type === FeatureType.CreditSystem || - // aggregate.type === AggregateType.Sum - // ) { - // return value; - // } - - // return 0; -}; - -export const getCreditSystemDeduction = ({ - meteredFeatures, - creditSystem, - event, -}: { - meteredFeatures: Feature[]; - creditSystem: Feature; - event: Event; -}) => { - let creditsUpdate = 0; - const meteredFeatureIds = meteredFeatures.map((feature) => feature.id); - - for (const schema of creditSystem.config.schema) { - if (meteredFeatureIds.includes(schema.metered_feature_id)) { - const meteredFeature = meteredFeatures.find( - (feature) => feature.id === schema.metered_feature_id, - ); - - if (!meteredFeature) { - continue; - } - - const meteredDeduction = getMeteredDeduction(meteredFeature, event); - - const meteredDeductionDecimal = new Decimal(meteredDeduction); - const featureAmountDecimal = new Decimal(schema.feature_amount ?? 1); - const creditAmountDecimal = new Decimal(schema.credit_amount); - creditsUpdate += meteredDeductionDecimal - .div(featureAmountDecimal) - .mul(creditAmountDecimal) - .toNumber(); - } - } - - return creditsUpdate; -}; - -// Deduct allowance export const performDeduction = ({ cusEntBalance, toDeduct, diff --git a/server/src/internal/balances/utils/legacy/performDeductionOnCusEnt.ts b/server/src/internal/balances/utils/legacy/performDeductionOnCusEnt.ts new file mode 100644 index 000000000..a9bfaac3a --- /dev/null +++ b/server/src/internal/balances/utils/legacy/performDeductionOnCusEnt.ts @@ -0,0 +1,185 @@ +import { + type EntityBalance, + type FullCusEntWithFullCusProduct, + getStartingBalance, + isEntityScopedCusEnt, + notNullish, + nullish, +} from "@autumn/shared"; + +import { Decimal } from "decimal.js"; + +import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { performDeduction } from "./performDeduction"; + +export const performDeductionOnCusEnt = ({ + cusEnt, + toDeduct, + entityId, + allowNegativeBalance = false, + addAdjustment = false, + setZeroAdjustment = false, + blockUsageLimit = true, + field = "balance", +}: { + cusEnt: FullCusEntWithFullCusProduct; + toDeduct: number; + entityId?: string | null; + allowNegativeBalance?: boolean; + addAdjustment?: boolean; + setZeroAdjustment?: boolean; + blockUsageLimit?: boolean; + field?: "balance" | "additional_balance"; +}): { + newBalance: number; + newEntities: Record | undefined; + deducted: number; + toDeduct: number; + newAdjustment?: number; +} => { + let newEntities: Record | undefined = + structuredClone(cusEnt.entities) ?? undefined; + + let newBalance: number = structuredClone(cusEnt[field]) ?? 0; + let deducted = 0; + + // To deprecate: adjustment. + let newAdjustment = structuredClone(cusEnt.adjustment); + + const cusProduct = cusEnt.customer_product; + + // 2. Get options, related price and starting balance! + const options = notNullish(cusProduct) + ? getEntOptions(cusProduct.options, cusEnt.entitlement) + : undefined; + + const cusPrice = notNullish(cusProduct) + ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) + : undefined; + + const resetBalance = notNullish(cusProduct) + ? getStartingBalance({ + options: options || undefined, + relatedPrice: cusPrice?.price, + entitlement: cusEnt.entitlement, + }) + : cusEnt.entitlement.allowance || 0; + + if (isEntityScopedCusEnt({ cusEnt })) { + // CASE 1: Deduct from entity balances + + if (nullish(entityId)) { + newEntities = structuredClone(cusEnt.entities) as Record< + string, + EntityBalance + >; + if (!newEntities) newEntities = {}; + + let toDeductCursor = toDeduct; + for (const entityId in cusEnt.entities) { + if (toDeductCursor === 0) break; + + const entityBalance = cusEnt.entities[entityId][field]; + + const { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(entityBalance ?? 0), + toDeduct: toDeductCursor, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); + + newEntities[entityId][field] = newEntityBalance!; + + if (addAdjustment) { + const adjustment = newEntities[entityId].adjustment || 0; + newEntities[entityId].adjustment = adjustment - newDeducted!; + } + + if (setZeroAdjustment) { + newEntities[entityId].adjustment = 0; + } + + toDeductCursor = newToDeduct; + deducted += newDeducted; + } + + toDeduct = toDeductCursor; + } + + // CASE 2: Deduct from entity balance + else { + if (!newEntities) newEntities = {}; + + const currentEntityBalance = cusEnt.entities?.[entityId]?.[field]; + + const { + newBalance: newEntityBalance, + deducted: newDeducted, + toDeduct: newToDeduct, + } = performDeduction({ + cusEntBalance: new Decimal(currentEntityBalance!), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); + + newEntities[entityId][field] = newEntityBalance!; + + if (addAdjustment) { + const adjustment = newEntities[entityId].adjustment || 0; + newEntities[entityId].adjustment = adjustment - newDeducted!; + } + + if (setZeroAdjustment) { + newEntities[entityId].adjustment = 0; + } + + toDeduct = newToDeduct; + deducted += newDeducted; + } + } + + // CASE 3: Deduct from balance + else { + const currentBalance = cusEnt[field] || 0; + + const { + newBalance: newBalance_, + deducted: deducted_, + toDeduct: newToDeduct_, + } = performDeduction({ + cusEntBalance: new Decimal(currentBalance), + toDeduct, + allowNegativeBalance, + ent: cusEnt.entitlement, + resetBalance, + blockUsageLimit, + }); + + newBalance = newBalance_; + deducted = deducted_; + toDeduct = newToDeduct_; + + if (addAdjustment) { + const adjustment = cusEnt.adjustment || 0; + newAdjustment = adjustment - deducted!; + } + } + + return { + newBalance, + newEntities, + deducted, + toDeduct, + newAdjustment: newAdjustment ?? undefined, + }; +}; diff --git a/server/src/trigger/adjustAllowance.ts b/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts similarity index 93% rename from server/src/trigger/adjustAllowance.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts index aabaaed54..599e2582b 100644 --- a/server/src/trigger/adjustAllowance.ts +++ b/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts @@ -18,11 +18,11 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import RecaseError from "@/utils/errorUtils.js"; -import { cusProductToSub } from "../internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; -import { handleProratedDowngrade } from "./arrearProratedUsage/handleProratedDowngrade.js"; -import { handleProratedUpgrade } from "./arrearProratedUsage/handleProratedUpgrade.js"; +import { handleProratedDowngrade } from "./createPaidAllocatedInvoice/handleProratedDowngrade.js"; +import { handleProratedUpgrade } from "./createPaidAllocatedInvoice/handleProratedUpgrade.js"; export const getUsageFromBalance = ({ ent, diff --git a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts b/server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/createUpgradeProrationInvoice.ts similarity index 98% rename from server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/createUpgradeProrationInvoice.ts index 723f6bd7d..c9710870a 100644 --- a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts +++ b/server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/createUpgradeProrationInvoice.ts @@ -10,6 +10,7 @@ import { } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type Stripe from "stripe"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; @@ -20,7 +21,6 @@ import { } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; -import type { Logger } from "../../external/logtail/logtailUtils"; export const getUpgradeProrationInvoiceItem = ({ prevPrice, diff --git a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts b/server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedDowngrade.ts similarity index 100% rename from server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedDowngrade.ts diff --git a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts b/server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedUpgrade.ts similarity index 100% rename from server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedUpgrade.ts diff --git a/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts b/server/src/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.ts similarity index 82% rename from server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.ts index 6168893a7..8449a4475 100644 --- a/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts +++ b/server/src/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.ts @@ -3,11 +3,11 @@ import { type FullCusEntWithFullCusProduct, type FullCustomer, } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { adjustAllowance } from "../../../../trigger/adjustAllowance"; -import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService"; -import { getTotalNegativeBalance } from "../../../customers/cusProducts/cusEnts/cusEntUtils"; -import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { DeductionUpdate } from "@/internal/balances/utils/types/deductionUpdate.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { getTotalNegativeBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { adjustAllowance } from "./adjustAllowance.js"; export const handlePaidAllocatedCusEnt = async ({ ctx, diff --git a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts b/server/src/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.ts similarity index 82% rename from server/src/internal/balances/track/trackUtils/rollbackDeduction.ts rename to server/src/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.ts index 56c1efaaa..0c8bc7609 100644 --- a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts +++ b/server/src/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.ts @@ -1,9 +1,10 @@ import { - type FullCustomer, fullCustomerToCustomerEntitlements + type FullCustomer, + fullCustomerToCustomerEntitlements, } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService"; -import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { DeductionUpdate } from "@/internal/balances/utils/types/deductionUpdate.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; export const rollbackDeduction = async ({ ctx, diff --git a/server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts b/server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts deleted file mode 100644 index d04497ea0..000000000 --- a/server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { AutumnContext } from "@server/honoUtils/HonoEnv.js"; -import { type SyncItem, syncItem } from "./syncItem.js"; - -interface SyncBatchPayload { - item: SyncItem; -} - -/** - * Worker that syncs a single Redis balance deduction back to PostgreSQL - * Each SQS message contains one sync item (deduplicated by SQS) - */ -export const runSyncBalanceBatch = async ({ - ctx, - payload, -}: { - ctx?: AutumnContext; - payload: SyncBatchPayload; -}) => { - const item = payload.item; - - if (!item || !ctx) { - console.warn("⚠️ No sync item provided"); - return; - } - - const { logger } = ctx; - - // Log what we're syncing - const itemDescription = item.entityId - ? `customer: ${item.customerId}, entity: ${item.entityId}, feature: ${item.featureId}` - : `customer: ${item.customerId}, feature: ${item.featureId}`; - - logger.info(`🔄 Starting sync: ${itemDescription}`); - - await syncItem({ item, ctx }); - logger.info(`✅ Successfully synced: ${itemDescription}`); - // try { - - // } catch (error) { - // logger.error(`❌ Failed to sync: ${itemDescription}`, { - // error: error instanceof Error ? error : new Error(String(error)), - // item, - // }); - // // Re-throw to trigger SQS retry - // throw error; - // } -}; diff --git a/server/src/internal/balances/utils/sync/legacy/syncItem.ts b/server/src/internal/balances/utils/sync/legacy/syncItem.ts deleted file mode 100644 index 833117ec5..000000000 --- a/server/src/internal/balances/utils/sync/legacy/syncItem.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { - FullCusEntWithFullCusProduct, - FullCustomer, -} from "@autumn/shared"; -import { - type ApiBalance, - type ApiBalanceBreakdown, - type ApiCustomer, - type ApiEntityV1, - type CustomerEntitlementFilters, - cusEntToPrepaidQuantity, - filterEntityLevelCustomerEntitlementsFromFullCustomer, - filterOutEntitiesFromFullCustomer, - fullCustomerToCustomerEntitlements, - getRelevantFeatures, - orgToInStatuses, - sumValues, -} from "@autumn/shared"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; -import { CACHE_CUSTOMER_VERSIONS } from "@/_luaScripts/cacheConfig.js"; - -import { getRegionalRedis } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; -import { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; - -import { handleThresholdReached } from "@/trigger/handleThresholdReached.js"; -import { getCachedApiEntity } from "../../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity"; -import { deductFromCusEnts } from "../../../track/trackUtils/runDeductionTx"; -import type { FeatureDeduction } from "../../types/featureDeduction.js"; - -export interface SyncItem { - customerId: string; - featureId: string; - orgId: string; - env: string; - entityId?: string; - region?: string; - timestamp: number; - customerEntitlementFilters?: CustomerEntitlementFilters; - alterGrantedBalance?: boolean; - overageBehaviour?: "cap" | "reject" | "allow"; - cacheVersion?: string; - fullCustomer?: FullCustomer; // old full customer -} - -/** - * Convert ApiBalance or ApiBalanceBreakdown to backend balance - * Works with both full balance objects and breakdown items (same fields used) - */ -const apiToBackendBalance = ({ - cusEnts, - apiBalance, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - apiBalance?: ApiBalance | ApiBalanceBreakdown; -}) => { - if (!apiBalance) return 0; - - const totalPrepaidQuantity = sumValues( - cusEnts.map((cusEnt) => cusEntToPrepaidQuantity({ cusEnt })), - ); - - // Backend balance = prepaid_quantity + current_balance - purchased_balance - const backendBalance = new Decimal(totalPrepaidQuantity) - .add(apiBalance.current_balance) - .sub(apiBalance.purchased_balance) - .toNumber(); - - return backendBalance; -}; - -/** - * Filter balance by customerEntitlementFilters.cusEntIds - * Returns a modified ApiBalance with: - * - breakdown filtered to only matching cusEntIds - * - current_balance/purchased_balance summed from filtered breakdowns - * If no filter, returns the original balance unchanged - */ -const applyCustomerEntitlementFiltersToBalance = ({ - apiBalance, - customerEntitlementFilters, -}: { - apiBalance: ApiBalance; - customerEntitlementFilters?: CustomerEntitlementFilters; -}): ApiBalance | null => { - // No filtering - return original balance - if ( - !customerEntitlementFilters?.cusEntIds || - customerEntitlementFilters.cusEntIds.length === 0 - ) { - return apiBalance; - } - - // Filtering but no breakdowns - nothing to match - if (!apiBalance.breakdown) { - return apiBalance; - } - - // Filter breakdowns to matching cusEntIds - const filteredBreakdowns = apiBalance.breakdown.filter((b) => - customerEntitlementFilters.cusEntIds?.includes(b.id), - ); - - if (filteredBreakdowns.length === 0) { - return null; - } - - // Sum balances from filtered breakdowns using Decimal.js for precision - const summedBalance = filteredBreakdowns.reduce( - (acc, b) => ({ - current_balance: acc.current_balance.add(b.current_balance), - purchased_balance: acc.purchased_balance.add(b.purchased_balance), - granted_balance: acc.granted_balance.add(b.granted_balance), - usage: acc.usage.add(b.usage), - }), - { - current_balance: new Decimal(0), - purchased_balance: new Decimal(0), - granted_balance: new Decimal(0), - usage: new Decimal(0), - }, - ); - - // Return modified balance with filtered breakdowns and summed values - return { - ...apiBalance, - current_balance: summedBalance.current_balance.toNumber(), - purchased_balance: summedBalance.purchased_balance.toNumber(), - granted_balance: summedBalance.granted_balance.toNumber(), - usage: summedBalance.usage.toNumber(), - breakdown: filteredBreakdowns, - }; -}; - -/** - * Handle syncing a single item from Redis to PostgreSQL - * Note: Does NOT use transaction or row locking - relies on deduction logic to handle concurrency - */ -export const syncItem = async ({ - item, - ctx, -}: { - item: SyncItem; - ctx: AutumnContext; -}) => { - const { - customerId, - featureId, - entityId, - region, - customerEntitlementFilters, - } = item; - const { db, org, env } = ctx; - - // Get the correct regional Redis instance for this sync item - // This ensures we read from the same region where the data was written - const redisInstance = region ? getRegionalRedis(region) : undefined; - - // Get cached customer/entity from Redis WITHOUT merging - // For sync, we need the raw balance for that specific scope (not merged) - let redisEntity: ApiCustomer | ApiEntityV1; - - ctx.skipCache = false; - if (entityId) { - const { apiEntity } = await getCachedApiEntity({ - ctx, - customerId, - entityId, - skipCustomerMerge: true, // Don't merge with customer - we want entity's own balance - redisInstance, - cacheVersion: item.cacheVersion || CACHE_CUSTOMER_VERSIONS.PREVIOUS, - }); - redisEntity = apiEntity; - } else { - const { apiCustomer } = await getCachedApiCustomer({ - ctx, - customerId, - skipEntityMerge: true, // Don't merge with entities - we want customer's own balance - redisInstance, - cacheVersion: item.cacheVersion || CACHE_CUSTOMER_VERSIONS.PREVIOUS, - }); - redisEntity = apiCustomer; - } - - // Get fresh customer from DB (no locking - let deduction handle it) - let fullCus = - item.fullCustomer || - (await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: RELEVANT_STATUSES, - withEntities: false, - withSubs: true, - entityId, - })); - - // If entityId provided, deduct entity level cusEnts - if (entityId) { - fullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer({ - fullCustomer: fullCus, - }); - } else { - // If entityId NOT provided, JUST deduct customer level cusEnts - fullCus = filterOutEntitiesFromFullCustomer({ fullCus }) as FullCustomer; - } - - const relevantFeatures = getRelevantFeatures({ - features: ctx.features, - featureId, - }); - - const featureDeductions: FeatureDeduction[] = []; - - // ApiBalance -> BackendBalance - - for (const relevantFeature of relevantFeatures) { - const redisBalance = redisEntity.balances?.[relevantFeature.id]; - if (!redisBalance) continue; - - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, - featureId: relevantFeature.id, - reverseOrder: org.config?.reverse_deduction_order, - entity: fullCus.entity, - inStatuses: orgToInStatuses({ org }), - customerEntitlementFilters, - }); - - // Filter balance by customerEntitlementFilters (handles breakdown filtering) - const filteredBalance = applyCustomerEntitlementFiltersToBalance({ - apiBalance: redisBalance, - customerEntitlementFilters, - }); - - if (!filteredBalance) continue; - - const backendBalance = apiToBackendBalance({ - cusEnts, - apiBalance: filteredBalance, - }); - - featureDeductions.push({ - feature: relevantFeature, - deduction: 0, - targetBalance: backendBalance, - }); - } - - // Sync from Redis to Postgres - deduct using target balance - - const result = await deductFromCusEnts({ - ctx, - customerId, - entityId, - deductions: featureDeductions, - fullCus, // to prevent fetching full customer again - customerEntitlementFilters, // Filter to specific entitlement if provided - refreshCache: false, // CRITICAL: Don't refresh cache after sync (Redis is the source of truth) - alterGrantedBalance: item.alterGrantedBalance, - overageBehaviour: item.overageBehaviour, - }); - - ctx.logger.info( - `[SYNC COMPLETE] (${customerId}${entityId ? `, ${entityId}` : ""}) feature ${featureId}, target: ${chalk.yellow(featureDeductions?.[0]?.targetBalance)}`, - ); - ctx.logger.info( - `[SYNC COMPLETE], actual deducted: ${chalk.yellow(result.actualDeductions[featureId])}`, - ); - - if (process.env.NODE_ENV === "production") { - console.log(`synced customer ${customerId}, feature ${featureId}`); - console.log(`org: ${org.slug}, env: ${env}`); - } - - // Old full cus vs new full cus - if (result.fullCus) { - for (const relevantFeature of relevantFeatures) { - await handleThresholdReached({ - ctx, - oldFullCus: result.oldFullCus, - newFullCus: result.fullCus, - feature: relevantFeature, - }); - } - } -}; diff --git a/server/src/internal/balances/utils/sync/legacy/syncItemV2.ts b/server/src/internal/balances/utils/sync/legacy/syncItemV2.ts deleted file mode 100644 index 3cddbfe03..000000000 --- a/server/src/internal/balances/utils/sync/legacy/syncItemV2.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - type ApiBalance, - type ApiBalanceBreakdown, - type ApiCustomer, - type ApiEntityV1, - cusEntsToAllowance, - cusEntToPrepaidQuantity, type FullCusEntWithFullCusProduct, - filterEntityLevelCustomerEntitlementsFromFullCustomer, - filterOutEntitiesFromFullCustomer, - fullCustomerToCustomerEntitlements, - getRelevantFeatures, - orgToInStatuses -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { sql } from "drizzle-orm"; -import { getRegionalRedis } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; -import { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; -import { getCachedApiEntity } from "@/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; - -export interface SyncItemV2 { - customerId: string; - featureId: string; - orgId: string; - env: string; - entityId?: string; - region?: string; - timestamp: number; - breakdownIds: string[]; -} - -interface EntitlementSync { - customer_entitlement_id: string; - target_balance?: number; - target_adjustment?: number; - entity_feature_id?: string; - target_entity_id?: string; -} - -/** - * Convert a breakdown item to backend balance using the corresponding cusEnt for prepaid quantity - */ -const breakdownToBackendBalance = ({ - breakdown, - cusEnt, -}: { - breakdown: ApiBalanceBreakdown; - cusEnt: FullCusEntWithFullCusProduct; -}): number => { - const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt }); - - // Backend balance = prepaid_quantity + current_balance - purchased_balance - return new Decimal(prepaidQuantity) - .add(breakdown.current_balance) - .sub(breakdown.purchased_balance) - .toNumber(); -}; - -const breakdownToTargetAdjustment = ({ - breakdown, - cusEnt, - targetEntityId, -}: { - breakdown: ApiBalanceBreakdown; - cusEnt: FullCusEntWithFullCusProduct; - targetEntityId?: string; -}): number => { - const allowance = cusEntsToAllowance({ - cusEnts: [cusEnt], - entityId: targetEntityId, - }); - - const grantedBalance = breakdown.granted_balance ?? 0; - - console.log( - `[breakdownToTargetAdjustment] grantedBalance: ${grantedBalance}, allowance: ${allowance}, targetEntityId: ${targetEntityId}`, - ); - - return new Decimal(grantedBalance).sub(allowance).toNumber(); -}; - -/** - * Build sync entries from Redis balance breakdown - * Each breakdown item maps to one EntitlementSync entry - */ -const buildSyncEntries = ({ - redisBalance, - cusEnts, - item, -}: { - redisBalance: ApiBalance; - cusEnts: FullCusEntWithFullCusProduct[]; - item: SyncItemV2; -}): EntitlementSync[] => { - const entries: EntitlementSync[] = []; - - if (!redisBalance.breakdown) return entries; - - // Normalize breakdownIds: Lua empty tables {} come through as objects, not arrays - const breakdownIds = Array.isArray(item.breakdownIds) - ? item.breakdownIds - : []; - - // Build a set of breakdown IDs to sync for efficient lookup - // If empty, sync all breakdowns - const breakdownIdsSet = - breakdownIds.length > 0 - ? new Set(breakdownIds) - : new Set(redisBalance.breakdown.map((b) => b.id)); - - // Iterate over SORTED cusEnts to maintain proper order - // This ensures breakdown entries are in the same order as deduction order - for (const cusEnt of cusEnts) { - // Skip if this cusEnt is not in the breakdowns to sync - if (!breakdownIdsSet.has(cusEnt.id)) continue; - - const breakdown = redisBalance.breakdown?.find((b) => b.id === cusEnt.id); - if (!breakdown) continue; - - const targetBalance = breakdownToBackendBalance({ breakdown, cusEnt }); - const targetAdjustment = breakdownToTargetAdjustment({ - breakdown, - cusEnt, - targetEntityId: item.entityId, - }); - - entries.push({ - customer_entitlement_id: breakdown.id, - target_balance: targetBalance, - target_adjustment: targetAdjustment, - entity_feature_id: cusEnt.entitlement.entity_feature_id ?? undefined, - target_entity_id: item.entityId ?? undefined, - }); - } - - return entries; -}; - -/** - * Sync Redis balances to Postgres using the sync_balances SQL function - * This is a cleaner approach than the old syncItem which repurposed deduction logic - */ -export const syncItemV2 = async ({ - item, - ctx, -}: { - item: SyncItemV2; - ctx: AutumnContext; -}): Promise => { - const { customerId, featureId, entityId, region } = item; - const { db, org, env } = ctx; - - // Get the correct regional Redis instance for this sync item - const redisInstance = region ? getRegionalRedis(region) : undefined; - - // Get cached customer/entity from Redis WITHOUT merging - let redisEntity: ApiCustomer | ApiEntityV1; - - ctx.skipCache = false; - if (entityId) { - const { apiEntity } = await getCachedApiEntity({ - ctx, - customerId, - entityId, - skipCustomerMerge: true, - redisInstance, - }); - redisEntity = apiEntity; - } else { - const { apiCustomer } = await getCachedApiCustomer({ - ctx, - customerId, - skipEntityMerge: true, - redisInstance, - }); - redisEntity = apiCustomer; - } - - // Get fresh customer from DB - let fullCus = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: RELEVANT_STATUSES, - withEntities: false, - withSubs: true, - entityId, - }); - - // Filter to entity-level or customer-level cusProducts - if (entityId) { - fullCus = filterEntityLevelCustomerEntitlementsFromFullCustomer({ - fullCustomer: fullCus, - }); - } else { - fullCus = filterOutEntitiesFromFullCustomer({ fullCus }); - } - - const relevantFeatures = getRelevantFeatures({ - features: ctx.features, - featureId, - }); - - // Collect all sync entries across features - const allEntries: EntitlementSync[] = []; - - for (const relevantFeature of relevantFeatures) { - const redisBalance = redisEntity.balances?.[relevantFeature.id]; - if (!redisBalance) continue; - - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, - featureId: relevantFeature.id, - reverseOrder: org.config?.reverse_deduction_order, - entity: fullCus.entity, - inStatuses: orgToInStatuses({ org }), - }); - - const entries = buildSyncEntries({ - redisBalance, - cusEnts, - item, - }); - - // console.log("Redis balance:", redisBalance); - // console.log("Sync entries:", entries); - allEntries.push(...entries); - } - - if (allEntries.length === 0) { - ctx.logger.info( - `[SYNC V2] No entries to sync for customer ${customerId}, feature ${featureId}`, - ); - return; - } - - // Call the sync_balances SQL function - const result = await db.execute( - sql`SELECT * FROM sync_balances( - ${JSON.stringify({ - entitlements: allEntries, - target_entity_id: entityId || null, - })}::jsonb - )`, - ); - - // Format result for readable logging - const syncResult = result[0] as - | { - sync_balances?: { - updates?: Record; - }; - } - | undefined; - const updates = syncResult?.sync_balances?.updates; - - if (updates && Object.keys(updates).length > 0) { - const formatted = Object.entries(updates) - .map(([id, data]) => { - const shortId = id.replace("cus_ent_", ""); - const parts: string[] = []; - if (data.balance !== undefined) parts.push(`bal=${data.balance}`); - if (data.adjustment !== undefined) parts.push(`adj=${data.adjustment}`); - return `${shortId}: ${parts.join(", ")}`; - }) - .join(" | "); - - ctx.logger.info(`[SYNC V2] (${customerId}) ${featureId}: ${formatted}`); - } else { - ctx.logger.info(`[SYNC V2] (${customerId}) ${featureId}: no changes`); - } -}; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts index 83e33ac07..9a76898c3 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts @@ -49,17 +49,7 @@ export const getUsageInvoiceItems = async ({ const cusPrices = cusProductsToCusPrices({ cusProducts: [cusProduct], }); - // const ents = cusProductToEnts({ cusProduct }); - // const cusEnts = fullCustomerToCustomerEntitlements({ - // fullCustomer: { - // customer_products: [cusProduct], - // }, - // inStatuses: [ - // CusProductStatus.Active, - // CusProductStatus.Expired, - // CusProductStatus.PastDue, - // ], - // }); + const cusEnts = cusProductToCusEnts({ cusProduct }); const invoiceItems: any[] = []; diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts index 475f660fc..e5c6216fd 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts @@ -1,21 +1,19 @@ -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js"; -import { Proration } from "@/internal/invoices/prorationUtils.js"; -import { getUsageFromBalance } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.js"; - import { - FullEntitlement, - FullCustomerEntitlement, - PreviewLineItem, - Price, + type FullCustomerEntitlement, + type FullEntitlement, + type PreviewLineItem, + type Price, usageToFeatureName, } from "@autumn/shared"; - -import { attachParamsToProduct } from "../convertAttachParams.js"; -import { priceToInvoiceItem } from "@/internal/products/prices/priceUtils/priceToInvoiceItem.js"; -import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; import { Decimal } from "decimal.js"; -import { getPrevAndNewPriceForUpgrade } from "@/trigger/arrearProratedUsage/handleProratedUpgrade.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import { getPrevAndNewPriceForUpgrade } from "@/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedUpgrade.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js"; +import type { Proration } from "@/internal/invoices/prorationUtils.js"; +import { getUsageFromBalance } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.js"; +import { priceToInvoiceItem } from "@/internal/products/prices/priceUtils/priceToInvoiceItem.js"; +import { attachParamsToProduct } from "../convertAttachParams.js"; export const getContUseUpgradeItems = async ({ price, @@ -34,29 +32,29 @@ export const getContUseUpgradeItems = async ({ curItem: PreviewLineItem; curUsage: number; proration?: Proration; - logger: any; + logger: Logger; }) => { - let prevInvoiceItem = curItem; - let prevBalance = prevCusEnt.entitlement.allowance! - curUsage; - let newBalance = ent.allowance! - curUsage; - let usageDiff = prevBalance - newBalance; + const prevInvoiceItem = curItem; + const prevBalance = prevCusEnt.entitlement.allowance! - curUsage; + const newBalance = ent.allowance! - curUsage; + const usageDiff = prevBalance - newBalance; const product = attachParamsToProduct({ attachParams }); const feature = prevCusEnt.entitlement.feature; - let { usage: prevUsage } = getUsageFromBalance({ + const { usage: prevUsage } = getUsageFromBalance({ ent: prevCusEnt.entitlement, price, balance: prevBalance, }); - let { usage: newUsage } = getUsageFromBalance({ + const { usage: newUsage } = getUsageFromBalance({ ent, price, balance: prevBalance, }); - let { usage: totalUsage } = getUsageFromBalance({ + const { usage: totalUsage } = getUsageFromBalance({ ent, price, balance: newBalance, diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index a52f8bf8e..eb82ccc34 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -14,7 +14,7 @@ import { features, type ResetCusEnt, } from "@autumn/shared"; -import { and, eq, isNull, lt, sql } from "drizzle-orm"; +import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -55,10 +55,12 @@ export class CusEntService { .select() .from(customerEntitlements) .where( - internalCustomerId ? and( - eq(customerEntitlements.internal_feature_id, internalFeatureId), - eq(customerEntitlements.internal_customer_id, internalCustomerId) - ) : eq(customerEntitlements.internal_feature_id, internalFeatureId), + internalCustomerId + ? and( + eq(customerEntitlements.internal_feature_id, internalFeatureId), + eq(customerEntitlements.internal_customer_id, internalCustomerId), + ) + : eq(customerEntitlements.internal_feature_id, internalFeatureId), ) .limit(10); @@ -96,10 +98,6 @@ export class CusEntService { const data = await db .select() .from(customerEntitlements) - .innerJoin( - customerProducts, - eq(customerEntitlements.customer_product_id, customerProducts.id), - ) .innerJoin( entitlements, eq(customerEntitlements.entitlement_id, entitlements.id), @@ -112,13 +110,26 @@ export class CusEntService { customers, eq(customerEntitlements.internal_customer_id, customers.internal_id), ) + .leftJoin( + customerProducts, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) .where( and( - eq(customerProducts.status, CusProductStatus.Active), + or( + isNull(customerEntitlements.customer_product_id), + eq(customerProducts.status, CusProductStatus.Active), + ), lt( customerEntitlements.next_reset_at, customDateUnix ?? Date.now(), ), + + // Customer entitlement has not expired + or( + isNull(customerEntitlements.expires_at), + gt(customerEntitlements.expires_at, Date.now()), + ), ), ) .limit(batchSize) @@ -149,73 +160,6 @@ export class CusEntService { return allResults as ResetCusEnt[]; } - static async getLooseResetPassed({ - db, - customDateUnix, - batchSize = 1000, - }: { - db: DrizzleCli; - customDateUnix?: number; - batchSize?: number; - }) { - const allResults: ResetCusEnt[] = []; - let offset = 0; - let hasMore = true; - - while (hasMore) { - const data = await db - .select() - .from(customerEntitlements) - .innerJoin( - entitlements, - eq(customerEntitlements.entitlement_id, entitlements.id), - ) - .innerJoin( - features, - eq(entitlements.internal_feature_id, features.internal_id), - ) - .innerJoin( - customers, - eq(customerEntitlements.internal_customer_id, customers.internal_id), - ) - .where( - and( - isNull(customerEntitlements.customer_product_id), - isNull(customerEntitlements.expires_at), // Ignore entitlements with expiry (they don't reset) - lt( - customerEntitlements.next_reset_at, - customDateUnix ?? Date.now(), - ), - ), - ) - .limit(batchSize) - .offset(offset); - - if (data.length === 0) { - hasMore = false; - } else { - const mappedData = data.map((item) => ({ - ...item.customer_entitlements, - entitlement: { - ...item.entitlements, - feature: item.features, - }, - customer_product: null, - customer: item.customers, - replaceables: [], - rollovers: [], - })) as ResetCusEnt[]; - - allResults.push(...mappedData); - offset += batchSize; - hasMore = data.length === batchSize; - console.log(`Fetched ${allResults.length} entitlements to reset`); - } - } - - return allResults; - } - static async update({ db, id, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts index 7b9792db7..121139b09 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts @@ -12,8 +12,8 @@ import { type Price, sortCusEntsForDeduction, } from "@autumn/shared"; +import { performDeductionOnCusEnt } from "@/internal/balances/utils/legacy/performDeductionOnCusEnt.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; -import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { isOneOff } from "../../../../products/productUtils.js"; import { diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts deleted file mode 100644 index 066f1bb4e..000000000 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { FullCusEntWithFullCusProduct, Rollover } from "@autumn/shared"; -import type { RolloverDeductParams } from "@/trigger/updateBalanceTask.js"; -import { RolloverService } from "./RolloverService.js"; - -export const deductFromApiCusRollovers = async ({ - toDeduct, - deductParams, - cusEnt, -}: { - toDeduct: number; - deductParams: RolloverDeductParams; - cusEnt: FullCusEntWithFullCusProduct; -}) => { - if (toDeduct === 0) { - return toDeduct; - } - - const updates = { - toInsert: [] as Rollover[], - toUpdate: [] as Rollover[], - }; - const rollovers = getSortedRollovers({ - cusEnts: [cusEnt], - featureId: deductParams.feature.id, - entityId: deductParams.entity?.id, - }); - - if (deductParams.entity) { - for (const rollover of rollovers) { - const entityRollover = rollover.entities[deductParams.entity.id]; - if (entityRollover) { - if (entityRollover.balance >= toDeduct) { - entityRollover.balance -= toDeduct; - entityRollover.usage += toDeduct; - - updates.toUpdate.push(rollover); - toDeduct = 0; - break; - } else { - if (entityRollover.balance > 0) { - const deductedAmount = entityRollover.balance; - toDeduct -= entityRollover.balance; - entityRollover.balance = 0; - entityRollover.usage += deductedAmount; - updates.toUpdate.push(rollover); - } - } - } - } - } else { - for (let rollover of rollovers) { - if (rollover.balance >= toDeduct) { - rollover = { - ...rollover, - balance: rollover.balance - toDeduct, - usage: rollover.usage + toDeduct, - }; - - updates.toUpdate.push(rollover); - toDeduct = 0; - - break; - } else { - if (rollover.balance > 0) { - toDeduct -= rollover.balance; - rollover = { - ...rollover, - usage: rollover.usage + rollover.balance, - balance: 0, - }; - - updates.toUpdate.push(rollover); - } - } - } - } - - await RolloverService.upsert({ - db: deductParams.db, - rows: updates.toUpdate, - }); - - return toDeduct; -}; - -export const getSortedRollovers = ({ - cusEnts, - featureId, - entityId, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - featureId: string; - entityId?: string; -}) => { - if (!entityId) - return cusEnts - .filter((cusEnt) => { - return cusEnt.feature_id === featureId; - }) - .flatMap((cusEnt) => { - return cusEnt.rollovers; - }) - .sort((a, b) => { - if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - else { - return cusEnts - .filter((cusEnt) => { - return ( - cusEnt.feature_id === featureId && - cusEnt.entities && - cusEnt.entities[entityId] - ); - }) - .flatMap((cusEnt) => { - return cusEnt.rollovers.filter((x) => { - return x.entities[entityId]; - }); - }) - .sort((a, b) => { - if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - } -}; diff --git a/server/src/internal/customers/cusRouter.ts b/server/src/internal/customers/cusRouter.ts index ac8aaf5fd..3876bb7b1 100644 --- a/server/src/internal/customers/cusRouter.ts +++ b/server/src/internal/customers/cusRouter.ts @@ -12,7 +12,6 @@ import { handleListCustomersV2 } from "./handlers/handleListCustomersV2.js"; import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js"; import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js"; import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js"; -import { handleUpdateCusEntitlementV2 } from "./handlers/handleUpdateCusEntitlementV2.js"; import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js"; export const expressCusRouter = express.Router(); @@ -39,7 +38,3 @@ cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal); // Legacy... cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2); -cusRouter.post( - "/:customer_id/entitlements/:customer_entitlement_id", - ...handleUpdateCusEntitlementV2, -); diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts deleted file mode 100644 index fea7ecc3f..000000000 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts +++ /dev/null @@ -1,217 +0,0 @@ -// import type { Redis } from "ioredis"; -// import { executeBatchDeduction } from "./executeBatchDeduction.js"; - -// interface BatchRequest { -// amount: number; -// timestamp: number; -// properties: Record; -// resolve: (result: { success: boolean; error?: string }) => void; -// reject: (error: Error) => void; -// } - -// export interface BatchContext { -// customerId: string; -// featureId: string; -// orgId: string; -// orgSlug: string; -// env: string; -// entityId?: string; -// } - -// interface Batch { -// requests: BatchRequest[]; -// timer: NodeJS.Timeout | null; -// context?: BatchContext; -// } - -// /** -// * Batching manager for Redis track deductions -// * Collects multiple deduction requests within a time window and processes them atomically in a single Lua script -// * -// * Benefits: -// * - Massive performance improvements for high-concurrency scenarios -// * - Atomic deductions across multiple requests -// * - Reduced Redis round trips -// */ -// export class BatchingManager { -// private batches = new Map(); -// private readonly BATCH_WINDOW_MS = 10; // 10ms batching window -// private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests - -// /** -// * Request a deduction with automatic batching -// * Returns a promise that resolves when the batch is processed -// */ -// async deduct({ -// redis, -// cacheKey, -// featureId, -// amount, -// timestamp, -// properties, -// context, -// }: { -// redis: Redis; -// cacheKey: string; -// featureId: string; -// amount: number; -// timestamp: number; -// properties: Record; -// context: BatchContext; -// }): Promise<{ success: boolean; error?: string }> { -// const batchKey = `${cacheKey}:${featureId}`; - -// return new Promise((resolve, reject) => { -// // Create batch if it doesn't exist -// if (!this.batches.has(batchKey)) { -// this.batches.set(batchKey, { -// requests: [], -// timer: null, -// context, -// }); - -// // Schedule batch execution -// this.scheduleBatch(batchKey, redis, cacheKey, featureId); -// } - -// const batch = this.batches.get(batchKey); -// if (!batch) { -// reject(new Error("Failed to get batch")); -// return; -// } - -// // Add request to batch -// batch.requests.push({ -// amount, -// timestamp, -// properties, -// resolve, -// reject, -// }); - -// // Force flush if batch is full -// if (batch.requests.length >= this.MAX_BATCH_SIZE) { -// this.executeBatch(batchKey, redis, cacheKey, featureId); -// } -// }); -// } - -// /** -// * Schedule batch execution after window expires -// */ -// private scheduleBatch( -// batchKey: string, -// redis: Redis, -// cacheKey: string, -// featureId: string, -// ): void { -// const batch = this.batches.get(batchKey); -// if (!batch) return; - -// batch.timer = setTimeout(() => { -// this.executeBatch(batchKey, redis, cacheKey, featureId); -// }, this.BATCH_WINDOW_MS); -// } - -// /** -// * Execute the batch - process all requests in one Lua script -// */ -// private async executeBatch( -// batchKey: string, -// redis: Redis, -// cacheKey: string, -// featureId: string, -// ): Promise { -// // CRITICAL: Remove batch from map FIRST to prevent race condition -// // New requests will create a new batch instead of adding to this one -// const batch = this.batches.get(batchKey); -// if (!batch || batch.requests.length === 0) { -// return; -// } - -// // Clear timer and remove from map IMMEDIATELY -// if (batch.timer) { -// clearTimeout(batch.timer); -// batch.timer = null; -// } -// this.batches.delete(batchKey); - -// const requests = batch.requests; -// const amounts = requests.map((r) => r.amount); -// const batchSize = requests.length; - -// console.log( -// `🚀 Executing batch with ${batchSize} requests for feature ${featureId}`, -// ); - -// try { -// // Execute batch Lua script -// const result = await executeBatchDeduction({ -// redis, -// cacheKey, -// targetFeatureId: featureId, -// amounts, -// }); - -// console.log( -// `✅ Batch completed (${batchSize} requests, ${result.successCount} succeeded)`, -// ); - -// // Resolve each request based on success/fail counts -// if (result.success) { -// const successCount = result.successCount || 0; - -// // TODO: Queue Postgres sync job for successful deductions if needed -// // This can be added later when integrating with the sync system - -// // First N requests succeed, rest fail -// for (let i = 0; i < requests.length; i++) { -// requests[i].resolve({ -// success: i < successCount, -// error: -// i < successCount -// ? undefined -// : result.error || "INSUFFICIENT_BALANCE", -// }); -// } -// } else { -// // Batch failed entirely (e.g., customer not found) -// for (const request of requests) { -// request.resolve({ -// success: false, -// error: result.error || "BATCH_FAILED", -// }); -// } -// } -// } catch (error) { -// console.error(`❌ Batch execution error:`, error); -// // Reject all requests on error -// for (const request of requests) { -// request.reject( -// error instanceof Error ? error : new Error(String(error)), -// ); -// } -// } -// } - -// /** -// * Get current batch statistics (for monitoring) -// */ -// getStats(): { -// activeBatches: number; -// totalPendingRequests: number; -// } { -// let totalPendingRequests = 0; -// for (const batch of this.batches.values()) { -// totalPendingRequests += batch.requests.length; -// } - -// return { -// activeBatches: this.batches.size, -// totalPendingRequests, -// }; -// } -// } - -// // Singleton instance -// export const globalBatchingManager = new BatchingManager(); diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts deleted file mode 100644 index 05bfebe5f..000000000 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts +++ /dev/null @@ -1,44 +0,0 @@ -interface BatchDeductionResult { - success: boolean; - successCount: number; - error?: string; -} - -// /** -// * Execute batch deduction Lua script -// * Processes multiple deductions atomically in a single Redis call -// * Supports credit system features as alternative payment sources -// */ -// export const executeBatchDeduction = async ({ -// redis, -// cacheKey, -// targetFeatureId, -// amounts, -// }: { -// redis: Redis; -// cacheKey: string; -// targetFeatureId: string; // The feature we're trying to deduct from -// amounts: number[]; -// }): Promise => { -// try { -// // Execute Lua script -// const result = await redis.eval( -// BATCH_DEDUCTION_SCRIPT, -// 2, // number of keys -// cacheKey, // KEYS[1] -// targetFeatureId, // KEYS[2] - target feature ID -// JSON.stringify(amounts), // ARGV[1] -// ); - -// // Parse result -// const parsed = JSON.parse(result as string) as BatchDeductionResult; -// return parsed; -// } catch (error) { -// console.error("Error executing batch deduction:", error); -// return { -// success: false, -// successCount: 0, -// error: error instanceof Error ? error.message : "UNKNOWN_ERROR", -// }; -// } -// }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index 096ca8f47..eb520db4a 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -129,6 +129,7 @@ export const getBooleanApiBalance = ({ max_purchase: null, reset: null, prepaid_quantity: 0, + expires_at: null, }, ], rollovers: undefined, @@ -174,6 +175,7 @@ export const getUnlimitedApiBalance = ({ max_purchase: null, reset: null, prepaid_quantity: 0, + expires_at: null, }, ], rollovers: undefined, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index a80013d66..7d549c255 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -41,7 +41,7 @@ const cusEntsToBreakdown = ({ cusEnts, }: { ctx: RequestContext; - cusEnts: (FullCusEntWithFullCusProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; fullCus: FullCustomer; }): { key: string; @@ -117,7 +117,7 @@ export const getApiBalance = ({ }: { ctx: RequestContext; fullCus: FullCustomer; - cusEnts: (FullCusEntWithFullCusProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; includeRollovers?: boolean; includeBreakdown?: boolean; @@ -204,13 +204,13 @@ export const getApiBalance = ({ const reset = cusEntsToReset({ cusEnts, feature }); const rollovers = cusEntsToRollovers({ cusEnts, entityId }); - const breakdownSet = includeBreakdown + const breakdown = includeBreakdown ? cusEntsToBreakdown({ ctx, fullCus, cusEnts }) - : undefined; + : []; const planId = cusEntsToPlanId({ cusEnts }); - const masterKey = breakdownSet ? null : cusEntToKey({ cusEnt: cusEnts[0] }); + const masterKey = breakdown ? null : cusEntToKey({ cusEnt: cusEnts[0] }); const { data: apiBalance, error } = ApiBalanceSchema.safeParse({ feature: expandIncludes({ @@ -243,7 +243,7 @@ export const getApiBalance = ({ reset: reset, plan_id: planId, - breakdown: breakdownSet?.map((item) => item.breakdown), + breakdown: breakdown.map((item) => item.breakdown), rollovers, } satisfies ApiBalance); @@ -251,7 +251,7 @@ export const getApiBalance = ({ // Return in latest format - version transformation happens at Customer level const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); - const breakdownLegacyData = breakdownSet?.map((item) => ({ + const breakdownLegacyData = breakdown.map((item) => ({ key: item.key, prepaid_quantity: item.prepaidQuantity, })); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index d87473b0f..e76737246 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -19,8 +19,6 @@ export const getApiBalances = async ({ }) => { const { org } = ctx; - // fullCustomerToCustomerEntitlements already includes extra_customer_entitlements - // and filters them by entity via cusEntMatchesEntity const allCusEnts = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, inStatuses: orgToInStatuses({ org }), diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts deleted file mode 100644 index 0c92f8bc4..000000000 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ /dev/null @@ -1,295 +0,0 @@ -// import { ErrCode, getCusEntBalance } from "@autumn/shared"; -// import { Decimal } from "decimal.js"; -// import { StatusCodes } from "http-status-codes"; -// import { CusService } from "@/internal/customers/CusService.js"; -// import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -// import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -// import { FeatureService } from "@/internal/features/FeatureService.js"; -// import { OrgService } from "@/internal/orgs/OrgService.js"; -// import { -// deductAllowanceFromCusEnt, -// deductFromUsageBasedCusEnt, -// } from "@/trigger/updateBalanceTask.js"; -// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -// import { notNullish } from "@/utils/genUtils.js"; -// import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js"; - -// const getCusFeaturesAndOrg = async (req: any, customerId: string) => { -// // 1. Get customer -// const [customer, features, org] = await Promise.all([ -// CusService.getFull({ -// db: req.db, -// idOrInternalId: customerId, -// orgId: req.orgId, -// env: req.env, -// entityId: req.params.entity_id, -// }), -// FeatureService.getFromReq(req), -// OrgService.getFromReq(req), -// ]); - -// if (!customer) { -// throw new RecaseError({ -// message: `Customer ${customerId} not found`, -// code: ErrCode.CustomerNotFound, -// statusCode: StatusCodes.NOT_FOUND, -// }); -// } - -// return { customer, features, org }; -// }; - -// export const handleUpdateBalances = async (req: any, res: any) => { -// try { -// const logger = req.logger; -// const cusId = req.params.customer_id; -// const { env, db, features } = req; -// const { balances } = req.body; - -// if (!Array.isArray(balances)) { -// throw new RecaseError({ -// message: "Balances must be an array", -// code: ErrCode.InvalidRequest, -// statusCode: StatusCodes.BAD_REQUEST, -// }); -// } - -// const { customer, org } = await getCusFeaturesAndOrg(req, cusId); - -// const featuresToUpdate = features.filter((f: any) => -// balances.map((b: any) => b.feature_id).includes(f.id), -// ); - -// if (featuresToUpdate.length === 0) { -// throw new RecaseError({ -// message: "No valid features found to update", -// code: ErrCode.InvalidRequest, -// statusCode: StatusCodes.BAD_REQUEST, -// }); -// } - -// const { cusEnts, cusPrices } = await getCusEntsInFeatures({ -// customer, -// internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!), -// logger: req.logger, -// }); - -// logger.info("--------------------------------"); -// logger.info( -// `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`, -// ); -// logger.info( -// `Features to update: ${balances.map( -// (b: any) => -// `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`, -// )}`, -// ); - -// // Get deductions for each feature -// const featureDeductions = []; -// for (const balance of balances) { -// if (!balance.feature_id) { -// throw new RecaseError({ -// message: "Feature ID is required", -// code: ErrCode.InvalidRequest, -// statusCode: StatusCodes.BAD_REQUEST, -// }); -// } - -// if (typeof balance.balance !== "number" && balance.unlimited !== true) { -// throw new RecaseError({ -// message: "Balance must be a number", -// code: ErrCode.InvalidRequest, -// statusCode: StatusCodes.BAD_REQUEST, -// }); -// } - -// const feature = featuresToUpdate.find( -// (f: any) => f.id === balance.feature_id, -// ); - -// if (balance.unlimited === true) { -// featureDeductions.push({ -// feature, -// unlimited: true, -// toDeduct: 0, -// }); -// continue; -// } - -// const { unlimited } = getUnlimitedAndUsageAllowed({ -// cusEnts, -// internalFeatureId: feature!.internal_id!, -// }); - -// if (unlimited) { -// throw new RecaseError({ -// message: `Can't set balance for unlimited feature: ${feature!.id}`, -// code: ErrCode.InvalidRequest, -// statusCode: StatusCodes.BAD_REQUEST, -// }); -// } - -// // Get deductions -// const newBalance = balance.balance; -// let curBalance = new Decimal(0); -// const properties = structuredClone(balance); -// delete properties.feature_id; -// delete properties.balance; - -// for (const cusEnt of cusEnts) { -// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; -// const deductionIntCount = balance.interval_count || 1; - -// const intCountMatch = notNullish(balance.interval_count) -// ? cusEntIntCount === deductionIntCount -// : true; - -// const intMatch = notNullish(balance.interval) -// ? balance.interval === cusEnt.entitlement.interval -// : true; - -// if ( -// cusEnt.internal_feature_id !== feature!.internal_id! || -// !intMatch || -// !intCountMatch -// ) { -// continue; -// } - -// const { balance: cusEntBalance } = getCusEntBalance({ -// cusEnt, -// entityId: balance.entity_id, -// }); - -// curBalance = curBalance.add(new Decimal(cusEntBalance!)); -// } - -// const toDeduct = curBalance.sub(newBalance).toNumber(); - -// if (toDeduct === 0) { -// logger.info(`Skipping ${feature!.id} -- no change`); -// } - -// featureDeductions.push({ -// feature, -// toDeduct, -// properties, -// interval: balance.interval, -// intervalCount: balance.interval_count, -// }); -// } - -// const batchDeduct = []; - -// for (const featureDeduction of featureDeductions) { -// // 1. Deduct from allowance -// const performDeduction = async () => { -// let { toDeduct, feature, properties, interval } = featureDeduction; - -// // Handle unlimited -// if (featureDeduction.unlimited) { -// // Get one active cusEnt and set unlimited to true - -// const cusEnt = notNullish(interval) -// ? cusEnts.find((cusEnt) => { -// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; -// const deductionIntCount = featureDeduction.intervalCount || 1; - -// return ( -// cusEnt.internal_feature_id === feature!.internal_id! && -// cusEnt.entitlement.interval === interval && -// cusEntIntCount === deductionIntCount -// ); -// }) -// : cusEnts.find( -// (cusEnt) => -// cusEnt.internal_feature_id === feature!.internal_id!, -// ); - -// if (!cusEnt) { -// logger.warn( -// `No active cus ent to set unlimited balance for feature: ${ -// feature!.id -// }`, -// ); -// return; -// } - -// await CusEntService.update({ -// db, -// id: cusEnt.id, -// updates: { -// unlimited: true, -// next_reset_at: null, -// }, -// }); - -// return; -// } - -// for (const cusEnt of cusEnts) { -// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; -// const deductionIntCount = featureDeduction.intervalCount || 1; - -// const intCountMatch = notNullish(featureDeduction.intervalCount) -// ? cusEntIntCount === deductionIntCount -// : true; - -// const intMatch = notNullish(featureDeduction.interval) -// ? featureDeduction.interval === cusEnt.entitlement.interval -// : true; - -// if ( -// cusEnt.internal_feature_id !== -// featureDeduction.feature!.internal_id! || -// !intMatch || -// !intCountMatch -// ) { -// continue; -// } - -// toDeduct = await deductAllowanceFromCusEnt({ -// toDeduct, -// deductParams: { -// db, -// feature: featureDeduction.feature!, -// env: req.env, -// org, -// cusPrices: cusPrices as any[], -// customer, -// }, -// cusEnt, -// featureDeductions: [], // not important because not deducting credits -// willDeductCredits: false, -// }); -// } - -// if (toDeduct === 0) { -// return; -// } - -// await deductFromUsageBasedCusEnt({ -// toDeduct, -// cusEnts, -// deductParams: { -// db, -// feature: featureDeduction.feature!, -// env, -// org, -// cusPrices: cusPrices as any[], -// customer, -// }, -// }); -// }; -// batchDeduct.push(performDeduction()); -// } -// await Promise.all(batchDeduct); - -// logger.info(" ✅ Successfully updated balances"); - -// res.status(200).json({ success: true }); -// } catch (error) { -// handleRequestError({ req, error, res, action: "update customer balances" }); -// } -// }; diff --git a/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts index e2edfdbf1..81302f0bc 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts @@ -2,9 +2,8 @@ import { FeatureNotFoundError, UpdateBalancesParamsSchema, } from "@autumn/shared"; +import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction"; import { createRoute } from "../../../honoMiddlewares/routeHandler"; - -import { runDeductionTx } from "../../balances/track/trackUtils/runDeductionTx"; import type { FeatureDeduction } from "../../balances/utils/types/featureDeduction"; import { CusService } from "../CusService"; @@ -38,14 +37,16 @@ export const handleUpdateBalancesV2 = createRoute({ targetBalance: b.balance, })) satisfies FeatureDeduction[]; - await runDeductionTx({ + await executePostgresDeduction({ ctx, + fullCustomer: fullCus, customerId: customer_id, deductions: featureDeductions, - entityId: fullCus.entity?.id, - skipAdditionalBalance: true, - alterGrantedBalance: true, refreshCache: true, + options: { + alterGrantedBalance: true, + overageBehaviour: "allow", + }, }); return c.json({ success: true }); diff --git a/server/src/internal/customers/handlers/handleUpdateCusEntitlementV2.ts b/server/src/internal/customers/handlers/handleUpdateCusEntitlementV2.ts deleted file mode 100644 index 0608793c2..000000000 --- a/server/src/internal/customers/handlers/handleUpdateCusEntitlementV2.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { InternalError, notNullish } from "@autumn/shared"; -import { z } from "zod/v4"; -import { createRoute } from "../../../honoMiddlewares/routeHandler"; -import { runDeductionTx } from "../../balances/track/trackUtils/runDeductionTx"; -import { CusService } from "../CusService"; -import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService"; -import { deleteCachedApiCustomer } from "../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; - -export const handleUpdateCusEntitlementV2 = createRoute({ - body: z.object({ - balance: z.number(), - next_reset_at: z.number().nullish(), - entity_id: z.string().nullish(), - }), - handler: async (c) => { - const ctx = c.get("ctx"); - const { customer_id, customer_entitlement_id } = c.req.param(); - const { balance, next_reset_at, entity_id } = c.req.valid("json"); - const { db, org, env } = ctx; - - const fullCus = await CusService.getFull({ - db, - idOrInternalId: customer_id, - orgId: org.id, - env, - }); - - const cusEnt = fullCus.customer_products - .flatMap((cp) => cp.customer_entitlements) - .find((ce) => ce.id === customer_entitlement_id); - if (!cusEnt) { - throw new InternalError({ - message: `[update cus entitlement] Customer entitlement not found: ${customer_entitlement_id}`, - }); - } - - console.log( - `Updating cus ent: ${cusEnt.id} to balance: ${balance}, entity ID: ${entity_id}`, - ); - await runDeductionTx({ - ctx, - customerId: customer_id, - entityId: entity_id ?? undefined, - deductions: [ - { - feature: cusEnt.entitlement.feature, - deduction: 0, - targetBalance: balance, - }, - ], - skipAdditionalBalance: true, - alterGrantedBalance: true, - customerEntitlementFilters: { - cusEntIds: [customer_entitlement_id], - }, - refreshCache: false, - }); - - if (notNullish(next_reset_at) && next_reset_at !== cusEnt.next_reset_at) { - await CusEntService.update({ - db, - id: customer_entitlement_id, - updates: { - next_reset_at, - }, - }); - } - - await deleteCachedApiCustomer({ - ctx, - customerId: customer_id, - source: "handleUpdateBalance", - }); - - return c.json({ success: true }); - }, -}); diff --git a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts b/server/src/internal/customers/handlers/handleUpdateEntitlement.ts deleted file mode 100644 index 7363fe9dd..000000000 --- a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - ErrCode, - type FullCustomerEntitlement, - getCusEntBalance, - notNullish, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { StatusCodes } from "http-status-codes"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { adjustAllowance } from "@/trigger/adjustAllowance.js"; -import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { CusProductService } from "../cusProducts/CusProductService.js"; - -const getCusOrgAndCusPrice = async ({ - db, - req, - cusEnt, -}: { - db: DrizzleCli; - req: ExtendedRequest; - cusEnt: FullCustomerEntitlement; -}) => { - const [cusPrice, customer, org] = await Promise.all([ - CusPriceService.getRelatedToCusEnt({ - db, - cusEnt, - }), - CusService.getByInternalId({ - db, - internalId: cusEnt.internal_customer_id, - }), - OrgService.getFromReq(req), - ]); - - return { cusPrice, customer, org }; -}; - -export const handleUpdateEntitlement = async (req: any, res: any) => { - try { - const { db } = req; - const { customer_entitlement_id } = req.params; - const { balance, next_reset_at, entity_id } = req.body; - - if (Number.isNaN(parseFloat(balance))) { - throw new RecaseError({ - message: "Invalid balance", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - if ( - notNullish(next_reset_at) && - (!Number.isInteger(next_reset_at) || next_reset_at < 0) - ) { - throw new RecaseError({ - message: "Next reset at must be a valid unix timestamp or null", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - // Check if org owns the entitlement - const cusEnt = await CusEntService.getStrict({ - db, - id: customer_entitlement_id, - orgId: req.orgId, - env: req.env, - withCusProduct: true, - }); - - const cusProduct = await CusProductService.get({ - db, - id: cusEnt.customer_product_id, - orgId: req.orgId, - env: req.env, - }); - - console.log(`Updating cus ent: ${cusEnt.id} to balance: ${balance}`); - - if (cusEnt.unlimited) { - throw new RecaseError({ - message: "Entitlement is unlimited", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const { balance: masterBalance } = getCusEntBalance({ - cusEnt, - entityId: entity_id, - }); - - const deducted = new Decimal(masterBalance!).minus(balance).toNumber(); - - const originalBalance = structuredClone(masterBalance); - - const { newBalance, newEntities, newAdjustment } = performDeductionOnCusEnt( - { - cusEnt: { - ...cusEnt, - customer_product: cusProduct!, - }, - toDeduct: deducted, - addAdjustment: true, - allowNegativeBalance: true, - entityId: entity_id, - }, - ); - - const updates = { - balance: newBalance, - next_reset_at, - entities: newEntities, - adjustment: newAdjustment, - }; - - const { cusPrice, customer, org } = await getCusOrgAndCusPrice({ - db, - req, - cusEnt, - }); - - if (cusPrice && customer) { - const fullCusProduct = await CusProductService.get({ - db, - id: cusEnt.customer_product_id, - orgId: req.orgId, - env: req.env, - }); - - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env: req.env, - org: org, - affectedFeature: cusEnt.entitlement.feature, - cusEnt: { - ...cusEnt, - customer_product: fullCusProduct!, - }, - cusPrices: [cusPrice], - customer: customer, - originalBalance: originalBalance!, - newBalance: balance, - logger: req.logger, - }); - - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } - - if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } - } - - await CusEntService.update({ - db, - id: customer_entitlement_id, - updates, - }); - - res.status(200).json({ success: true }); - } catch (error) { - handleRequestError({ - req, - error, - res, - action: "update customer entitlement", - }); - } -}; diff --git a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts index e362b8289..4b4394bf9 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts @@ -13,8 +13,8 @@ import { findMainCusEntForFeature, } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { adjustAllowance } from "@/trigger/adjustAllowance.js"; -import { getReps } from "@/trigger/arrearProratedUsage/handleProratedUpgrade.js"; +import { adjustAllowance } from "@/internal/balances/utils/paidAllocatedFeature/adjustAllowance.js"; +import { getReps } from "@/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedUpgrade.js"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; diff --git a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts index 165154cd3..c7dcdebdc 100644 --- a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts +++ b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts @@ -1,6 +1,6 @@ import { EntityNotFoundError } from "@autumn/shared"; import { createRoute } from "../../../../honoMiddlewares/routeHandler.js"; -import { adjustAllowance } from "../../../../trigger/adjustAllowance.js"; +import { adjustAllowance } from "@/internal/balances/utils/paidAllocatedFeature/adjustAllowance.js"; import type { ExtendedRequest } from "../../../../utils/models/Request.js"; import { EntityService } from "../../../api/entities/EntityService.js"; import { CusService } from "../../../customers/CusService.js"; diff --git a/server/src/queue/bullmq/initBullMqWorkers.ts b/server/src/queue/bullmq/initBullMqWorkers.ts index 0181fe485..fd26b43fb 100644 --- a/server/src/queue/bullmq/initBullMqWorkers.ts +++ b/server/src/queue/bullmq/initBullMqWorkers.ts @@ -4,7 +4,7 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; -import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/legacy/runSyncBalanceBatch.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js"; import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; @@ -94,8 +94,14 @@ const initWorker = ({ id, db }: { id: number; db: DrizzleCli }) => { return; } - if (job.name === JobName.SyncBalanceBatch) { - await runSyncBalanceBatch({ + if (job.name === JobName.SyncBalanceBatchV3) { + if (!ctx) { + workerLogger.error( + "No context found for sync balance batch v3 job", + ); + return; + } + await syncItemV3({ ctx, payload: job.data, }); diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index ffa637e1d..ad6a9f824 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -11,8 +11,6 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; -import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/legacy/runSyncBalanceBatch.js"; -import { syncItemV2 } from "@/internal/balances/utils/sync/legacy/syncItemV2.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js"; @@ -138,27 +136,6 @@ const processMessage = async ({ return; } - if (job.name === JobName.SyncBalanceBatch) { - await runSyncBalanceBatch({ - ctx, - payload: job.data, - }); - return; - } - - if (job.name === JobName.SyncBalanceBatchV2) { - if (!ctx) { - workerLogger.error("No context found for sync balance batch v2 job"); - return; - } - - await syncItemV2({ - ctx, - item: job.data.item, - }); - return; - } - if (job.name === JobName.SyncBalanceBatchV3) { if (!ctx) { workerLogger.error("No context found for sync balance batch v3 job"); diff --git a/server/src/scan/runScan.ts b/server/src/scan/runScan.ts deleted file mode 100644 index adb6507c4..000000000 --- a/server/src/scan/runScan.ts +++ /dev/null @@ -1,405 +0,0 @@ -import "dotenv/config"; - -import assert from "node:assert"; -import { - AppEnv, - CusProductStatus, - cusProductToPrices, - type Entity, - type FullCusProduct, - type FullCustomer, - type Organization, -} from "@autumn/shared"; - -import type Stripe from "stripe"; -import { initDrizzle } from "@/db/initDrizzle.js"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js"; -import { createSupabaseClient } from "@/external/supabaseUtils.js"; -import { EntityService } from "@/internal/api/entities/EntityService.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; -import { checkCusSubCorrect } from "@/utils/checkUtils/checkCustomerCorrect.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { - getAllEntities, - getAllFullCustomers, -} from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js"; -import { - getAllStripeSchedules, - getAllStripeSubscriptions, -} from "@/utils/scriptUtils/getAll/getAllStripeSubs.js"; - -const { db } = initDrizzle({ maxConnections: 5 }); - -const orgSlugs = process.env.ORG_SLUGS!.split(","); -const skipEmails = process.env.SKIP_EMAILS!.split(","); -const skipIds = [ - "cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx", - "DxG668K7uDd0Vahk54YWjvCGVgf2", -]; - -// orgSlugs = ["athenahq"]; -const customerId = null; - -const getSingleCustomer = async ({ - stripeCli, - customerId, - orgId, - env, -}: { - stripeCli: Stripe; - customerId: string; - orgId: string; - env: AppEnv; -}) => { - const customers = [ - await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId, - env, - }), - ]; - - const stripeCusId = customers[0].processor?.id; - const stripeSubs = stripeCusId - ? ( - await stripeCli.subscriptions.list({ - customer: stripeCusId, - expand: ["data.discounts.coupon"], - }) - ).data - : []; - - // const stripeSubs = await getStripeSubs({ - // stripeCli, - // subIds: customers[0].customer_products.flatMap( - // (cp) => cp.subscription_ids || [] - // ), - // }); - - let scheduleIds = customers[0].customer_products.flatMap( - (cp) => cp.scheduled_ids || [], - ); - - scheduleIds = Array.from(new Set(scheduleIds)); - - const stripeSchedules = await getStripeSchedules({ - stripeCli, - scheduleIds, - }); - - const entities = await EntityService.list({ - db, - internalCustomerId: customers[0].internal_id, - }); - - return { customers, stripeSubs, stripeSchedules, entities }; -}; - -const checkCustomerCorrect = async ({ - fullCus, - subs, - schedules, - org, - entities, -}: { - fullCus: FullCustomer; - subs: Stripe.Subscription[]; - schedules: Stripe.SubscriptionSchedule[]; - org: Organization; - entities: Entity[]; -}) => { - if (skipIds.includes(fullCus.internal_id!)) return; - - if (skipEmails.some((skipEmail) => skipEmail === fullCus.email)) { - return; - } - - fullCus.entities = entities.filter( - (entity) => entity.internal_customer_id === fullCus.internal_id, - ); - - // console.log(`Checking ${fullCus.email} (${fullCus.id})`); - const cusProducts = fullCus.customer_products; - - await checkCusSubCorrect({ - db, - fullCus, - subs, - schedules, - org, - env: AppEnv.Live, - }); - - for (const cusProduct of cusProducts) { - if (!cusProduct.subscription_ids) continue; - - if (cusProduct.status === CusProductStatus.Scheduled) { - // Check if there's a main product elsewhere - const mainCusProd = cusProducts.find( - (cp: FullCusProduct) => - cp.product.group === cusProduct.product.group && - cp.id !== cusProduct.id && - cp.status !== CusProductStatus.Scheduled && - (cusProduct.internal_entity_id - ? cusProduct.internal_entity_id === cp.internal_entity_id - : true), - ); - - assert( - mainCusProd, - `Found scheduled cus product with no main product (${cusProduct.product.name})`, - ); - } - - if ( - !cusProduct.product.is_add_on && - cusProduct.status !== CusProductStatus.Scheduled - ) { - const group = cusProduct.product.group; - const otherCusProd = cusProducts.find( - (cp: FullCusProduct) => - cp.product.group === group && - cp.id !== cusProduct.id && - !cp.product.is_add_on && - cp.status !== CusProductStatus.Scheduled && - cp.internal_entity_id === cusProduct.internal_entity_id, - ); - - assert( - !otherCusProd, - `found two cus products from the same group: ${otherCusProd?.product.name} and ${cusProduct.product.name}`, - ); - } - - const stripeSubs = subs.filter((sub: any) => - cusProduct.subscription_ids!.some((id: string) => id === sub.id), - ); - - assert( - stripeSubs.length === cusProduct.subscription_ids!.length, - "number of stripe subs should be the same as number of subscription ids", - ); - - // let subItems = stripeSubs.flatMap((sub: any) => sub.items.data); - - const prices = cusProductToPrices({ cusProduct }); - - if ( - isOneOff(prices) || - isFreeProduct(prices) || - cusProduct.status === CusProductStatus.Scheduled - ) { - continue; - } - - for (const cusEnt of cusProduct.customer_entitlements) { - const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices); - - if (cusEnt.usage_allowed && !cusPrice) { - assert.fail( - `Feature ${cusEnt.feature_id} has usage allowed but no related cus price`, - ); - } - } - } - - // Other checks to perform -}; - -const checkCustomerHandleError = async ({ - fullCus, - subs, - org, - schedules, - entities, -}: { - fullCus: FullCustomer; - subs: Stripe.Subscription[]; - org: Organization; - schedules: Stripe.SubscriptionSchedule[]; - entities: Entity[]; -}) => { - try { - await checkCustomerCorrect({ - fullCus, - subs, - org, - schedules, - entities, - }); - - return undefined; - } catch (error: any) { - return { - id: fullCus.id, - name: fullCus.name, - email: fullCus.email, - error: error.message, - }; - } -}; - -export const check = async () => { - const env = AppEnv.Live; - const sb = createSupabaseClient(); - - const today = new Date().toISOString().slice(0, 16); - - for (const slug of orgSlugs) { - const org = await OrgService.getBySlug({ - db, - slug, - }); - - if (!org) { - console.log(`Org ${slug} not found`); - continue; - } - - const fileName = `errors/${today}-${org.slug}.json`; - - const stripeCli = createStripeCli({ - org, - env, - }); - - console.log("--------------------------------"); - console.log(`Running error check for ${org.name}`); - - let customers: FullCustomer[] = []; - let stripeSubs: Stripe.Subscription[] = []; - let stripeSchedules: Stripe.SubscriptionSchedule[] = []; - let entities: Entity[] = []; - - if (customerId) { - const res = await getSingleCustomer({ - stripeCli, - customerId, - orgId: org.id, - env, - }); - - customers = res.customers; - stripeSubs = res.stripeSubs; - entities = res.entities; - } else { - const [customersRes, stripeSubsRes, stripeSchedulesRes, entitiesRes] = - await Promise.all([ - getAllFullCustomers({ - db, - orgId: org.id, - env, - }), - getAllStripeSubscriptions({ - stripeCli, - waitForSeconds: 1, - }), - getAllStripeSchedules({ - stripeCli, - waitForSeconds: 1, - }), - getAllEntities({ - db, - orgId: org.id, - env, - }), - ]); - - customers = customersRes; - stripeSubs = stripeSubsRes.subscriptions; - stripeSchedules = stripeSchedulesRes.schedules; - entities = entitiesRes; - } - - const batchSize = 1; - const allErrors = []; - for (let i = 0; i < customers.length; i += batchSize) { - const batch = customers.slice(i, i + batchSize); - - const batchCheck: any = []; - for (const customer of batch) { - batchCheck.push( - checkCustomerHandleError({ - fullCus: customer, - subs: stripeSubs, - schedules: stripeSchedules, - org, - entities, - }), - ); - } - - let results = await Promise.all(batchCheck); - results = results.filter(notNullish); - allErrors.push(...results); - } - - console.log(`Found ${allErrors.length} errors`); - - if (allErrors.length > 0 && customers.length > 1) { - await sb.storage - .from("autumn") - .upload(fileName, JSON.stringify(allErrors, null, 2)); - - if (allErrors.length > 0) { - const slackBody = { - text: `Error check for ${org.name}`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `*Error check for ${org.name}*: found ${allErrors.length} errors\nSee results at ${process.env.SUPABASE_URL}/storage/v1/object/public/autumn/${fileName}`, - }, - }, - ], - }; - - await fetch(process.env.SLACK_WEBHOOK_URL!, { - method: "POST", - body: JSON.stringify(slackBody), - }); - } - } else { - console.log(allErrors); - } - } - - console.log( - `COMPLETED ERROR CHECK FOR ${new Date().toISOString().slice(0, 16)}`, - ); - - if (process.env.NODE_ENV === "production") { - const slackBody = { - text: `Completed error check for ${new Date().toISOString().slice(0, 16)}`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Error check completed for ${new Date().toISOString().slice(0, 16)}`, - }, - }, - ], - }; - - await fetch(process.env.SLACK_WEBHOOK_URL!, { - method: "POST", - body: JSON.stringify(slackBody), - }); - } -}; - -check() - .catch((error) => { - console.error(error); - process.exit(1); - }) - .finally(() => { - process.exit(0); - }); diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts deleted file mode 100644 index 9b4633ece..000000000 --- a/server/src/trigger/updateBalanceTask.ts +++ /dev/null @@ -1,781 +0,0 @@ -import { - AllowanceType, - type AppEnv, - BillingType, - CusProductStatus, - type Customer, - type Entity, - type EntityBalance, - type Event, - type Feature, - FeatureType, - FeatureUsageType, - type FullCusEntWithFullCusProduct, - type FullCustomerEntitlement, - type FullCustomerPrice, - getStartingBalance, - type Organization, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; -import { - getCusEntMasterBalance, - getRelatedCusPrice, - getTotalNegativeBalance, -} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; -import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js"; -import { - creditSystemContainsFeature, - featureToCreditSystem, -} from "@/internal/features/creditSystemUtils.js"; -import { - getBillingType, - getEntOptions, -} from "@/internal/products/prices/priceUtils.js"; -import { notNullish, nullish } from "@/utils/genUtils.js"; -import { adjustAllowance } from "./adjustAllowance.js"; -import { - getCreditSystemDeduction, - getMeteredDeduction, - performDeduction, -} from "./deductUtils.js"; - -export type DeductParams = { - db: DrizzleCli; - env: AppEnv; - org: Organization; - cusPrices: FullCustomerPrice[]; - customer: Customer; - // properties: any; - feature: Feature; - entity?: Entity; -}; - -export type RolloverDeductParams = { - db: DrizzleCli; - env: AppEnv; - feature: Feature; - entity?: Entity; -}; - -// 2. Get deductions for each feature -const getFeatureDeductions = ({ - cusEnts, - event, - features, -}: { - cusEnts: FullCustomerEntitlement[]; - event: Event; - features: Feature[]; -}) => { - const meteredFeatures = features.filter( - (feature) => feature.type === FeatureType.Metered, - ); - const featureDeductions = []; - for (const feature of features) { - let deduction; - if (feature.type === FeatureType.Metered) { - deduction = getMeteredDeduction(feature, event); - } else if (feature.type === FeatureType.CreditSystem) { - deduction = getCreditSystemDeduction({ - meteredFeatures: meteredFeatures, - creditSystem: feature, - event, - }); - } - - // Check if unlimited exists - const unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id === feature.internal_id, - ); - - if (unlimitedExists || !deduction) { - continue; - } - - featureDeductions.push({ - feature, - deduction, - }); - } - - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } - - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } - - return a.feature.id.localeCompare(b.feature.id); - }); - - return featureDeductions; -}; - -export const logBalanceUpdate = ({ - timeTaken, - customer, - features, - cusEnts, - featureDeductions, - properties, - entityId, - org, -}: { - timeTaken: string; - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - properties: any; - entityId?: string | null; - org: Organization; -}) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")}`, - ); - console.log(" - Properties:", properties); - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; - - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - console.log( - ` - Entity feature ID found for feature: ${cusEnt.feature_id}`, - ); - - if (notNullish(entityId)) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } else { - balanceStr = `${ - getCusEntMasterBalance({ - cusEnt, - entities: cusEnt.customer_product?.entities, - }).balance - } [Master]`; - } - } - try { - if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { - balanceStr = "Unlimited"; - } - } catch (error) { - balanceStr = "failed_to_get_balance"; - } - - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product ? cusEnt.customer_product.product_id : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`), - ); -}; - -export const performDeductionOnCusEnt = ({ - cusEnt, - toDeduct, - entityId, - allowNegativeBalance = false, - addAdjustment = false, - setZeroAdjustment = false, - blockUsageLimit = true, - field = "balance", -}: { - cusEnt: FullCusEntWithFullCusProduct; - toDeduct: number; - entityId?: string | null; - allowNegativeBalance?: boolean; - addAdjustment?: boolean; - setZeroAdjustment?: boolean; - blockUsageLimit?: boolean; - field?: "balance" | "additional_balance"; -}): { - newBalance: number; - newEntities: Record | undefined; - deducted: number; - toDeduct: number; - newAdjustment?: number; -} => { - let newEntities: Record | undefined = - structuredClone(cusEnt.entities) ?? undefined; - - let newBalance: number = structuredClone(cusEnt[field]) ?? 0; - let deducted = 0; - - // To deprecate: adjustment. - let newAdjustment = structuredClone(cusEnt.adjustment); - - const cusProduct = cusEnt.customer_product; - - // 2. Get options, related price and starting balance! - const options = notNullish(cusProduct) - ? getEntOptions(cusProduct.options, cusEnt.entitlement) - : undefined; - - const cusPrice = notNullish(cusProduct) - ? getRelatedCusPrice(cusEnt, cusProduct.customer_prices) - : undefined; - - const resetBalance = notNullish(cusProduct) - ? getStartingBalance({ - options: options || undefined, - relatedPrice: cusPrice?.price, - entitlement: cusEnt.entitlement, - }) - : cusEnt.entitlement.allowance || 0; - - if (entityFeatureIdExists({ cusEnt })) { - // CASE 1: Deduct from entity balances - - if (nullish(entityId)) { - newEntities = structuredClone(cusEnt.entities) as Record< - string, - EntityBalance - >; - if (!newEntities) newEntities = {}; - - let toDeductCursor = toDeduct; - for (const entityId in cusEnt.entities) { - if (toDeductCursor === 0) break; - - const entityBalance = cusEnt.entities[entityId][field]; - - const { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(entityBalance ?? 0), - toDeduct: toDeductCursor, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); - - newEntities[entityId][field] = newEntityBalance!; - - if (addAdjustment) { - const adjustment = newEntities[entityId].adjustment || 0; - newEntities[entityId].adjustment = adjustment - newDeducted!; - } - - if (setZeroAdjustment) { - newEntities[entityId].adjustment = 0; - } - - toDeductCursor = newToDeduct; - deducted += newDeducted; - } - - toDeduct = toDeductCursor; - } - - // CASE 2: Deduct from entity balance - else { - if (!newEntities) newEntities = {}; - - const currentEntityBalance = cusEnt.entities?.[entityId]?.[field]; - - const { - newBalance: newEntityBalance, - deducted: newDeducted, - toDeduct: newToDeduct, - } = performDeduction({ - cusEntBalance: new Decimal(currentEntityBalance!), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); - - newEntities[entityId][field] = newEntityBalance!; - - if (addAdjustment) { - const adjustment = newEntities[entityId].adjustment || 0; - newEntities[entityId].adjustment = adjustment - newDeducted!; - } - - if (setZeroAdjustment) { - newEntities[entityId].adjustment = 0; - } - - toDeduct = newToDeduct; - deducted += newDeducted; - } - } - - // CASE 3: Deduct from balance - else { - const currentBalance = cusEnt[field] || 0; - - const { - newBalance: newBalance_, - deducted: deducted_, - toDeduct: newToDeduct_, - } = performDeduction({ - cusEntBalance: new Decimal(currentBalance), - toDeduct, - allowNegativeBalance, - ent: cusEnt.entitlement, - resetBalance, - blockUsageLimit, - }); - - newBalance = newBalance_; - deducted = deducted_; - toDeduct = newToDeduct_; - - if (addAdjustment) { - const adjustment = cusEnt.adjustment || 0; - newAdjustment = adjustment - deducted!; - } - } - - return { - newBalance, - newEntities, - deducted, - toDeduct, - newAdjustment: newAdjustment ?? undefined, - }; -}; - -export const deductAllowanceFromCusEnt = async ({ - toDeduct, - deductParams, - cusEnt, - featureDeductions, - willDeductCredits = false, - setZeroAdjustment = false, -}: { - toDeduct: number; - deductParams: DeductParams; - cusEnt: FullCusEntWithFullCusProduct; - featureDeductions: any; - willDeductCredits?: boolean; - setZeroAdjustment?: boolean; -}) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - - if ( - entity && - entityFeatureIdExists({ cusEnt }) && - cusEnt.entitlement.entity_feature_id !== entity.feature_id - ) - return toDeduct; - - const { - newBalance, - newEntities, - deducted, - toDeduct: newToDeduct, - } = performDeductionOnCusEnt({ - cusEnt, - toDeduct, - entityId: entity?.id, - allowNegativeBalance: false, - setZeroAdjustment, - }); - - const originalGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: cusEnt.balance!, - entities: cusEnt.entities!, - }); - - const newGrpBalance = getTotalNegativeBalance({ - cusEnt, - balance: newBalance!, - entities: newEntities!, - }); - - const updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } - - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - org, - cusPrices: cusPrices as any, - customer, - affectedFeature: feature, - cusEnt: cusEnt as any, - originalBalance: originalGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); - - if (newReplaceables && newReplaceables.length > 0) { - updates.balance = newBalance! - newReplaceables.length; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - updates.balance = newBalance! + deletedReplaceables.length; - } - - await CusEntService.update({ - db, - id: cusEnt.id, - updates, - }); - - // Deduct credit amounts too - if (feature.type === FeatureType.Metered && willDeductCredits) { - for (let i = 0; i < featureDeductions.length; i++) { - const { feature: creditSystem, deduction } = featureDeductions[i]; - - if ( - creditSystem.type === FeatureType.CreditSystem && - creditSystemContainsFeature({ - creditSystem: creditSystem, - meteredFeatureId: feature.id!, - }) - ) { - // toDeduct -= deduction; - const creditAmount = featureToCreditSystem({ - featureId: feature.id!, - creditSystem: creditSystem, - amount: deducted, - }); - const newDeduction = new Decimal(deduction) - .minus(creditAmount) - .toNumber(); - - featureDeductions[i].deduction = newDeduction; - } - } - } - - cusEnt.balance = newBalance; - cusEnt.entities = newEntities; - - return newToDeduct; -}; - -export const deductFromUsageBasedCusEnt = async ({ - toDeduct, - deductParams, - cusEnts, - setZeroAdjustment = false, -}: { - toDeduct: number; - deductParams: DeductParams; - cusEnts: FullCusEntWithFullCusProduct[]; - setZeroAdjustment?: boolean; -}) => { - const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - - // Deduct from usage-based price - let usageBasedEnt = findCusEnt({ - cusEnts, - feature, - entity, - onlyUsageAllowed: true, - }) as FullCusEntWithFullCusProduct; - - console.log( - "Cus ents:", - cusEnts.map( - (ce) => - `Feature: ${ce.entitlement.feature_id}, Balance: ${ce.balance}, Usage Allowed: ${ce.usage_allowed}`, - ), - ); - - if ( - !usageBasedEnt && - feature.config?.usage_type === FeatureUsageType.Continuous - ) { - console.log(`FALLING BACK TO REGULAR CUS ENT, FEATURE: ${feature.id}`); - usageBasedEnt = findCusEnt({ - cusEnts, - feature, - entity, - }) as FullCusEntWithFullCusProduct; // fallback to regular cus ent if allowed... - } - - if (!usageBasedEnt) { - console.log( - ` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found`, - ); - return; - } - - const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); - const billingType = cusPrice?.price - ? getBillingType(cusPrice?.price.config ?? undefined) - : undefined; - const blockUsageLimit = - billingType === BillingType.InArrearProrated ? false : true; - - const { newBalance, newEntities, deducted } = performDeductionOnCusEnt({ - cusEnt: usageBasedEnt, - toDeduct, - allowNegativeBalance: true, - setZeroAdjustment, - entityId: entity?.id, - blockUsageLimit, - }); - - const oldGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: usageBasedEnt.balance!, - entities: usageBasedEnt.entities!, - }); - - const newGrpBalance = getTotalNegativeBalance({ - cusEnt: usageBasedEnt, - balance: newBalance!, - entities: newEntities!, - }); - - // Update usageBasedEnt in place with the deduction results - usageBasedEnt.balance = newBalance; - usageBasedEnt.entities = newEntities; - if (setZeroAdjustment) { - usageBasedEnt.adjustment = 0; - } - - const updates: any = { - balance: newBalance, - entities: newEntities, - }; - if (setZeroAdjustment) { - updates.adjustment = 0; - } - - const { newReplaceables, deletedReplaceables } = await adjustAllowance({ - db, - env, - affectedFeature: feature, - org, - cusEnt: usageBasedEnt as any, - cusPrices: cusPrices as any, - customer, - originalBalance: oldGrpBalance, - newBalance: newGrpBalance, - logger: console, - }); - - if (newReplaceables && newReplaceables.length > 0) { - const finalBalance = newBalance! - newReplaceables.length; - updates.balance = finalBalance; - usageBasedEnt.balance = finalBalance; - } else if (deletedReplaceables && deletedReplaceables.length > 0) { - const finalBalance = newBalance! + deletedReplaceables.length; - updates.balance = finalBalance; - usageBasedEnt.balance = finalBalance; - } - - await CusEntService.update({ - db, - id: usageBasedEnt!.id, - updates, - }); - - console.log("Usage based cus ent balance", usageBasedEnt.balance); -}; - -// Main function to update customer balance -export const updateCustomerBalance = async ({ - db, - customerId, - entityId, - event, - features, - org, - env, - logger, - allFeatures, -}: { - db: DrizzleCli; - customerId: string; - entityId: string; - event: Event; - features: Feature[]; - org: Organization; - env: AppEnv; - logger: any; - allFeatures: Feature[]; -}) => { - const startTime = performance.now(); - console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order); - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - withSubs: true, - }); - - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config.reverse_deduction_order, - }); - - const endTime = performance.now(); - - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - event, - features, - }); - - logBalanceUpdate({ - timeTaken: (endTime - startTime).toFixed(2), - customer, - features, - cusEnts, - featureDeductions, - properties: event.properties, - org, - entityId: event.entity_id, - }); - - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } - - // 4. Perform deductions and update customer balance - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; - - const originalCusEnts = structuredClone(cusEnts); - - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) { - continue; - } - - toDeduct = await deductFromApiCusRollovers({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - entity: customer.entity ? customer.entity : undefined, - }, - }); - - if (toDeduct === 0) continue; - - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - }); - } - - if (toDeduct !== 0) { - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - entity: customer.entity, - }, - }); - } - } - - return cusEnts; -}; - -// MAIN FUNCTION -export const runUpdateBalanceTask = async ({ - payload, - logger, - db, -}: { - payload: any; - logger: any; - db: DrizzleCli; -}) => { - try { - // 1. Update customer balance - const { customerId, features, event, org, env, entityId, allFeatures } = - payload; - - console.log("--------------------------------"); - console.log( - `UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}`, - ); - - const cusEnts: any = await updateCustomerBalance({ - db, - customerId, - features, - event, - org, - env, - logger, - entityId, - allFeatures, - }); - - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); - } catch (error) { - if (logger) { - logger.use((log: any) => { - return { - ...log, - data: payload, - }; - }); - - logger.error(`ERROR UPDATING BALANCE`); - logger.error(error); - } else { - console.log(error); - } - } -}; diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts deleted file mode 100644 index 3a61ab378..000000000 --- a/server/src/trigger/updateUsageTask.ts +++ /dev/null @@ -1,602 +0,0 @@ -import { - AllowanceType, - type AppEnv, - CusProductStatus, - type Customer, - cusEntToIncludedUsage, - customerEntitlements, - customers, - ErrCode, - type Feature, - FeatureType, - FeatureUsageType, - type FullCusEntWithFullCusProduct, - type FullCustomerEntitlement, - type Organization, - sumValues, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { sql } from "drizzle-orm"; -import { StatusCodes } from "http-status-codes"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; -import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js"; -import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; - -import { - deductAllowanceFromCusEnt, - deductFromUsageBasedCusEnt, -} from "./updateBalanceTask.js"; - -// 2. Get deductions for each feature -const getFeatureDeductions = ({ - cusEnts, - value, - features, - shouldSet, - entityId, -}: { - cusEnts: FullCustomerEntitlement[]; - value: number; - features: Feature[]; - shouldSet: boolean; - entityId?: string; -}) => { - const meteredFeature = - features.find((f) => f.type === FeatureType.Metered) || features[0]; - - const featureDeductions = []; - for (const feature of features) { - let newValue = value; - const unlimitedExists = cusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance_type === AllowanceType.Unlimited && - cusEnt.entitlement.internal_feature_id === feature.internal_id, - ); - - if (unlimitedExists) { - continue; - } - - if (feature.type === FeatureType.CreditSystem) { - newValue = featureToCreditSystem({ - featureId: meteredFeature.id, - creditSystem: feature, - amount: value, - }); - } - - // If it's set - let deduction = newValue; - - if (shouldSet) { - // const totalAllowance = cusEnts.reduce((acc, curr) => { - // return acc + (curr.entitlement.allowance || 0); - // }, 0); - const totalIncludedUsage = sumValues( - cusEnts.map((cusEnt) => { - return cusEntToIncludedUsage({ - cusEnt: cusEnt as FullCusEntWithFullCusProduct, - entityId: entityId, - }); - }), - ); - - const targetBalance = new Decimal(totalIncludedUsage) - .sub(value) - .toNumber(); - - const totalBalance = getFeatureBalance({ - cusEnts, - internalFeatureId: feature.internal_id!, - entityId, - })!; - - deduction = new Decimal(totalBalance).sub(targetBalance).toNumber(); - } - - if (deduction === 0) { - console.log(` - Skipping feature ${feature.id} -- deduction is 0`); - continue; - } - - featureDeductions.push({ - feature, - deduction, - }); - } - - featureDeductions.sort((a, b) => { - if ( - a.feature.type === FeatureType.CreditSystem && - b.feature.type !== FeatureType.CreditSystem - ) { - return 1; - } - - if ( - a.feature.type !== FeatureType.CreditSystem && - b.feature.type === FeatureType.CreditSystem - ) { - return -1; - } - - return a.feature.id.localeCompare(b.feature.id); - }); - - return featureDeductions; -}; - -/** - * Calculate total available rollover balance for a feature - */ -const calculateAvailableRolloverBalance = ({ - cusEnts, - feature, - entityId, -}: { - cusEnts: FullCustomerEntitlement[]; - feature: Feature; - entityId?: string; -}) => { - const featureCusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.internal_feature_id === feature.internal_id, - ); - - if (!entityId) { - // Non-entity: sum rollover.balance - return featureCusEnts.reduce((sum, cusEnt) => { - const rolloverSum = cusEnt.rollovers.reduce( - (rSum, rollover) => - new Decimal(rSum).add(rollover.balance || 0).toNumber(), - 0, - ); - return new Decimal(sum).add(rolloverSum).toNumber(); - }, 0); - } else { - // Entity: sum rollover.entities[entityId].balance - return featureCusEnts.reduce((sum, cusEnt) => { - const rolloverSum = cusEnt.rollovers.reduce((rSum, rollover) => { - const entityRollover = rollover.entities?.[entityId]; - if (entityRollover) { - return new Decimal(rSum).add(entityRollover.balance || 0).toNumber(); - } - return rSum; - }, 0); - return new Decimal(sum).add(rolloverSum).toNumber(); - }, 0); - } -}; - -/** - * Validate that the deduction is possible given the current balance and usage allowed. - * Constraint 1: Insufficient balance without usage_allowed. - * Constraint 2: Usage limit exceeded for customer entitlements with usage_allowed. - */ -const validateDeductionPossible = ({ - cusEnts, - featureDeductions, - entityId, -}: { - cusEnts: FullCustomerEntitlement[]; - featureDeductions: { feature: Feature; deduction: number }[]; - entityId?: string; -}) => { - for (const { feature, deduction } of featureDeductions) { - const featureCusEnts = cusEnts.filter( - (customerEntitlement) => - customerEntitlement.entitlement.internal_feature_id === - feature.internal_id, - ); - - // CONSTRAINT 1: Insufficient balance without usage_allowed - const cusEntBalance = getFeatureBalance({ - cusEnts: featureCusEnts, - internalFeatureId: feature.internal_id!, - entityId, - }); - - // If unlimited, skip validation - if (cusEntBalance === null) { - continue; - } - const rolloverBalance = calculateAvailableRolloverBalance({ - cusEnts, - feature, - entityId, - }); - const totalBalance = new Decimal(cusEntBalance) - .add(rolloverBalance) - .toNumber(); - - const hasUsageAllowed = featureCusEnts.some( - (customerEntitlement) => customerEntitlement.usage_allowed, - ); - - // Check if this is a "free" feature (single-use with included_usage but no pricing) - // Only apply to SingleUse features; ContinuousUse (allocated) features should reject - const isFreeFeature = - feature.type === FeatureType.Metered && - feature.config?.usage_type === FeatureUsageType.Single && - featureCusEnts.some( - (cusEnt) => - cusEnt.entitlement.allowance && cusEnt.entitlement.allowance > 0, - ) && - !hasUsageAllowed; - - // For free SingleUse features, allow tracking beyond balance (will cap at 0 in performDeduction) - // For prepaid/allocated/other features without usage_allowed, reject insufficient balance - if (totalBalance < deduction && !hasUsageAllowed && !isFreeFeature) { - throw new RecaseError({ - message: `Insufficient balance for feature ${feature.id}. Available: ${totalBalance} (${cusEntBalance} + ${rolloverBalance} rollover), Required: ${deduction}`, - code: ErrCode.InsufficientBalance, - statusCode: StatusCodes.BAD_REQUEST, - data: { - feature_id: feature.id, - available: totalBalance, - cus_ent_balance: cusEntBalance, - rollover_balance: rolloverBalance, - required: deduction, - }, - }); - } - - // CONSTRAINT 2: Usage limit exceeded for customer entitlements with usage_allowed - const entitlementDeduction = - new Decimal(deduction).sub(rolloverBalance).toNumber() > 0 - ? new Decimal(deduction).sub(rolloverBalance).toNumber() - : 0; - - if (entitlementDeduction > 0) { - const featureCusEntsWithUsageAllowed = featureCusEnts.filter( - (customerEntitlement) => customerEntitlement.usage_allowed, - ); - - const totalRemainingLimit = featureCusEntsWithUsageAllowed.reduce( - (sum, cusEnt) => { - const usageLimit = cusEnt.entitlement.usage_limit; - if (!usageLimit) { - return sum; - } - - const featureBalance = getFeatureBalance({ - cusEnts: [cusEnt], - internalFeatureId: feature.internal_id!, - entityId, - }); - - // Skip if unlimited - if (featureBalance === null) { - return sum; - } - - const allowance = new Decimal(cusEnt.entitlement.allowance || 0); - const currentBalance = new Decimal(featureBalance); - const currentUsed = allowance.sub(currentBalance); - const remainingLimit = new Decimal(usageLimit).sub(currentUsed); - - return new Decimal(sum) - .add(Decimal.max(0, remainingLimit)) - .toNumber(); - }, - 0, - ); - - if ( - featureCusEntsWithUsageAllowed.length > 0 && - entitlementDeduction > totalRemainingLimit - ) { - throw new RecaseError({ - message: `Usage limit exceeded for feature ${feature.id}. Total remaining capacity: ${totalRemainingLimit}, Requested from entitlement: ${entitlementDeduction} (${rolloverBalance} covered by rollovers)`, - code: ErrCode.InsufficientBalance, - statusCode: StatusCodes.BAD_REQUEST, - data: { - feature_id: feature.id, - total_remaining_capacity: totalRemainingLimit, - requested_from_entitlement: entitlementDeduction, - covered_by_rollovers: rolloverBalance, - total_requested: deduction, - }, - }); - } - } - } -}; - -const logUsageUpdate = ({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, -}: { - customer: Customer; - features: Feature[]; - cusEnts: FullCustomerEntitlement[]; - featureDeductions: any; - org: Organization; - setUsage: boolean; - entityId?: string; -}) => { - console.log( - ` - Customer: ${customer.id} (${customer.env}) | Org: ${ - org.slug - } | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${ - setUsage ? "true" : "false" - }`, - ); - - console.log( - " - CusEnts:", - cusEnts.map((cusEnt: any) => { - let balanceStr = cusEnt.balance; - try { - if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) { - balanceStr = "Unlimited"; - } - } catch (_error) { - balanceStr = "failed_to_get_balance"; - } - - if (entityId && cusEnt.entities) { - balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`; - } - - return `${cusEnt.feature_id} - ${balanceStr} (${ - cusEnt.customer_product ? cusEnt.customer_product.product_id : "" - })`; - }), - "| Deductions:", - featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`), - ); -}; - -// Main function to update customer balance -export const updateUsage = async ({ - db, - customerId, - features, - org, - env, - value, - properties, - setUsage, - logger, - entityId, - allFeatures, -}: { - db: DrizzleCli; - customerId: string; - features: Feature[]; - org: Organization; - env: AppEnv; - value: number; - properties: any; - setUsage: boolean; - logger: any; - entityId?: string; - allFeatures: Feature[]; -}) => { - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId, - withSubs: true, - }); - - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: features.map((f) => f.internal_id!), - logger, - reverseOrder: org.config?.reverse_deduction_order, - }); - - // 1. Get deductions for each feature - const featureDeductions = getFeatureDeductions({ - cusEnts, - value, - shouldSet: setUsage, - features, - entityId, - }); - - logUsageUpdate({ - customer, - features, - cusEnts, - featureDeductions, - org, - setUsage, - entityId, - }); - - // 3. Return if no customer entitlements or features found - if (cusEnts.length === 0 || features.length === 0) { - console.log(" - No customer entitlements or features found"); - return; - } - - // validateDeductionPossible({ cusEnts, featureDeductions, entityId }); - - const originalCusEnts = structuredClone(cusEnts); - for (const obj of featureDeductions) { - let { feature, deduction: toDeduct } = obj; - - const performFeatureDeduction = async () => { - // 1. Deduct from rollovers - for (const cusEnt of cusEnts) { - if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) { - continue; - } - - toDeduct = await deductFromApiCusRollovers({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - entity: customer.entity ? customer.entity : undefined, - }, - }); - } - - if (toDeduct === 0) return; - - // 3. Deduct from allowance - for (const cusEnt of cusEnts) { - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - cusEnt, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - entity: customer.entity, - }, - featureDeductions, - willDeductCredits: true, - setZeroAdjustment: true, - }); - } - - if (toDeduct === 0) return; - - // 4. Deduct from usage-based entitlement - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature, - env, - org, - cusPrices: cusPrices as any[], - customer, - entity: customer.entity, - }, - setZeroAdjustment: true, - }); - }; - - await performFeatureDeduction(); - } - - return cusEnts; -}; - -// MAIN FUNCTION -export const runUpdateUsageTask = async ({ - payload, - logger, - db, - throwError = false, -}: { - payload: any; - logger: any; - db: DrizzleCli; - throwError?: boolean; -}) => { - try { - // 1. Update customer balance - const { - internalCustomerId, - customerId, - eventId, - features, - value, - set_usage, - properties, - org, - env, - entityId, - allFeatures, - } = payload; - - console.log("--------------------------------"); - console.log( - `HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}, EVENT ID: ${eventId}`, - ); - - const cusEnts = await db.transaction( - async (tx) => { - // Lock ALL customer entitlements for this customer using JOIN - await tx.execute(sql` - SELECT ce.* - FROM ${customerEntitlements} ce - INNER JOIN ${customers} c ON ce.internal_customer_id = c.internal_id - WHERE c.id = ${customerId} - AND c.org_id = ${org.id} - AND c.env = ${env} - FOR UPDATE OF ce - `); - // Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests - // Include entity_id in lock key so different entities can update concurrently - // const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`; - // const hash = - // lockKeyStr.split("").reduce((acc, char) => { - // return (acc << 5) - acc + char.charCodeAt(0); - // }, 0) | 0; // Convert to 32-bit integer - - // console.log( - // ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`, - // ); - // await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); - // console.log( - // ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`, - // ); - - return await updateUsage({ - db: tx as unknown as DrizzleCli, - customerId, - features, - value, - properties, - org, - env, - setUsage: set_usage, - logger, - entityId, - allFeatures, - }); - }, - { - isolationLevel: "read committed", - }, - ); - - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Usage updated"); - } catch (error) { - if (logger) { - logger.use((log: any) => { - return { - ...log, - data: payload, - }; - }); - - logger.error(`ERROR UPDATING USAGE`); - logger.error(error); - } else { - console.log(error); - } - - if (throwError) { - throw error; - } - } -}; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts deleted file mode 100644 index 3e977d275..000000000 --- a/server/tests/_temp/temp1.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - EntInterval, - ErrCode, - type FullCustomerEntitlement, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { constructFeatureItem } from "../../src/utils/scriptUtils/constructItem.js"; -import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js"; - -const free = constructProduct({ - type: "free", - isDefault: false, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 5, - }), - ], -}); - -const pro = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 300, - }), - ], -}); - -export const premium = constructProduct({ - type: "premium", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], -}); - -describe(`${chalk.yellowBright("temp1: Testing balances.create endpoint")}`, () => { - const customerId = `temp1-${Math.random().toString(36).substring(2, 15)}`; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [free, pro, premium], - prefix: customerId, - }); - }); - - test("should create balance with granted_balance", async () => { - const grantedBalance = "500"; - - await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Messages, - granted_balance: grantedBalance, - }); - - const { balances: rawBalances } = await autumn.balances.list({ - customer_id: customerId, - }); - - expect(rawBalances).toBeDefined(); - expect(rawBalances.length).toBeGreaterThan(0); - const createdBalance = rawBalances.find( - (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Messages, - ); - expect(createdBalance).toBeDefined(); - expect(createdBalance.balance).toBe(500); - expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Messages); - }); - - test("should create unlimited balance", async () => { - await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Users, - unlimited: true, - }); - - const { balances: rawBalances } = await autumn.balances.list({ - customer_id: customerId, - }); - - const createdBalance = rawBalances.find( - (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Users, - ); - expect(createdBalance).toBeDefined(); - expect(createdBalance.unlimited).toBe(true); - expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Users); - }); - - test("should create balance with reset interval", async () => { - // Use Action1 which is a single-use feature that can have monthly reset - await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Action1, - granted_balance: "1000", - reset: { - interval: EntInterval.Month, - interval_count: 1, - }, - }); - - const { balances: rawBalances } = await autumn.balances.list({ - customer_id: customerId, - }); - - const createdBalance = rawBalances.find( - (b: FullCustomerEntitlement) => b.feature_id === TestFeature.Action1, - ); - expect(createdBalance).toBeDefined(); - expect(createdBalance.balance).toBe(1000); - expect(createdBalance.entitlement.interval).toBe(EntInterval.Month); - expect(createdBalance.entitlement.feature.id).toBe(TestFeature.Action1); - }); - - test("should throw error if entitlement already exists", async () => { - // Create balance first - await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Dashboard, - }); - - // Try to create again - should fail - await expectAutumnError({ - errCode: ErrCode.InvalidRequest, - func: async () => { - return await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Dashboard, - }); - }, - }); - }); - - test("should throw error if feature not found", async () => { - await expectAutumnError({ - errCode: ErrCode.FeatureNotFound, - func: async () => { - await autumn.balances.create({ - customer_id: customerId, - feature_id: "non-existent-feature", - granted_balance: "100", - }); - }, - }); - }); - - test("should throw error if customer not found", async () => { - await expectAutumnError({ - errCode: ErrCode.CustomerNotFound, - func: async () => { - await autumn.balances.create({ - customer_id: "non-existent-customer", - feature_id: TestFeature.Messages, - granted_balance: "100", - }); - }, - }); - }); -}); diff --git a/server/tests/_temp/temp2.test.ts b/server/tests/_temp/temp2.test.ts deleted file mode 100644 index b4aeff164..000000000 --- a/server/tests/_temp/temp2.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const pro = constructProduct({ - type: "pro", - items: [ - constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 5, - price: 0.1, - billingUnits: 1, - }), - ], -}); - -describe(`${chalk.yellowBright("temp2: Testing pay-per-use with raw balance")}`, () => { - const customerId = "temp2"; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [pro], - prefix: customerId, - }); - }); - - test("should attach product and create raw balance", async () => { - // Attach product with pay-per-use feature (5 messages included) - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - // Create a raw balance of 5 messages - await autumn.balances.create({ - customer_id: customerId, - feature_id: TestFeature.Messages, - granted_balance: "5", - }); - }); -}); diff --git a/server/tests/_temp/temp3.test.ts b/server/tests/_temp/temp3.test.ts deleted file mode 100644 index b4c5d163e..000000000 --- a/server/tests/_temp/temp3.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const testCase = "concurrent-track7"; - -// Product with both lifetime and monthly Messages features -const lifetimeMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 20000, - interval: null, // Lifetime -}) as LimitedItem; - -const monthlyMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10000, - interval: "month" as any, - intervalCount: 1, -}) as LimitedItem; - -const pro = constructProduct({ - type: "free", - isDefault: false, - items: [lifetimeMessagesItem, monthlyMessagesItem], -}); - -const NUM_REQUESTS = 500; // Reduced from 10000 to avoid DB parameter limits -const NUM_CUSTOMERS = 3; - -// Calculate total included usage dynamically -const TOTAL_INCLUDED_USAGE = - (lifetimeMessagesItem.included_usage ?? 0) + - (monthlyMessagesItem.included_usage ?? 0); - -// Helper to generate random decimal between min and max using Decimal.js -const randomDecimal = (min: number, max: number): Decimal => { - const value = Math.random() * (max - min) + min; - return new Decimal(value).toDecimalPlaces(2); -}; - -describe(`${chalk.yellowBright(`${testCase}: Stress test with 10k concurrent requests per customer through check (send_event)`)}`, () => { - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - const customerIds = Array.from( - { length: NUM_CUSTOMERS }, - (_, i) => `${testCase}_customer${i + 1}`, - ); - - // Store expected total usage per customer using Decimal for precision - const customerExpectedUsage: Record = {}; - - beforeAll(async () => { - // Initialize all customers - for (const customerId of customerIds) { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - } - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - for (const customerId of customerIds) { - await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - // Initialize expected usage to 0 - customerExpectedUsage[customerId] = new Decimal(0); - } - }); - - test("should have initial balances for all customers", async () => { - for (const customerId of customerIds) { - const customer = await autumnV1.customers.get(customerId); - - console.log(`\n🔍 Initial state for ${customerId}:`); - console.log( - ` Balance: ${customer.features[TestFeature.Messages].balance}`, - ); - console.log(` Usage: ${customer.features[TestFeature.Messages].usage}`); - - // Total balance should be lifetime + monthly - expect(customer.features[TestFeature.Messages].balance).toBe( - TOTAL_INCLUDED_USAGE, - ); - expect(customer.features[TestFeature.Messages].usage).toBe(0); - expect(customer.features[TestFeature.Messages].breakdown?.length).toBe(2); - } - }); - - test(`should handle ${NUM_REQUESTS * NUM_CUSTOMERS} concurrent requests across ${NUM_CUSTOMERS} customers`, async () => { - console.log( - `\n🚀 Starting ${NUM_REQUESTS * NUM_CUSTOMERS} concurrent track requests...`, - ); - console.log( - ` ${NUM_REQUESTS} requests per customer × ${NUM_CUSTOMERS} customers`, - ); - - const allPromises: Promise[] = []; - - // Generate requests for each customer - for (const customerId of customerIds) { - const customerPromises: Promise[] = []; - - for (let i = 0; i < NUM_REQUESTS; i++) { - // Generate random value between 0.01 and 2.00 using Decimal - const decimalValue = randomDecimal(0.01, 2.0); - const value = decimalValue.toDecimalPlaces(5).toNumber(); - - // Accumulate expected usage using Decimal for precision - customerExpectedUsage[customerId] = - customerExpectedUsage[customerId].plus(decimalValue); - - // Create track request for Messages feature with timing - const requestStart = Date.now(); - const promise = autumnV1 - .check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - send_event: true, - required_balance: value, - skip_event: true, // Skip event insertion for stress test - }) - .then(() => Date.now() - requestStart); - - customerPromises.push(promise); - } - - allPromises.push(...customerPromises); - } - - // Execute all requests concurrently - const startTime = Date.now(); - const durations = await Promise.all(allPromises); - const endTime = Date.now(); - - // Calculate P99 - const sortedDurations = durations.sort((a, b) => a - b); - const p99Index = Math.floor(sortedDurations.length * 0.99); - const p99 = sortedDurations[p99Index]; - - console.log( - `\n✅ Completed ${NUM_REQUESTS * NUM_CUSTOMERS} requests in ${endTime - startTime}ms`, - ); - console.log( - ` Average: ${((endTime - startTime) / (NUM_REQUESTS * NUM_CUSTOMERS)).toFixed(2)}ms per request`, - ); - console.log(` P99: ${p99.toFixed(2)}ms`); - - // Log expected totals per customer - for (const customerId of customerIds) { - console.log(`\n📊 ${customerId}:`); - console.log( - ` Total usage: ${customerExpectedUsage[customerId].toFixed(2)} units`, - ); - } - }); - - test("should have correct cached balances for all customers", async () => { - for (const customerId of customerIds) { - const customer = await autumnV1.customers.get(customerId); - - const totalUsage = customerExpectedUsage[customerId]; - - // Balance should be capped at 0 (no negative balances without overage_allowed) - const expectedBalance = Decimal.max( - 0, - new Decimal(TOTAL_INCLUDED_USAGE).minus(totalUsage), - ) - .toDP(5) - .toNumber(); - const actualBalance = new Decimal( - customer.features[TestFeature.Messages].balance ?? 0, - ) - .toDP(5) - .toNumber(); - - // Usage should be capped at included_usage without overage_allowed - const expectedUsage = Decimal.min(totalUsage, TOTAL_INCLUDED_USAGE) - .toDP(5) - .toNumber(); - const actualUsage = new Decimal( - customer.features[TestFeature.Messages].usage ?? 0, - ) - .toDP(5) - .toNumber(); - - // Verify balance and usage match expectations - expect(actualBalance).toEqual(expectedBalance); - expect(actualUsage).toEqual(expectedUsage); - - // Verify breakdown balances sum to top-level balance - const breakdown = customer.features[TestFeature.Messages].breakdown; - if (breakdown && breakdown.length > 0) { - const breakdownBalance = breakdown.reduce( - (sum, b) => new Decimal(sum).plus(b.balance || 0).toNumber(), - 0, - ); - expect(new Decimal(breakdownBalance).toDP(5).toNumber()).toEqual( - actualBalance!, - ); - } - } - }); - - test("should have correct non-cached balances for all customers after 2s", async () => { - console.log("\n⏳ Waiting 2s for DB sync..."); - await timeout(5000); - - for (const customerId of customerIds) { - const customer = await autumnV1.customers.get(customerId, { - skip_cache: "true", - }); - - const totalUsage = customerExpectedUsage[customerId]; - - // Balance should be capped at 0 (no negative balances without overage_allowed) - const expectedBalance = Decimal.max( - 0, - new Decimal(TOTAL_INCLUDED_USAGE).minus(totalUsage), - ) - .toDP(5) - .toNumber(); - const actualBalance = new Decimal( - customer.features[TestFeature.Messages].balance ?? 0, - ) - .toDP(5) - .toNumber(); - - // Usage should be capped at included_usage without overage_allowed - const expectedUsage = Decimal.min(totalUsage, TOTAL_INCLUDED_USAGE) - .toDP(5) - .toNumber(); - const actualUsage = new Decimal( - customer.features[TestFeature.Messages].usage ?? 0, - ) - .toDP(5) - .toNumber(); - - // Use Decimal for precise comparisons - expect exact match - expect(actualBalance).toEqual(expectedBalance); - - // Verify usage matches - expect exact match - expect(actualUsage).toEqual(expectedUsage); - - // Verify breakdown balances match top-level (lifetime + monthly) - const breakdown = customer.features[TestFeature.Messages].breakdown; - if (breakdown && breakdown.length > 0) { - const breakdownBalance = breakdown.reduce( - (sum, b) => new Decimal(sum).plus(b.balance || 0).toNumber(), - 0, - ); - - expect(new Decimal(breakdownBalance).toDP(5).toNumber()).toEqual( - actualBalance!, - ); - } - } - - console.log("\n✅ All balances verified successfully!"); - }); -}); diff --git a/server/tests/attach/entities/entity4.test.ts b/server/tests/attach/entities/entity4.test.ts index f2b00f170..910339f07 100644 --- a/server/tests/attach/entities/entity4.test.ts +++ b/server/tests/attach/entities/entity4.test.ts @@ -87,6 +87,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti entityId: entity2.id, numSubs: 2, }); + + // wait for webhooks to clear cache + await timeout(4000); }); const entity1Usage = Math.random() * 1000000; diff --git a/server/tests/balances/check/basic/check6.test.ts b/server/tests/balances/check/basic/check6.test.ts index c6384cc1a..0ae7952e3 100644 --- a/server/tests/balances/check/basic/check6.test.ts +++ b/server/tests/balances/check/basic/check6.test.ts @@ -73,6 +73,7 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal })) as unknown as CheckResponseV2; const expectedLifetimeBreadown: ApiBalanceBreakdown = { + id: expect.any(String), plan_id: proProd.id, granted_balance: 1000, purchased_balance: 0, @@ -85,6 +86,7 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal resets_at: null, }, prepaid_quantity: 0, + expires_at: null, }; const expectedMonthlyBreadown = { diff --git a/server/tests/balances/cron/loose-reset.test.ts b/server/tests/balances/cron/loose-reset.test.ts new file mode 100644 index 000000000..8e15b1fbd --- /dev/null +++ b/server/tests/balances/cron/loose-reset.test.ts @@ -0,0 +1,181 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type ApiCustomer, + ApiVersion, + customerEntitlements, + type ResetCusEnt, + ResetInterval, + sleepUntil, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { CusService } from "@/internal/customers/CusService"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { findCustomerEntitlement } from "../utils/findCustomerEntitlement"; + +describe(`${chalk.yellowBright("loose-reset: test getActiveResetPassed for loose entitlements")}`, () => { + const customerId = "loose-reset-test"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create a monthly loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + reset: { + interval: ResetInterval.Month, + }, + }); + }); + + test("getActiveResetPassed should fetch loose entitlement with past next_reset_at", async () => { + const looseCusEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(looseCusEnt).toBeDefined(); + + // 2. Update next_reset_at to be in the past + const pastTime = Date.now() - 1000; // 1 second ago + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: pastTime }) + .where(eq(customerEntitlements.id, looseCusEnt!.id)); + + // 3. Call getActiveResetPassed and verify it returns the row + const resetCusEnts = await CusEntService.getActiveResetPassed({ + db: ctx.db, + }); + + const foundCusEnt = resetCusEnts.find((ce) => ce.id === looseCusEnt!.id); + expect(foundCusEnt).toBeDefined(); + expect(foundCusEnt?.customer_product).toBeNull(); + expect(foundCusEnt?.customer.id).toBe(customerId); + }); + + test("resetCustomerEntitlement should reset loose entitlement balance", async () => { + // 1. Track 50 usage (leaving balance at 50) + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + expect(trackRes?.balance).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, + }); + + // Wait for sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const cusEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + fullCustomer, + }); + + const resetCusEnt: ResetCusEnt = { + ...cusEnt!, + customer_product: null, + customer: fullCustomer, + }; + + // 3. Call resetCustomerEntitlement + const updatedCusEnt = await resetCustomerEntitlement({ + db: ctx.db, + cusEnt: resetCusEnt, + updatedCusEnts: [], + }); + + if (updatedCusEnt) { + await CusEntService.upsert({ + db: ctx.db, + data: [updatedCusEnt], + }); + } + + // 4. Verify balance has reset to granted_balance (100) + const customer = (await autumnV2.customers.get(customerId, { + skip_cache: "true", + })) as unknown as ApiCustomer; + + expect(customer.balances[TestFeature.Messages].current_balance).toBe(100); + expect(customer.balances[TestFeature.Messages].usage).toBe(0); + }); +}); + +describe(`${chalk.yellowBright("loose-reset: expired entitlements should not be fetched")}`, () => { + const customerId = "loose-reset-expiry-test"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + }); + + test("getActiveResetPassed should NOT fetch expired loose entitlements", async () => { + // 1. Create a balance with expires_at 3 seconds from now + const expiresAt = Date.now() + 3000; + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + reset: { + interval: ResetInterval.Month, + }, + expires_at: expiresAt, + }); + + // Get the cusEnt and set next_reset_at to past (so it would be due for reset) + const cusEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEnt).toBeDefined(); + + const pastTime = Date.now() - 1000; + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: pastTime }) + .where(eq(customerEntitlements.id, cusEnt!.id)); + + // 2. Wait until past expiry + await sleepUntil(expiresAt + 1000); + + // 3. Call getActiveResetPassed - should NOT include the expired entitlement + const resetCusEnts = await CusEntService.getActiveResetPassed({ + db: ctx.db, + }); + + const foundCusEnt = resetCusEnts.find((ce) => ce.id === cusEnt!.id); + expect(foundCusEnt).toBeUndefined(); + }); +}); diff --git a/server/tests/balances/set-usage/set-usage1.test.ts b/server/tests/balances/set-usage/set-usage1.test.ts index 1077c3f5e..e41c27732 100644 --- a/server/tests/balances/set-usage/set-usage1.test.ts +++ b/server/tests/balances/set-usage/set-usage1.test.ts @@ -77,24 +77,30 @@ const simulateOneCycle = async ({ const customer = await autumn.customers.get(customerId); const prevBalance = customer.features[TestFeature.Users].balance!; - const prevUsage = includedUsage - prevBalance; + // const prevUsage = includedUsage - prevBalance; - const usageDiff = usageValue - prevUsage; + // const usageDiff = usageValue - prevUsage; - const value1 = Math.floor(usageDiff / 2); - const value2 = usageDiff - value1; + // const value1 = Math.floor(usageDiff / 2); + // const value2 = usageDiff - value1; - await autumn.track({ + await autumn.usage({ customer_id: customerId, feature_id: TestFeature.Users, - value: value1, + value: usageValue, }); - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: value2, - }); + // await autumn.track({ + // customer_id: customerId, + // feature_id: TestFeature.Users, + // value: value1, + // }); + + // await autumn.track({ + // customer_id: customerId, + // feature_id: TestFeature.Users, + // value: value2, + // }); const newBalance = includedUsage - usageValue; const prevOverage = Math.max(0, -prevBalance); diff --git a/server/tests/balances/track/loose/loose-expiry.test.ts b/server/tests/balances/track/loose/loose-expiry.test.ts index 455611a56..491ef59ad 100644 --- a/server/tests/balances/track/loose/loose-expiry.test.ts +++ b/server/tests/balances/track/loose/loose-expiry.test.ts @@ -54,9 +54,6 @@ describe(`${chalk.yellowBright("loose-expiry: track with expiring loose entitlem value: 10, }); - // Wait for sync - await new Promise((resolve) => setTimeout(resolve, 500)); - // Check balance - should have 90 remaining const res = (await autumnV2.check({ customer_id: customerId, diff --git a/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts b/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts index 98ecfb825..144c56fd3 100644 --- a/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts +++ b/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts @@ -5,7 +5,7 @@ // import chalk from "chalk"; // import { AutumnInt } from "@/external/autumn/autumnCli.js"; // import { currentRegion } from "@/external/redis/initRedis.js"; -// import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; + // import { CusService } from "@/internal/customers/CusService.js"; // import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; // import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts b/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts index a664e2cd5..c0945db45 100644 --- a/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts +++ b/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts @@ -5,7 +5,7 @@ // import chalk from "chalk"; // import { AutumnInt } from "@/external/autumn/autumnCli.js"; // import { currentRegion } from "@/external/redis/initRedis.js"; -// import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; + // import { CusService } from "@/internal/customers/CusService.js"; // import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; // import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/balances/utils/findCustomerEntitlement.ts b/server/tests/balances/utils/findCustomerEntitlement.ts new file mode 100644 index 000000000..9f65458f7 --- /dev/null +++ b/server/tests/balances/utils/findCustomerEntitlement.ts @@ -0,0 +1,35 @@ +import { + type FullCustomer, + type FullCustomerEntitlement, + fullCustomerToCustomerEntitlements, +} from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +export const findCustomerEntitlement = async ({ + ctx, + customerId, + fullCustomer, + featureId, +}: { + ctx: TestContext; + customerId: string; + fullCustomer?: FullCustomer; + featureId?: string; +}): Promise => { + fullCustomer = + fullCustomer || + (await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + })); + + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + }); + + return cusEnts?.[0]; +}; diff --git a/server/tests/balances/utils/fullCusEntToResetCusEnt.ts b/server/tests/balances/utils/fullCusEntToResetCusEnt.ts new file mode 100644 index 000000000..3495db862 --- /dev/null +++ b/server/tests/balances/utils/fullCusEntToResetCusEnt.ts @@ -0,0 +1,22 @@ +import type { + CusProduct, + Customer, + FullCustomerEntitlement, + ResetCusEnt, +} from "@autumn/shared"; + +export const fullCusEntToResetCusEnt = ({ + fullCusEnt, + customer, + customerProduct, +}: { + fullCusEnt: FullCustomerEntitlement; + customer: Customer; + customerProduct: CusProduct; +}): ResetCusEnt => { + return { + ...fullCusEnt, + customer, + customer_product: customerProduct, + }; +}; diff --git a/server/tests/balances/utils/getCustomerEntitlement.ts b/server/tests/balances/utils/getCustomerEntitlement.ts new file mode 100644 index 000000000..a6e460074 --- /dev/null +++ b/server/tests/balances/utils/getCustomerEntitlement.ts @@ -0,0 +1,27 @@ +import { fullCustomerToCustomerEntitlements } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +export const findCustomerEntitlement = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId?: string; +}) => { + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + }); + + return cusEnts; +}; diff --git a/server/tests/balances/utils/getCustomerEntitlements.ts b/server/tests/balances/utils/getCustomerEntitlements.ts new file mode 100644 index 000000000..73e9c6820 --- /dev/null +++ b/server/tests/balances/utils/getCustomerEntitlements.ts @@ -0,0 +1,27 @@ +import { fullCustomerToCustomerEntitlements } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +export const getCustomerEntitlement = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId?: string; +}) => { + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId, + }); + + return cusEnts; +}; diff --git a/server/tests/utils/expectUtils/expectAttach.ts b/server/tests/utils/expectUtils/expectAttach.ts index 00b32b5d3..ea6cd80cc 100644 --- a/server/tests/utils/expectUtils/expectAttach.ts +++ b/server/tests/utils/expectUtils/expectAttach.ts @@ -15,7 +15,7 @@ import { expectProductAttached, } from "@tests/utils/expectUtils/expectProductAttached.js"; import { getCurrentOptions } from "@tests/utils/testAttachUtils/testAttachUtils.js"; -import type { AttachParams, Customer } from "autumn-js"; +import type { AttachParams, Customer, CustomerInvoice } from "autumn-js"; import { Decimal } from "decimal.js"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -172,7 +172,7 @@ export const attachAndExpectCorrect = async ({ if (!skipInvoiceCheck && !freeProduct) { expectInvoicesCorrect({ - customer, + customer: customer as Customer & { invoices: CustomerInvoice[] }, first: { productId: product.id, total: new Decimal(checkoutRes.total).toDecimalPlaces(2).toNumber(), diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index 0da5cbdcc..25a04f316 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -8,7 +8,12 @@ import { type Entitlement, type ProductV2, } from "@autumn/shared"; -import type { Customer, ProductItem, ProductStatus } from "autumn-js"; +import type { + Customer, + CustomerInvoice, + ProductItem, + ProductStatus, +} from "autumn-js"; import { AutumnInt } from "../../../src/external/autumn/autumnCli"; export const expectProductAttached = ({ @@ -209,7 +214,7 @@ export const expectInvoicesCorrect = ({ first, // second, }: { - customer: Customer; + customer: Customer & { invoices: CustomerInvoice[] }; first: { productId: string; total: number; diff --git a/shared/api/_openapi2.0_/balancesOpenApi.ts b/shared/api/_openapi2.0_/balancesOpenApi.ts index ef454ceda..564518103 100644 --- a/shared/api/_openapi2.0_/balancesOpenApi.ts +++ b/shared/api/_openapi2.0_/balancesOpenApi.ts @@ -1,4 +1,4 @@ -import { CreateBalanceSchema } from "@api/balances/create/createBalanceParams.js"; +import { CreateBalanceParamsSchema } from "@api/balances/create/createBalanceParams.js"; import type { ZodOpenApiPathsObject } from "zod-openapi"; import { ExtBalancesUpdateParamsSchema } from "../balances/balancesUpdateModels.js"; import { SuccessResponseSchema } from "../common/commonResponses.js"; @@ -33,7 +33,7 @@ export const balancesOpenApi: ZodOpenApiPathsObject = { tags: ["balances"], requestBody: { content: { - "application/json": { schema: CreateBalanceSchema }, + "application/json": { schema: CreateBalanceParamsSchema }, }, }, responses: { diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index c153aff9d..19cfa9a5e 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -1,59 +1,59 @@ import { FeatureSchema, FeatureType, ResetInterval } from "@autumn/shared"; import { z } from "zod/v4"; -export const CreateBalanceSchema = z.object({ - feature_id: z.string(), - granted_balance: z.number().optional(), - unlimited: z.boolean().optional(), - reset: z - .object({ - interval: z.enum(ResetInterval), - interval_count: z.number().optional(), - }) - .optional(), - expires_at: z.number().optional(), // Unix timestamp in milliseconds - customer_id: z.string(), - entity_id: z.string().optional(), -}).refine((data) => { - if (data.entity_id && !data.customer_id) { - return false; - } else return true; -}); +export const CreateBalanceParamsSchema = z + .object({ + feature_id: z.string(), + customer_id: z.string(), + entity_id: z.string().optional(), -export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ - feature: FeatureSchema, -}).refine((data) => { - if (!data.feature) { - return false; - } + granted_balance: z.number().optional(), + unlimited: z.boolean().optional(), + reset: z + .object({ + interval: z.enum(ResetInterval), + interval_count: z.number().optional(), + }) + .optional(), + expires_at: z.number().optional(), // Unix timestamp in milliseconds + }) + .refine((data) => { + if (data.entity_id && !data.customer_id) { + return false; + } else return true; + }); - if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance !== undefined || data.unlimited || data.reset?.interval) { +export const ValidateCreateBalanceParamsSchema = + CreateBalanceParamsSchema.extend({ + feature: FeatureSchema, + }).refine((data) => { + if (!data.feature) { return false; } - } - if (data.feature.type === FeatureType.Metered) { - if (data.granted_balance === undefined && !data.unlimited) { - return false; + if (data.feature.type === FeatureType.Boolean) { + if ( + data.granted_balance !== undefined || + data.unlimited || + data.reset?.interval + ) { + return false; + } } - if (data.granted_balance !== undefined && data.unlimited) { - return false; - } - if (data.unlimited && data.reset?.interval) { - return false; - } - } - return true; -}).refine((data) => { - // expires_at and reset interval are mutually exclusive (for all non-boolean feature types) - if (data.expires_at && data.reset?.interval) { - return false; - } - return true; -}, { - message: "expires_at and reset interval are mutually exclusive - a balance cannot have both", -}); + if (data.feature.type === FeatureType.Metered) { + if (data.granted_balance === undefined && !data.unlimited) { + return false; + } + if (data.granted_balance !== undefined && data.unlimited) { + return false; + } + if (data.unlimited && data.reset?.interval) { + return false; + } + } -export type CreateBalanceParams = z.infer; + return true; + }); + +export type CreateBalanceParams = z.infer; diff --git a/shared/api/customers/cusFeatures/apiBalance.ts b/shared/api/customers/cusFeatures/apiBalance.ts index 6b8dcae0f..88afb7289 100644 --- a/shared/api/customers/cusFeatures/apiBalance.ts +++ b/shared/api/customers/cusFeatures/apiBalance.ts @@ -28,7 +28,7 @@ export const ApiBalanceBreakdownSchema = z.object({ // Extra fields prepaid_quantity: z.number().default(0), - expires_at: z.number().nullable().optional(), // For loose entitlements with expiry + expires_at: z.number().nullable(), // For loose entitlements with expiry }); export const ApiBalanceSchema = z.object({ diff --git a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts index 3c8c61864..44e25fab1 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts @@ -4,7 +4,7 @@ import { FullCustomerEntitlementSchema } from "./cusEntModels.js"; export const FullCusEntWithProductSchema = FullCustomerEntitlementSchema.extend( { - customer_product: CusProductSchema, + customer_product: CusProductSchema.nullable(), }, ); diff --git a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts index f20896be5..dc99ad5ee 100644 --- a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts +++ b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts @@ -10,13 +10,11 @@ export const sortCusEntsForDeduction = ({ reverseOrder = false, entityId, customerEntitlementFilters, - isRefund = false, }: { cusEnts: FullCusEntWithFullCusProduct[]; reverseOrder?: boolean; entityId?: string; customerEntitlementFilters?: CustomerEntitlementFilters; - isRefund?: boolean; }) => { cusEnts.sort((a, b) => { if ( @@ -92,27 +90,27 @@ export const sortCusEntsForDeduction = ({ return 1; } - // Handle overage vs prepaid ordering: - // - An entitlement is "in overage mode" if usage_allowed=true AND balance <= 0 - // - An entitlement has "prepaid balance" if balance > 0 (regardless of usage_allowed) - // - For deductions: entitlements with prepaid balance go FIRST, overage mode goes LAST - // - For refunds: overage mode goes FIRST (recover overage before prepaid) - // Note: When both have prepaid balance, interval sorting (below) determines order - const aBalance = a.balance ?? 0; - const bBalance = b.balance ?? 0; - const aInOverageMode = a.usage_allowed && aBalance <= 0; - const bInOverageMode = b.usage_allowed && bBalance <= 0; + // // Handle overage vs prepaid ordering: + // // - An entitlement is "in overage mode" if usage_allowed=true AND balance <= 0 + // // - An entitlement has "prepaid balance" if balance > 0 (regardless of usage_allowed) + // // - For deductions: entitlements with prepaid balance go FIRST, overage mode goes LAST + // // - For refunds: overage mode goes FIRST (recover overage before prepaid) + // // Note: When both have prepaid balance, interval sorting (below) determines order + // const aBalance = a.balance ?? 0; + // const bBalance = b.balance ?? 0; + // const aInOverageMode = a.usage_allowed && aBalance <= 0; + // const bInOverageMode = b.usage_allowed && bBalance <= 0; - if (aInOverageMode !== bInOverageMode) { - if (aInOverageMode && !bInOverageMode) { - // a is in overage mode, b has prepaid balance - return isRefund ? -1 : 1; - } - if (!aInOverageMode && bInOverageMode) { - // a has prepaid balance, b is in overage mode - return isRefund ? 1 : -1; - } - } + // if (aInOverageMode !== bInOverageMode) { + // if (aInOverageMode && !bInOverageMode) { + // // a is in overage mode, b has prepaid balance + // return isRefund ? -1 : 1; + // } + // if (!aInOverageMode && bInOverageMode) { + // // a has prepaid balance, b is in overage mode + // return isRefund ? 1 : -1; + // } + // } // If one has a next_reset_at, it should go first const nextResetFirst = reverseOrder ? 1 : -1; diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index 3d66d2d43..58d79b4f4 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -5,7 +5,7 @@ import { cusEntMatchesEntity } from "@utils/cusEntUtils/filterCusEntUtils.js"; import { sortCusEntsForDeduction } from "@utils/cusEntUtils/sortCusEntsForDeduction.js"; import { notNullish } from "@utils/utils.js"; import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; -import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; import type { CusProduct, FullCusProduct, @@ -54,28 +54,14 @@ export const cusProductsToCusPrices = ({ export const cusProductsToCusEnts = ({ cusProducts, - inStatuses = [CusProductStatus.Active, CusProductStatus.PastDue], - reverseOrder = false, - featureId, featureIds, - entity, - customerEntitlementFilters, - isRefund = false, }: { cusProducts: FullCusProduct[]; - inStatuses?: CusProductStatus[]; - reverseOrder?: boolean; - featureId?: string; featureIds?: string[]; - entity?: Entity; - customerEntitlementFilters?: CustomerEntitlementFilters; - isRefund?: boolean; }) => { let cusEnts: FullCusEntWithFullCusProduct[] = []; for (const cusProduct of cusProducts) { - if (!inStatuses.includes(cusProduct.status)) continue; - cusEnts.push( ...cusProduct.customer_entitlements.map((cusEnt) => ({ ...cusEnt, @@ -84,51 +70,19 @@ export const cusProductsToCusEnts = ({ ); } - if (featureId) { - cusEnts = cusEnts.filter( - (cusEnt) => cusEnt.entitlement.feature.id === featureId, - ); - } - if (featureIds) { cusEnts = cusEnts.filter((cusEnt) => featureIds.includes(cusEnt.entitlement.feature.id), ); } - if (entity) { - cusEnts = cusEnts.filter((cusEnt) => - cusEntMatchesEntity({ - cusEnt: cusEnt, - entity, - }), - ); - } - sortCusEntsForDeduction({ cusEnts, - reverseOrder, - entityId: entity?.id, - isRefund, - // customerEntitlementFilters, + reverseOrder: false, + entityId: undefined, + customerEntitlementFilters: undefined, }); - if ( - customerEntitlementFilters?.cusEntIds && - customerEntitlementFilters.cusEntIds.length > 0 - ) { - cusEnts = cusEnts.filter((cusEnt) => - customerEntitlementFilters.cusEntIds?.includes(cusEnt.id), - ); - } - - if (notNullish(customerEntitlementFilters?.interval)) { - cusEnts = cusEnts.filter( - (cusEnt) => - cusEnt.entitlement.interval === customerEntitlementFilters.interval, - ); - } - return cusEnts as FullCusEntWithFullCusProduct[]; }; diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts index 180e0dcd4..10c72adea 100644 --- a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts @@ -79,8 +79,7 @@ export const fullCustomerToCustomerEntitlements = ({ cusEnts, reverseOrder, entityId: entity?.id, - isRefund, - // customerEntitlementFilters, + customerEntitlementFilters, }); if ( diff --git a/shared/utils/featureUtils/findFeatureUtils.ts b/shared/utils/featureUtils/findFeatureUtils.ts new file mode 100644 index 000000000..b0a284efa --- /dev/null +++ b/shared/utils/featureUtils/findFeatureUtils.ts @@ -0,0 +1,72 @@ +import { InternalError } from "@api/errors/base/InternalError.js"; +import type { Feature } from "../../models/featureModels/featureModels.js"; + +// Overload: errorOnNotFound = true → guaranteed Feature +export function findFeatureByInternalId(params: { + features: Feature[]; + internalId: string; + errorOnNotFound: true; +}): Feature; + +// Overload: errorOnNotFound = false/undefined → Feature | undefined +export function findFeatureByInternalId(params: { + features: Feature[]; + internalId: string; + errorOnNotFound?: false; +}): Feature | undefined; + +// Implementation +export function findFeatureByInternalId({ + features, + internalId, + errorOnNotFound, +}: { + features: Feature[]; + internalId: string; + errorOnNotFound?: boolean; +}): Feature | undefined { + const result = features.find((feature) => feature.internal_id === internalId); + + if (errorOnNotFound && !result) { + throw new InternalError({ + message: `Feature not found for internal_id: ${internalId}`, + }); + } + + return result; +} + +// Overload: errorOnNotFound = true → guaranteed Feature +export function findFeatureById(params: { + features: Feature[]; + featureId: string; + errorOnNotFound: true; +}): Feature; + +// Overload: errorOnNotFound = false/undefined → Feature | undefined +export function findFeatureById(params: { + features: Feature[]; + featureId: string; + errorOnNotFound?: false; +}): Feature | undefined; + +// Implementation +export function findFeatureById({ + features, + featureId, + errorOnNotFound, +}: { + features: Feature[]; + featureId: string; + errorOnNotFound?: boolean; +}): Feature | undefined { + const result = features.find((feature) => feature.id === featureId); + + if (errorOnNotFound && !result) { + throw new InternalError({ + message: `Feature not found for id: ${featureId}`, + }); + } + + return result; +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 268eb15bd..da0d18c7a 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -28,9 +28,9 @@ export * from "./cusEntUtils/getStartingBalance.js"; export * from "./cusEntUtils/sortCusEntsForDeduction.js"; // Cus product utils export * from "./cusProductUtils/classifyCusProduct.js"; -export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/convertCusProduct/cusProductToCusEnts.js"; export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; +export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/cusProductConstants.js"; export * from "./cusProductUtils/cusProductUtils.js"; export * from "./cusProductUtils/filterCusProductUtils.js"; @@ -50,7 +50,8 @@ export * from "./featureUtils.js"; export * from "./orgUtils/convertOrgUtils.js"; // Product utils export * from "./productUtils/convertUtils.js"; -export * from "./productUtils/entUtils/formatEntUtils.js"; +// Ent utils +export * from "./productUtils/entUtils/index.js"; export * from "./productUtils/priceUtils/convertAmountUtils.js"; export * from "./productUtils/priceUtils/formatPriceUtils.js"; export * from "./productUtils/priceUtils.js"; diff --git a/shared/utils/planFeatureUtils/planFeaturesToItems.ts b/shared/utils/planFeatureUtils/planFeaturesToItems.ts index 4f05bcc6e..a4d84fd61 100644 --- a/shared/utils/planFeatureUtils/planFeaturesToItems.ts +++ b/shared/utils/planFeatureUtils/planFeaturesToItems.ts @@ -6,7 +6,11 @@ import { ProductItemType, type RolloverConfig, } from "@models/productV2Models/productItemModels/productItemModels.js"; -import { type ApiFeatureV0, FeatureNotFoundError } from "../../api/models.js"; +import { + type ApiFeatureV0, + type CreateBalanceParams, + FeatureNotFoundError, +} from "../../api/models.js"; import type { UpdatePlanFeatureParams } from "../../api/products/planFeature/planFeatureOpModels.js"; import { ApiVersion } from "../../api/versionUtils/ApiVersion.js"; import { ApiVersionClass } from "../../api/versionUtils/ApiVersionClass.js"; @@ -83,11 +87,24 @@ const planFeatureToItemConfig = ({ return undefined; }; +/** + * Augmented CreateBalanceParams that can be used for planFeaturesToItems function + */ +type CreateBalanceForPlanFeatureMap = CreateBalanceParams & { + price?: undefined; +} & { + reset?: CreateBalanceParams["reset"] & { reset_when_enabled: true }; +}; + export const planFeaturesToItems = ({ planFeatures, features, }: { - planFeatures: (ApiPlanFeature | UpdatePlanFeatureParams)[]; + planFeatures: ( + | ApiPlanFeature + | UpdatePlanFeatureParams + | CreateBalanceForPlanFeatureMap + )[]; features: Feature[]; }): ProductItem[] => { if (!planFeatures) return []; diff --git a/shared/utils/productUtils/entUtils/enrichEntitlement.ts b/shared/utils/productUtils/entUtils/enrichEntitlement.ts new file mode 100644 index 000000000..0e75162de --- /dev/null +++ b/shared/utils/productUtils/entUtils/enrichEntitlement.ts @@ -0,0 +1,33 @@ +import type { Feature } from "../../../models/featureModels/featureModels.js"; +import type { + Entitlement, + EntitlementWithFeature, +} from "../../../models/productModels/entModels/entModels.js"; +import { findFeatureByInternalId } from "../../featureUtils/findFeatureUtils.js"; + +export const enrichEntitlementWithFeature = ({ + entitlement, + feature, +}: { + entitlement: Entitlement; + feature: Feature; +}): EntitlementWithFeature => { + return { ...entitlement, feature }; +}; + +export const enrichEntitlementsWithFeatures = ({ + entitlements, + features, +}: { + entitlements: Entitlement[]; + features: Feature[]; +}): EntitlementWithFeature[] => { + return entitlements.map((ent) => { + const feature = findFeatureByInternalId({ + features, + internalId: ent.internal_feature_id, + errorOnNotFound: true, + }); + return { ...ent, feature }; + }); +}; diff --git a/shared/utils/productUtils/entUtils/enrichEntitlementUtils.ts b/shared/utils/productUtils/entUtils/enrichEntitlementUtils.ts new file mode 100644 index 000000000..1f8eedd6e --- /dev/null +++ b/shared/utils/productUtils/entUtils/enrichEntitlementUtils.ts @@ -0,0 +1,27 @@ +import { ErrCode, RecaseError } from "../../../errors/errCodes.js"; +import type { Feature } from "../../../models/featureModels/featureModels.js"; +import type { + Entitlement, + EntitlementWithFeature, +} from "../../../models/productModels/entModels/entModels.js"; + +export const enrichEntitlementsWithFeatures = ({ + entitlements, + features, +}: { + entitlements: Entitlement[]; + features: Feature[]; +}): EntitlementWithFeature[] => { + return entitlements.map((ent) => { + const feature = features.find( + (f) => f.internal_id === ent.internal_feature_id, + ); + if (!feature) { + throw new RecaseError({ + message: `Couldn't find feature ${ent.internal_feature_id} for entitlement ${ent.id}`, + code: ErrCode.FeatureNotFound, + }); + } + return { ...ent, feature }; + }); +}; diff --git a/shared/utils/productUtils/entUtils/index.ts b/shared/utils/productUtils/entUtils/index.ts new file mode 100644 index 000000000..e7ad136f9 --- /dev/null +++ b/shared/utils/productUtils/entUtils/index.ts @@ -0,0 +1,2 @@ +export * from "./enrichEntitlement.js"; +export * from "./formatEntUtils.js"; diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index ca2930059..36d7f5db5 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -60,3 +60,11 @@ export async function tryCatch( return { data: null, error: error as E }; } } + +/** Sleep until a specific epoch timestamp (in milliseconds) */ +export function sleepUntil(epochMs: number): Promise { + const now = Date.now(); + const delay = epochMs - now; + if (delay <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, delay)); +} diff --git a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts index 76280263c..5828f9e64 100644 --- a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts +++ b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts @@ -35,7 +35,7 @@ export function useFeatureUsageBalance({ }: FeatureUsageBalanceParams): FeatureUsageBalanceResult { const cusEnts = cusProductsToCusEnts({ cusProducts, - featureId, + featureIds: [featureId], }); //without manual update adjustment, no rollovers From 7739bc5e87d18689c214bd705c1f4802f1f66e62 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 13:41:30 +0000 Subject: [PATCH 36/59] fix: added guard for extra_customer_entitlements being undefined in cache --- .../utils/deduction/applyDeductionUpdateToFullCustomer.ts | 5 +++-- .../cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts index afe9801b5..444dce3e7 100644 --- a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts +++ b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts @@ -51,8 +51,9 @@ export const applyDeductionUpdateToFullCustomer = ({ } // Search in extra_customer_entitlements (loose entitlements) - for (let i = 0; i < fullCus.extra_customer_entitlements.length; i++) { - const ce = fullCus.extra_customer_entitlements[i]; + const extraCusEnts = fullCus.extra_customer_entitlements || []; + for (let i = 0; i < extraCusEnts.length; i++) { + const ce = extraCusEnts[i]; if (ce.id === cusEntId) { let replaceables = ce.replaceables ?? []; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index df4b6b295..2a1fc1256 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -114,6 +114,11 @@ export const getCachedFullCustomer = async ({ fullCustomer.entity = undefined; } + // Ensure extra_customer_entitlements is an array (due to legacy issues with old full customer object in cache.) + if (!fullCustomer.extra_customer_entitlements) { + fullCustomer.extra_customer_entitlements = []; + } + // Round balance fields to handle floating-point precision from JSON.NUMINCRBY return roundFullCustomerBalances(fullCustomer); }; From cb12816bc51b0e579bdfd90966587dc4318c26ee Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 13:43:40 +0000 Subject: [PATCH 37/59] fix: guard against extra_customer_entitlements being empty --- .../cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts index 10c72adea..3b6fb1013 100644 --- a/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts @@ -40,7 +40,7 @@ export const fullCustomerToCustomerEntitlements = ({ ); } - for (const cusEnt of fullCustomer.extra_customer_entitlements) { + for (const cusEnt of fullCustomer.extra_customer_entitlements || []) { cusEnts.push({ ...cusEnt, customer_product: null, From a9455bc5d901be2a133adbbfb6f31ca75e2b3ab9 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 13:47:03 +0000 Subject: [PATCH 38/59] fix: tracking supabase changes in git --- .gitignore | 4 ---- .../src/external/supabase/createSupabaseClient.ts | 13 +++++++++++++ server/src/external/supabase/storageUtils.ts | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 server/src/external/supabase/createSupabaseClient.ts diff --git a/.gitignore b/.gitignore index 377c6a6e3..21a344f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -93,11 +93,7 @@ server/test.sh .dev.vars -supabase/ -# To load supabase -# 1. Start supabase locally -# 2. Load files .claude/ .vscode/ diff --git a/server/src/external/supabase/createSupabaseClient.ts b/server/src/external/supabase/createSupabaseClient.ts new file mode 100644 index 000000000..4624c2397 --- /dev/null +++ b/server/src/external/supabase/createSupabaseClient.ts @@ -0,0 +1,13 @@ +import { createClient } from "@supabase/supabase-js"; + +export const createSupabaseClient = () => { + try { + return createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SERVICE_KEY!, + ); + } catch (error) { + console.error("Error creating Supabase client:", error); + throw error; + } +}; diff --git a/server/src/external/supabase/storageUtils.ts b/server/src/external/supabase/storageUtils.ts index c4a41591e..b55949474 100644 --- a/server/src/external/supabase/storageUtils.ts +++ b/server/src/external/supabase/storageUtils.ts @@ -1,4 +1,4 @@ -import { createSupabaseClient } from "./createSupabaseClient"; +import { createSupabaseClient } from "@/external/supabase/createSupabaseClient"; export const readFile = async ({ bucket = "autumn", From 9c91e5e3b9502aed4138443cfa115cbee4be24f6 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Tue, 13 Jan 2026 13:59:03 +0000 Subject: [PATCH 39/59] implemented customer feedback: table links, sorting and better prompts --- .../components/general/table/table-body.tsx | 69 ++++++++++++------- .../general/table/table-context.tsx | 3 + .../components/general/table/table-header.tsx | 24 +++---- vite/src/components/v2/tooltips/Tooltip.tsx | 4 +- vite/src/hooks/queries/useProductsQuery.tsx | 9 ++- .../table/customer-list/CustomerListTable.tsx | 17 ++--- .../src/views/onboarding4/prompts/customer.md | 4 +- .../product-list/ProductListColumns.tsx | 24 +++++-- .../product-list/ProductListTable.tsx | 27 +++----- 9 files changed, 106 insertions(+), 75 deletions(-) diff --git a/vite/src/components/general/table/table-body.tsx b/vite/src/components/general/table/table-body.tsx index 897b99c12..0996535b0 100644 --- a/vite/src/components/general/table/table-body.tsx +++ b/vite/src/components/general/table/table-body.tsx @@ -1,4 +1,5 @@ import { flexRender } from "@tanstack/react-table"; +import { Link } from "react-router"; import { Checkbox } from "@/components/ui/checkbox"; import { TableBody as ShadcnTableBody, @@ -15,6 +16,7 @@ export function TableBody() { numberOfColumns, enableSelection, isLoading, + getRowHref, onRowClick, rowClassName, emptyStateChildren, @@ -51,6 +53,8 @@ export function TableBody() { {rows.map((row) => { const isSelected = selectedItemId === (row.original as any).id; + const rowHref = getRowHref?.(row.original); + return ( onRowClick?.(row.original)} + onClick={!rowHref ? () => onRowClick?.(row.original) : undefined} > {enableSelection && ( @@ -71,28 +75,47 @@ export function TableBody() { /> )} - {row.getVisibleCells().map((cell, index) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getVisibleCells().map((cell, index) => { + const cellContent = flexRender( + cell.column.columnDef.cell, + cell.getContext(), + ); + const cellStyle = flexibleTableColumns + ? { + width: `${cell.column.getSize()}px`, + maxWidth: `${cell.column.getSize()}px`, + minWidth: cell.column.columnDef.minSize + ? `${cell.column.columnDef.minSize}px` + : undefined, + } + : { width: `${cell.column.getSize()}px` }; + + return ( + + {rowHref ? ( + + {cellContent} + + ) : ( + cellContent + )} + + ); + })} ); })} diff --git a/vite/src/components/general/table/table-context.tsx b/vite/src/components/general/table/table-context.tsx index a53dfd52c..ab50e856f 100644 --- a/vite/src/components/general/table/table-context.tsx +++ b/vite/src/components/general/table/table-context.tsx @@ -12,6 +12,9 @@ export interface TableProps { columnVisibilityStorageKey?: string; /** Column groups for UI organization (renders as submenus in visibility dropdown) */ columnGroups?: ColumnGroup[]; + /** For navigation - returns href string, enables cmd+click to open in new tab */ + getRowHref?: (row: T) => string; + /** For non-navigation actions like opening sheets/modals */ onRowClick?: (row: T) => void; rowClassName?: string; emptyStateChildren?: ReactNode; diff --git a/vite/src/components/general/table/table-header.tsx b/vite/src/components/general/table/table-header.tsx index 7d300b40c..306869073 100644 --- a/vite/src/components/general/table/table-header.tsx +++ b/vite/src/components/general/table/table-header.tsx @@ -10,20 +10,20 @@ import { cn } from "@/lib/utils"; import { useTableContext } from "./table-context"; function SortIcon({ sortDirection }: { sortDirection: string | false }) { - if (!sortDirection) { - return null; + if (sortDirection === "asc") { + return ( +
+ return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
- {attachmentLabel} -
-
- -
- {isImage && ( -
- {filename -
- )} -
-
-

- {filename || (isImage ? "Image" : "Attachment")} -

- {data.mediaType && ( -

- {data.mediaType} -

- )} -
-
-
-
-
- ); + {attachmentLabel} +
+ + +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+ + ); } export type PromptInputAttachmentsProps = Omit< - HTMLAttributes, - "children" + HTMLAttributes, + "children" > & { - children: (attachment: FileUIPart & { id: string }) => ReactNode; + children: (attachment: FileUIPart & { id: string }) => ReactNode; }; export function PromptInputAttachments({ - children, - className, - ...props + children, + className, + ...props }: PromptInputAttachmentsProps) { - const attachments = usePromptInputAttachments(); + const attachments = usePromptInputAttachments(); - if (!attachments.files.length) { - return null; - } + if (!attachments.files.length) { + return null; + } - return ( -
- {attachments.files.map((file) => ( - {children(file)} - ))} -
- ); + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); } export type PromptInputActionAddAttachmentsProps = ComponentProps< - typeof DropdownMenuItem + typeof DropdownMenuItem > & { - label?: string; + label?: string; }; export const PromptInputActionAddAttachments = ({ - label = "Add photos or files", - ...props + label = "Add photos or files", + ...props }: PromptInputActionAddAttachmentsProps) => { - const attachments = usePromptInputAttachments(); + const attachments = usePromptInputAttachments(); - return ( - { - e.preventDefault(); - attachments.openFileDialog(); - }} - > - {label} - - ); + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); }; export type PromptInputMessage = { - text: string; - files: FileUIPart[]; + text: string; + files: FileUIPart[]; }; export type PromptInputProps = Omit< - HTMLAttributes, - "onSubmit" | "onError" + HTMLAttributes, + "onSubmit" | "onError" > & { - accept?: string; // e.g., "image/*" or leave undefined for any - multiple?: boolean; - // When true, accepts drops anywhere on document. Default false (opt-in). - globalDrop?: boolean; - // Render a hidden input with given name and keep it in sync for native form posts. Default false. - syncHiddenInput?: boolean; - // Minimal constraints - maxFiles?: number; - maxFileSize?: number; // bytes - onError?: (err: { - code: "max_files" | "max_file_size" | "accept"; - message: string; - }) => void; - onSubmit: ( - message: PromptInputMessage, - event: FormEvent - ) => void | Promise; + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent, + ) => void | Promise; }; export const PromptInput = ({ - className, - accept, - multiple, - globalDrop, - syncHiddenInput, - maxFiles, - maxFileSize, - onError, - onSubmit, - children, - ...props + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props }: PromptInputProps) => { - // Try to use a provider controller if present - const controller = useOptionalPromptInputController(); - const usingProvider = !!controller; + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; - // Refs - const inputRef = useRef(null); - const formRef = useRef(null); + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); - // ----- Local attachments (only used when no provider) - const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); - const files = usingProvider ? controller.attachments.files : items; + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; - // Keep a ref to files for cleanup on unmount (avoids stale closure) - const filesRef = useRef(files); - filesRef.current = files; + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; - const openFileDialogLocal = useCallback(() => { - inputRef.current?.click(); - }, []); + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); - const matchesAccept = useCallback( - (f: File) => { - if (!accept || accept.trim() === "") { - return true; - } + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } - const patterns = accept - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); - return patterns.some((pattern) => { - if (pattern.endsWith("/*")) { - const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ - return f.type.startsWith(prefix); - } - return f.type === pattern; - }); - }, - [accept] - ); + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept], + ); - const addLocal = useCallback( - (fileList: File[] | FileList) => { - const incoming = Array.from(fileList); - const accepted = incoming.filter((f) => matchesAccept(f)); - if (incoming.length && accepted.length === 0) { - onError?.({ - code: "accept", - message: "No files match the accepted types.", - }); - return; - } - const withinSize = (f: File) => - maxFileSize ? f.size <= maxFileSize : true; - const sized = accepted.filter(withinSize); - if (accepted.length > 0 && sized.length === 0) { - onError?.({ - code: "max_file_size", - message: "All files exceed the maximum size.", - }); - return; - } + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } - setItems((prev) => { - const capacity = - typeof maxFiles === "number" - ? Math.max(0, maxFiles - prev.length) - : undefined; - const capped = - typeof capacity === "number" ? sized.slice(0, capacity) : sized; - if (typeof capacity === "number" && sized.length > capacity) { - onError?.({ - code: "max_files", - message: "Too many files. Some were not added.", - }); - } - const next: (FileUIPart & { id: string })[] = []; - for (const file of capped) { - next.push({ - id: nanoid(), - type: "file", - url: URL.createObjectURL(file), - mediaType: file.type, - filename: file.name, - }); - } - return prev.concat(next); - }); - }, - [matchesAccept, maxFiles, maxFileSize, onError] - ); + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError], + ); - const removeLocal = useCallback( - (id: string) => - setItems((prev) => { - const found = prev.find((file) => file.id === id); - if (found?.url) { - URL.revokeObjectURL(found.url); - } - return prev.filter((file) => file.id !== id); - }), - [] - ); + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [], + ); - const clearLocal = useCallback( - () => - setItems((prev) => { - for (const file of prev) { - if (file.url) { - URL.revokeObjectURL(file.url); - } - } - return []; - }), - [] - ); + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [], + ); - const add = usingProvider ? controller.attachments.add : addLocal; - const remove = usingProvider ? controller.attachments.remove : removeLocal; - const clear = usingProvider ? controller.attachments.clear : clearLocal; - const openFileDialog = usingProvider - ? controller.attachments.openFileDialog - : openFileDialogLocal; + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; - // Let provider know about our hidden file input so external menus can call openFileDialog() - useEffect(() => { - if (!usingProvider) return; - controller.__registerFileInput(inputRef, () => inputRef.current?.click()); - }, [usingProvider, controller]); + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); - // Note: File input cannot be programmatically set for security reasons - // The syncHiddenInput prop is no longer functional - useEffect(() => { - if (syncHiddenInput && inputRef.current && files.length === 0) { - inputRef.current.value = ""; - } - }, [files, syncHiddenInput]); + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); - // Attach drop handlers on nearest form and document (opt-in) - useEffect(() => { - const form = formRef.current; - if (!form) return; - if (globalDrop) return // when global drop is on, let the document-level handler own drops + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + if (globalDrop) return; // when global drop is on, let the document-level handler own drops - const onDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - }; - const onDrop = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); - } - }; - form.addEventListener("dragover", onDragOver); - form.addEventListener("drop", onDrop); - return () => { - form.removeEventListener("dragover", onDragOver); - form.removeEventListener("drop", onDrop); - }; - }, [add, globalDrop]); + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); - useEffect(() => { - if (!globalDrop) return; + useEffect(() => { + if (!globalDrop) return; - const onDragOver = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - }; - const onDrop = (e: DragEvent) => { - if (e.dataTransfer?.types?.includes("Files")) { - e.preventDefault(); - } - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); - } - }; - document.addEventListener("dragover", onDragOver); - document.addEventListener("drop", onDrop); - return () => { - document.removeEventListener("dragover", onDragOver); - document.removeEventListener("drop", onDrop); - }; - }, [add, globalDrop]); + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); - useEffect( - () => () => { - if (!usingProvider) { - for (const f of filesRef.current) { - if (f.url) URL.revokeObjectURL(f.url); - } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current - [usingProvider] - ); + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider], + ); - const handleChange: ChangeEventHandler = (event) => { - if (event.currentTarget.files) { - add(event.currentTarget.files); - } - // Reset input value to allow selecting files that were previously removed - event.currentTarget.value = ""; - }; + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; - const convertBlobUrlToDataUrl = async ( - url: string - ): Promise => { - try { - const response = await fetch(url); - const blob = await response.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = () => resolve(null); - reader.readAsDataURL(blob); - }); - } catch { - return null; - } - }; + const convertBlobUrlToDataUrl = async ( + url: string, + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; - const ctx = useMemo( - () => ({ - files: files.map((item) => ({ ...item, id: item.id })), - add, - remove, - clear, - openFileDialog, - fileInputRef: inputRef, - }), - [files, add, remove, clear, openFileDialog] - ); + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog], + ); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); - const form = event.currentTarget; - const text = usingProvider - ? controller.textInput.value - : (() => { - const formData = new FormData(form); - return (formData.get("message") as string) || ""; - })(); + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); - // Reset form immediately after capturing text to avoid race condition - // where user input during async blob conversion would be lost - if (!usingProvider) { - form.reset(); - } + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } - // Convert blob URLs to data URLs asynchronously - Promise.all( - files.map(async ({ id, ...item }) => { - if (item.url && item.url.startsWith("blob:")) { - const dataUrl = await convertBlobUrlToDataUrl(item.url); - // If conversion failed, keep the original blob URL - return { - ...item, - url: dataUrl ?? item.url, - }; - } - return item; - }) - ) - .then((convertedFiles: FileUIPart[]) => { - try { - const result = onSubmit({ text, files: convertedFiles }, event); + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }), + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); - // Handle both sync and async onSubmit - if (result instanceof Promise) { - result - .then(() => { - clear(); - if (usingProvider) { - controller.textInput.clear(); - } - }) - .catch(() => { - // Don't clear on error - user may want to retry - }); - } else { - // Sync function completed without throwing, clear attachments - clear(); - if (usingProvider) { - controller.textInput.clear(); - } - } - } catch { - // Don't clear on error - user may want to retry - } - }) - .catch(() => { - // Don't clear on error - user may want to retry - }); - }; + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; - // Render with or without local provider - const inner = ( - <> - -
- {children} -
- - ); + // Render with or without local provider + const inner = ( + <> + +
+ + {children} + +
+ + ); - return usingProvider ? ( - inner - ) : ( - - {inner} - - ); + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); }; export type PromptInputBodyProps = HTMLAttributes; export const PromptInputBody = ({ - className, - ...props + className, + ...props }: PromptInputBodyProps) => ( -
+
); export type PromptInputTextareaProps = ComponentProps< - typeof InputGroupTextarea + typeof InputGroupTextarea >; export const PromptInputTextarea = ({ - onChange, - className, - placeholder = "What would you like to know?", - ...props + onChange, + className, + placeholder = "What would you like to know?", + ...props }: PromptInputTextareaProps) => { - const controller = useOptionalPromptInputController(); - const attachments = usePromptInputAttachments(); - const [isComposing, setIsComposing] = useState(false); + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); - const handleKeyDown: KeyboardEventHandler = (e) => { - if (e.key === "Enter") { - if (isComposing || e.nativeEvent.isComposing) { - return; - } - if (e.shiftKey) { - return; - } - e.preventDefault(); + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); - // Check if the submit button is disabled before submitting - const form = e.currentTarget.form; - const submitButton = form?.querySelector( - 'button[type="submit"]' - ) as HTMLButtonElement | null; - if (submitButton?.disabled) { - return; - } + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } - form?.requestSubmit(); - } + form?.requestSubmit(); + } - // Remove last attachment when Backspace is pressed and textarea is empty - if ( - e.key === "Backspace" && - e.currentTarget.value === "" && - attachments.files.length > 0 - ) { - e.preventDefault(); - const lastAttachment = attachments.files.at(-1); - if (lastAttachment) { - attachments.remove(lastAttachment.id); - } - } - }; + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; - const handlePaste: ClipboardEventHandler = (event) => { - const items = event.clipboardData?.items; + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; - if (!items) { - return; - } + if (!items) { + return; + } - const files: File[] = []; + const files: File[] = []; - for (const item of items) { - if (item.kind === "file") { - const file = item.getAsFile(); - if (file) { - files.push(file); - } - } - } + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } - if (files.length > 0) { - event.preventDefault(); - attachments.add(files); - } - }; + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; - const controlledProps = controller - ? { - value: controller.textInput.value, - onChange: (e: ChangeEvent) => { - controller.textInput.setInput(e.currentTarget.value); - onChange?.(e); - }, - } - : { - onChange, - }; + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; - return ( - setIsComposing(false)} - onCompositionStart={() => setIsComposing(true)} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - placeholder={placeholder} - {...props} - {...controlledProps} - /> - ); + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); }; export type PromptInputHeaderProps = Omit< - ComponentProps, - "align" + ComponentProps, + "align" >; export const PromptInputHeader = ({ - className, - ...props + className, + ...props }: PromptInputHeaderProps) => ( - + ); export type PromptInputFooterProps = Omit< - ComponentProps, - "align" + ComponentProps, + "align" >; export const PromptInputFooter = ({ - className, - ...props + className, + ...props }: PromptInputFooterProps) => ( - + ); export type PromptInputToolsProps = HTMLAttributes; export const PromptInputTools = ({ - className, - ...props + className, + ...props }: PromptInputToolsProps) => ( -
+
); export type PromptInputButtonProps = ComponentProps; export const PromptInputButton = ({ - variant = "ghost", - className, - size, - ...props + variant = "skeleton", + className, + size, + ...props }: PromptInputButtonProps) => { - const newSize = - size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); - return ( - - ); + return ( + + ); }; export type PromptInputActionMenuProps = ComponentProps; export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( - + ); export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; export const PromptInputActionMenuTrigger = ({ - className, - children, - ...props + className, + children, + ...props }: PromptInputActionMenuTriggerProps) => ( - - - {children ?? } - - + + + {children ?? } + + ); export type PromptInputActionMenuContentProps = ComponentProps< - typeof DropdownMenuContent + typeof DropdownMenuContent >; export const PromptInputActionMenuContent = ({ - className, - ...props + className, + ...props }: PromptInputActionMenuContentProps) => ( - + ); export type PromptInputActionMenuItemProps = ComponentProps< - typeof DropdownMenuItem + typeof DropdownMenuItem >; export const PromptInputActionMenuItem = ({ - className, - ...props + className, + ...props }: PromptInputActionMenuItemProps) => ( - + ); // Note: Actions that perform side-effects (like opening a file dialog) // are provided in opt-in modules (e.g., prompt-input-attachments). export type PromptInputSubmitProps = ComponentProps & { - status?: ChatStatus; + status?: ChatStatus; }; export const PromptInputSubmit = ({ - className, - variant = "default", - size = "icon-sm", - status, - children, - ...props + className, + variant = "primary", + size = "icon-sm", + status, + children, + ...props }: PromptInputSubmitProps) => { - let Icon = ; + let Icon = ; - if (status === "submitted") { - Icon = ; - } else if (status === "streaming") { - Icon = ; - } else if (status === "error") { - Icon = ; - } + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } - return ( - - {children ?? Icon} - - ); + return ( + + {children ?? Icon} + + ); }; interface SpeechRecognition extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - onstart: ((this: SpeechRecognition, ev: Event) => any) | null; - onend: ((this: SpeechRecognition, ev: Event) => any) | null; - onresult: - | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) - | null; - onerror: - | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) - | null; + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => any) | null; + onend: ((this: SpeechRecognition, ev: Event) => any) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) + | null; } interface SpeechRecognitionEvent extends Event { - results: SpeechRecognitionResultList; - resultIndex: number; + results: SpeechRecognitionResultList; + resultIndex: number; } type SpeechRecognitionResultList = { - readonly length: number; - item(index: number): SpeechRecognitionResult; - [index: number]: SpeechRecognitionResult; + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; }; type SpeechRecognitionResult = { - readonly length: number; - item(index: number): SpeechRecognitionAlternative; - [index: number]: SpeechRecognitionAlternative; - isFinal: boolean; + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; }; type SpeechRecognitionAlternative = { - transcript: string; - confidence: number; + transcript: string; + confidence: number; }; interface SpeechRecognitionErrorEvent extends Event { - error: string; + error: string; } declare global { - interface Window { - SpeechRecognition: { - new (): SpeechRecognition; - }; - webkitSpeechRecognition: { - new (): SpeechRecognition; - }; - } + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } } export type PromptInputSpeechButtonProps = ComponentProps< - typeof PromptInputButton + typeof PromptInputButton > & { - textareaRef?: RefObject; - onTranscriptionChange?: (text: string) => void; + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; }; export const PromptInputSpeechButton = ({ - className, - textareaRef, - onTranscriptionChange, - ...props + className, + textareaRef, + onTranscriptionChange, + ...props }: PromptInputSpeechButtonProps) => { - const [isListening, setIsListening] = useState(false); - const [recognition, setRecognition] = useState( - null - ); - const recognitionRef = useRef(null); + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null, + ); + const recognitionRef = useRef(null); - useEffect(() => { - if ( - typeof window !== "undefined" && - ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) - ) { - const SpeechRecognition = - window.SpeechRecognition || window.webkitSpeechRecognition; - const speechRecognition = new SpeechRecognition(); + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); - speechRecognition.continuous = true; - speechRecognition.interimResults = true; - speechRecognition.lang = "en-US"; + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; - speechRecognition.onstart = () => { - setIsListening(true); - }; + speechRecognition.onstart = () => { + setIsListening(true); + }; - speechRecognition.onend = () => { - setIsListening(false); - }; + speechRecognition.onend = () => { + setIsListening(false); + }; - speechRecognition.onresult = (event) => { - let finalTranscript = ""; + speechRecognition.onresult = (event) => { + let finalTranscript = ""; - for (let i = event.resultIndex; i < event.results.length; i++) { - const result = event.results[i]; - if (result.isFinal) { - finalTranscript += result[0]?.transcript ?? ""; - } - } + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } - if (finalTranscript && textareaRef?.current) { - const textarea = textareaRef.current; - const currentValue = textarea.value; - const newValue = - currentValue + (currentValue ? " " : "") + finalTranscript; + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; - textarea.value = newValue; - textarea.dispatchEvent(new Event("input", { bubbles: true })); - onTranscriptionChange?.(newValue); - } - }; + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; - speechRecognition.onerror = (event) => { - console.error("Speech recognition error:", event.error); - setIsListening(false); - }; + speechRecognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; - recognitionRef.current = speechRecognition; - setRecognition(speechRecognition); - } + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } - return () => { - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - }; - }, [textareaRef, onTranscriptionChange]); + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); - const toggleListening = useCallback(() => { - if (!recognition) { - return; - } + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } - if (isListening) { - recognition.stop(); - } else { - recognition.start(); - } - }, [recognition, isListening]); + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); - return ( - - - - ); + return ( + + + + ); }; export type PromptInputSelectProps = ComponentProps; export const PromptInputSelect = (props: PromptInputSelectProps) => ( - ); export type PromptInputSelectTriggerProps = ComponentProps< - typeof SelectTrigger + typeof SelectTrigger >; export const PromptInputSelectTrigger = ({ - className, - ...props + className, + ...props }: PromptInputSelectTriggerProps) => ( - + ); export type PromptInputSelectContentProps = ComponentProps< - typeof SelectContent + typeof SelectContent >; export const PromptInputSelectContent = ({ - className, - ...props + className, + ...props }: PromptInputSelectContentProps) => ( - + ); export type PromptInputSelectItemProps = ComponentProps; export const PromptInputSelectItem = ({ - className, - ...props + className, + ...props }: PromptInputSelectItemProps) => ( - + ); export type PromptInputSelectValueProps = ComponentProps; export const PromptInputSelectValue = ({ - className, - ...props + className, + ...props }: PromptInputSelectValueProps) => ( - + ); export type PromptInputHoverCardProps = ComponentProps; export const PromptInputHoverCard = ({ - openDelay = 0, - closeDelay = 0, - ...props + openDelay = 0, + closeDelay = 0, + ...props }: PromptInputHoverCardProps) => ( - + ); export type PromptInputHoverCardTriggerProps = ComponentProps< - typeof HoverCardTrigger + typeof HoverCardTrigger >; export const PromptInputHoverCardTrigger = ( - props: PromptInputHoverCardTriggerProps + props: PromptInputHoverCardTriggerProps, ) => ; export type PromptInputHoverCardContentProps = ComponentProps< - typeof HoverCardContent + typeof HoverCardContent >; export const PromptInputHoverCardContent = ({ - align = "start", - ...props + align = "start", + ...props }: PromptInputHoverCardContentProps) => ( - + ); export type PromptInputTabsListProps = HTMLAttributes; export const PromptInputTabsList = ({ - className, - ...props + className, + ...props }: PromptInputTabsListProps) =>
; export type PromptInputTabProps = HTMLAttributes; export const PromptInputTab = ({ - className, - ...props + className, + ...props }: PromptInputTabProps) =>
; export type PromptInputTabLabelProps = HTMLAttributes; export const PromptInputTabLabel = ({ - className, - ...props + className, + ...props }: PromptInputTabLabelProps) => ( -

+

); export type PromptInputTabBodyProps = HTMLAttributes; export const PromptInputTabBody = ({ - className, - ...props + className, + ...props }: PromptInputTabBodyProps) => ( -
+
); export type PromptInputTabItemProps = HTMLAttributes; export const PromptInputTabItem = ({ - className, - ...props + className, + ...props }: PromptInputTabItemProps) => ( -
+
); export type PromptInputCommandProps = ComponentProps; export const PromptInputCommand = ({ - className, - ...props + className, + ...props }: PromptInputCommandProps) => ; export type PromptInputCommandInputProps = ComponentProps; export const PromptInputCommandInput = ({ - className, - ...props + className, + ...props }: PromptInputCommandInputProps) => ( - + ); export type PromptInputCommandListProps = ComponentProps; export const PromptInputCommandList = ({ - className, - ...props + className, + ...props }: PromptInputCommandListProps) => ( - + ); export type PromptInputCommandEmptyProps = ComponentProps; export const PromptInputCommandEmpty = ({ - className, - ...props + className, + ...props }: PromptInputCommandEmptyProps) => ( - + ); export type PromptInputCommandGroupProps = ComponentProps; export const PromptInputCommandGroup = ({ - className, - ...props + className, + ...props }: PromptInputCommandGroupProps) => ( - + ); export type PromptInputCommandItemProps = ComponentProps; export const PromptInputCommandItem = ({ - className, - ...props + className, + ...props }: PromptInputCommandItemProps) => ( - + ); export type PromptInputCommandSeparatorProps = ComponentProps< - typeof CommandSeparator + typeof CommandSeparator >; export const PromptInputCommandSeparator = ({ - className, - ...props + className, + ...props }: PromptInputCommandSeparatorProps) => ( - + ); diff --git a/vite/src/components/ui/input-group.tsx b/vite/src/components/ui/input-group.tsx index 3d1f9d923..7ba78b870 100644 --- a/vite/src/components/ui/input-group.tsx +++ b/vite/src/components/ui/input-group.tsx @@ -1,170 +1,169 @@ -"use client" +"use client"; -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/v2/buttons/Button"; +import { Input } from "@/components/v2/inputs/Input"; +import { cn } from "@/lib/utils"; function InputGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
textarea]:h-auto", + return ( +
textarea]:h-auto", - // Variants based on alignment. - "has-[>[data-align=inline-start]]:[&>input]:pl-2", - "has-[>[data-align=inline-end]]:[&>input]:pr-2", - "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", - "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", - // Focus state. - "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", - // Error state. - "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", - className - )} - {...props} - /> - ) + className, + )} + {...props} + /> + ); } const inputGroupAddonVariants = cva( - "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", - { - variants: { - align: { - "inline-start": - "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", - "inline-end": - "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", - "block-start": - "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", - "block-end": - "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", - }, - }, - defaultVariants: { - align: "inline-start", - }, - } -) + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); function InputGroupAddon({ - className, - align = "inline-start", - ...props + className, + align = "inline-start", + ...props }: React.ComponentProps<"div"> & VariantProps) { - return ( -
{ - if ((e.target as HTMLElement).closest("button")) { - return - } - e.currentTarget.parentElement?.querySelector("input")?.focus() - }} - {...props} - /> - ) + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); } const inputGroupButtonVariants = cva( - "text-sm shadow-none flex gap-2 items-center", - { - variants: { - size: { - xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", - sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", - "icon-xs": - "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", - }, - }, - defaultVariants: { - size: "xs", - }, - } -) + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + }, +); function InputGroupButton({ - className, - type = "button", - variant = "ghost", - size = "xs", - ...props + className, + type = "button", + variant = "ghost", + size = "xs", + ...props }: Omit, "size"> & - VariantProps) { - return ( -