diff --git a/apps/dashboard/src/components/payments/customer-payments-section.tsx b/apps/dashboard/src/components/payments/customer-payments-section.tsx
index d1042bc5c..29dc14535 100644
--- a/apps/dashboard/src/components/payments/customer-payments-section.tsx
+++ b/apps/dashboard/src/components/payments/customer-payments-section.tsx
@@ -3,6 +3,7 @@
import {
DesignBadge,
type DesignBadgeColor,
+ DesignButton,
DesignCard,
} from "@/components/design-components";
import { cn, Skeleton } from "@/components/ui";
@@ -10,12 +11,13 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { UserPageMetricCard } from "@/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card";
import { UserPageTableSection } from "@/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-table-section";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
-import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, CoinsIcon, GearIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from "@phosphor-icons/react";
+import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, CoinsIcon, GearIcon, PlusMinusIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from "@phosphor-icons/react";
import type { DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import type { Transaction, TransactionEntry, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import { captureError } from "@hexclave/shared/dist/utils/errors";
-import { Suspense, useMemo } from "react";
+import { Suspense, useMemo, useState } from "react";
import type { CustomerType } from "./customer-selector";
+import { ItemQuantityChangeDialog } from "./item-quantity-change-dialog";
// Re-export so existing consumers of this module keep working, while the
// canonical definition lives in customer-selector.tsx.
@@ -527,6 +529,7 @@ function ItemsCard({ customerType, customerId, itemIds }: { customerType: Custom
function ItemBalanceRow({ customerType, customerId, itemId }: { customerType: CustomerType, customerId: string, itemId: string }) {
const hexclaveAdminApp = useAdminApp();
+ const [isAdjustOpen, setIsAdjustOpen] = useState(false);
const itemOptions = customerType === "user"
? { userId: customerId, itemId }
: customerType === "team"
@@ -536,13 +539,36 @@ function ItemBalanceRow({ customerType, customerId, itemId }: { customerType: Cu
const isNegative = item.quantity < 0;
return (
-
- {item.displayName}
-
- {item.quantity}
-
-
+ <>
+
+
{item.displayName}
+
+
+ {item.quantity}
+
+
setIsAdjustOpen(true)}
+ >
+
+
+
+
+
+ >
);
}
diff --git a/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx b/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx
new file mode 100644
index 000000000..2ab965c76
--- /dev/null
+++ b/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
+import {
+ DesignButton,
+ DesignDialog,
+ DesignDialogClose,
+ DesignInput,
+} from "@/components/design-components";
+import { Label, Typography, toast } from "@/components/ui";
+import { cn } from "@/lib/utils";
+import { PlusMinusIcon } from "@phosphor-icons/react";
+import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
+import { Result } from "@hexclave/shared/dist/utils/results";
+import { useEffect, useState } from "react";
+import type { CustomerType } from "./customer-selector";
+
+type Props = {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ customerType: CustomerType,
+ customerId: string,
+ itemId: string,
+ itemDisplayName: string,
+ currentQuantity: number,
+};
+
+function parseQuantityChange(text: string): number | null {
+ const trimmed = text.trim();
+ if (!/^[+-]?\d+$/.test(trimmed)) return null;
+ const parsed = Number.parseInt(trimmed, 10);
+ if (!Number.isSafeInteger(parsed) || parsed === 0) return null;
+ return parsed;
+}
+
+/**
+ * Dialog for creating a manual item quantity change (positive or negative
+ * delta) for a single customer and a single item. Opened from the item
+ * balances card on the customer payments views.
+ */
+export function ItemQuantityChangeDialog(props: Props) {
+ const hexclaveAdminApp = useAdminApp();
+ const [quantityText, setQuantityText] = useState("");
+ const [description, setDescription] = useState("");
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Reset the form whenever the dialog is (re)opened so stale input doesn't
+ // leak across invocations.
+ useEffect(() => {
+ if (props.open) {
+ setQuantityText("");
+ setDescription("");
+ setError(null);
+ }
+ }, [props.open]);
+
+ const parsedQuantity = parseQuantityChange(quantityText);
+ const newQuantity = parsedQuantity == null ? null : props.currentQuantity + parsedQuantity;
+
+ const submit = async () => {
+ if (parsedQuantity == null) {
+ setError("Enter a non-zero whole number. Use a negative value to subtract.");
+ return;
+ }
+ setIsSubmitting(true);
+ try {
+ const customerOptions = props.customerType === "user"
+ ? { userId: props.customerId }
+ : props.customerType === "team"
+ ? { teamId: props.customerId }
+ : { customCustomerId: props.customerId };
+ const result = await Result.fromPromise(hexclaveAdminApp.createItemQuantityChange({
+ ...customerOptions,
+ itemId: props.itemId,
+ quantity: parsedQuantity,
+ ...(description.trim() ? { description: description.trim() } : {}),
+ }));
+ if (result.status === "error") {
+ toast({ title: "Failed to update item quantity", variant: "destructive" });
+ return;
+ }
+ toast({ title: `${props.itemDisplayName}: quantity changed by ${parsedQuantity > 0 ? "+" : ""}${parsedQuantity}` });
+ props.onOpenChange(false);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Cancel
+
+
+ runAsynchronouslyWithAlert(submit())}
+ >
+ Apply Change
+
+ >
+ )}
+ >
+
+
+
+ {
+ setQuantityText(e.target.value);
+ if (error) setError(null);
+ }}
+ placeholder="e.g. 10 or -5"
+ autoFocus
+ disabled={isSubmitting}
+ size="md"
+ className={cn(
+ "tabular-nums",
+ error && "border-destructive focus-visible:ring-destructive/30",
+ )}
+ />
+ {error ? (
+
+ {error}
+
+ ) : (
+
+ {newQuantity == null
+ ? "Positive values add to the balance, negative values subtract."
+ : `New balance: ${props.currentQuantity} → ${newQuantity}`}
+
+ )}
+
+
+
+
+ setDescription(e.target.value)}
+ placeholder="e.g. Support credit for outage"
+ disabled={isSubmitting}
+ size="md"
+ />
+
+
+
+ );
+}
diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts
index 7f1d26189..78ecdddd1 100644
--- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts
+++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts
@@ -9,7 +9,7 @@ import type { AdminGetSessionReplayChunkEventsResponse } from "@hexclave/shared/
import type { Transaction, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
import type { MoneyAmount } from "@hexclave/shared/dist/utils/currency-constants";
-import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
+import { HexclaveAssertionError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
import type { Json } from "@hexclave/shared/dist/utils/json";
import { pick, typedEntries, typedValues } from "@hexclave/shared/dist/utils/objects";
import { Result } from "@hexclave/shared/dist/utils/results";
@@ -905,6 +905,20 @@ export class _HexclaveAdminAppImplIncomplete true);
+ } catch (error) {
+ captureError("create-item-quantity-change-cache-refresh", error);
+ }
}
async refundTransaction(options: {
diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
index c931ec3a0..1594e4dcc 100644
--- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
+++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
@@ -1448,6 +1448,16 @@ export class _HexclaveServerAppImplIncomplete {
+ if (customerType === "user") {
+ await this._serverUserItemsCache.refresh([customerId, itemId]);
+ } else if (customerType === "team") {
+ await this._serverTeamItemsCache.refresh([customerId, itemId]);
+ } else {
+ await this._serverCustomItemsCache.refresh([customerId, itemId]);
+ }
+ }
+
async listProducts(options: CustomerProductsRequestOptions): Promise {
if ("userId" in options) {
const response = Result.orThrow(await this._serverUserProductsCache.getOrWait([options.userId, options.cursor ?? null, options.limit ?? null], "write-only"));