Merge pull request #1266 from useautumn/fix/max-entitis

fix: max entitis
This commit is contained in:
John Yeo
2026-04-17 05:02:23 +01:00
committed by GitHub
12 changed files with 154 additions and 164 deletions

View File

@@ -114,7 +114,7 @@ const buildEntitiesCTE = (withEntities: boolean) => {
SELECT * FROM entities e
WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record)
ORDER BY e.internal_id DESC
LIMIT 1000
LIMIT 300
) e
)
`;
@@ -692,7 +692,7 @@ export const getPaginatedFullCusQuery = ({
FROM entities e
WHERE e.internal_customer_id = cr.internal_id
ORDER BY e.internal_id DESC
LIMIT 1000
LIMIT 300
) e ON true
GROUP BY cr.internal_id
)`

View File

@@ -216,11 +216,6 @@ export class InvoiceService {
currency: stripeInvoice.currency,
});
const atmnAmountPaid = stripeToAtmnAmount({
amount: stripeInvoice.amount_paid,
currency: stripeInvoice.currency,
});
const invoice: Invoice = {
id: generateId("inv"),
internal_customer_id: internalCustomerId,
@@ -234,7 +229,6 @@ export class InvoiceService {
// Stripe stuff
total: atmnTotal,
amount_paid: atmnAmountPaid,
refunded_amount: 0,
currency: stripeInvoice.currency,
discounts: getInvoiceDiscounts({
@@ -329,7 +323,6 @@ export class InvoiceService {
hosted_invoice_url: invoice.hosted_invoice_url,
discounts: invoice.discounts,
total: invoice.total,
amount_paid: invoice.amount_paid,
product_ids: invoice.product_ids?.length
? sql`CASE
WHEN ${invoices.product_ids} IS NULL OR cardinality(${invoices.product_ids}) = 0

View File

@@ -29,10 +29,6 @@ export const updateInvoiceFromStripe = async ({
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
}),
amount_paid: stripeToAtmnAmount({
amount: stripeInvoice.amount_paid,
currency: stripeInvoice.currency,
}),
},
});

View File

@@ -50,11 +50,6 @@ export const initInvoiceFromStripe = async ({
currency: stripeInvoice.currency,
});
const atmnAmountPaid = stripeToAtmnAmount({
amount: stripeInvoice.amount_paid,
currency: stripeInvoice.currency,
});
return {
id: generateId("inv"),
internal_customer_id: internalCustomerId,
@@ -66,7 +61,6 @@ export const initInvoiceFromStripe = async ({
status: stripeInvoice.status as string | undefined,
internal_entity_id: internalEntityId || null,
total: atmnTotal,
amount_paid: atmnAmountPaid,
currency: stripeInvoice.currency,
discounts: getInvoiceDiscounts({ expandedInvoice: stripeInvoice }),
items: autumnInvoiceItems,

View File

@@ -39,7 +39,6 @@ export const InvoiceSchema = z.object({
// Total amount of the invoice
total: z.number(),
amount_paid: z.number().nullish(),
refunded_amount: z.number().default(0),
currency: z.string(),
receipt_url: z.string().nullish(),

View File

@@ -28,7 +28,6 @@ export const invoices = pgTable(
status: text("status").notNull().default("draft"),
hosted_invoice_url: text("hosted_invoice_url"),
total: numeric({ mode: "number" }).notNull().default(0),
amount_paid: numeric({ mode: "number" }),
refunded_amount: numeric({ mode: "number" }).notNull().default(0),
currency: text("currency").notNull().default("usd"),
discounts: jsonb("discounts").$type<InvoiceDiscount>().array().default([]),

View File

@@ -8,9 +8,8 @@ import {
import {
ArrowCounterClockwiseIcon,
ArrowSquareOutIcon,
CalendarBlankIcon,
CreditCardIcon,
HashIcon,
EyeIcon,
EyeSlashIcon,
} from "@phosphor-icons/react";
import { format } from "date-fns";
import { useMemo, useState } from "react";
@@ -18,12 +17,10 @@ import { AdminHover } from "@/components/general/AdminHover";
import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button";
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
import { InfoRow } from "@/components/v2/InfoRow";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
@@ -31,6 +28,11 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { CustomerInvoiceStatus } from "../table/customer-invoices/CustomerInvoiceStatus";
import { RefundInvoiceDialog } from "./RefundInvoiceDialog";
interface InvoiceDetailSheetProps {
invoice: Invoice;
lineItems: InvoiceLineItem[];
}
type LineItemGroup = {
groupKey: string;
label: string;
@@ -58,19 +60,29 @@ const resolveFeatureName = ({
return feature?.name ?? featureId;
};
export function InvoiceDetailSheet() {
const sheetData = useSheetStore((s) => s.data);
const invoice = sheetData?.invoice as Invoice | undefined;
const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? [];
export function InvoiceDetailSheet({
invoice,
lineItems,
}: InvoiceDetailSheetProps) {
const { stripeAccount } = useOrgStripeQuery();
const { features } = useFeaturesQuery();
const { products } = useProductsQuery();
const env = useEnv();
const [showDescriptions, setShowDescriptions] = useState(false);
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
const { customer } = useCusQuery();
const isStripeCustomer = customer?.processor?.type === ProcessorType.Stripe;
const isFullyRefunded =
invoice.refunded_amount > 0 &&
invoice.refunded_amount >= Math.abs(invoice.total);
const canRefund =
isStripeCustomer &&
invoice.status === InvoiceStatus.Paid &&
!isFullyRefunded;
// Group line items: first by product_id, then within each product by
// stripe_subscription_item_id (for tiered) or individually.
// Sort: base price first, then alphabetically by label within each product.
const productGroups = useMemo(() => {
// Step 1: bucket line items by product_id
const byProduct = new Map<string, InvoiceLineItem[]>();
@@ -134,17 +146,6 @@ export function InvoiceDetailSheet() {
return result;
}, [lineItems, features, products]);
if (!invoice) return null;
const isStripeCustomer = customer?.processor?.type === ProcessorType.Stripe;
const refundableAmount = Math.abs(invoice.amount_paid ?? invoice.total);
const isFullyRefunded =
invoice.refunded_amount > 0 && invoice.refunded_amount >= refundableAmount;
const canRefund =
isStripeCustomer &&
invoice.status === InvoiceStatus.Paid &&
!isFullyRefunded;
const formatAmount = (amount: number, currency: string) => {
const absAmount = Math.abs(amount);
return new Intl.NumberFormat("en-US", {
@@ -188,14 +189,13 @@ export function InvoiceDetailSheet() {
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="flex flex-col h-full">
<SheetHeader
title={
<div className="flex items-center gap-2">
<span>Invoice</span>
<CustomerInvoiceStatus
status={invoice.status ?? "paid"}
amountPaid={invoice.amount_paid}
total={invoice.total}
refundedAmount={invoice.refunded_amount}
/>
@@ -204,86 +204,91 @@ export function InvoiceDetailSheet() {
description={`${formatDate(invoice.created_at)}${formatSignedAmount(invoice.total, invoice.currency)}`}
/>
{productGroups.map((productGroup) => (
<SheetSection
key={productGroup.productId ?? "unknown"}
withSeparator={true}
>
<div className="mb-2">
<span className="text-xs font-medium text-t3 truncate">
{productGroup.productName}
</span>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* Product groups with line items */}
{productGroups.map((productGroup) => (
<SheetSection
key={productGroup.productId ?? "unknown"}
withSeparator={true}
>
{/* Product header with description toggle */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-t3 truncate">
{productGroup.productName}
</span>
<button
type="button"
onClick={() => setShowDescriptions((prev) => !prev)}
className="text-t4 hover:text-t2 transition-colors p-0.5 rounded"
title={
showDescriptions
? "Show computed display"
: "Show descriptions"
}
>
{showDescriptions ? (
<EyeSlashIcon size={14} />
) : (
<EyeIcon size={14} />
)}
</button>
</div>
<div className="flex flex-col gap-2">
{productGroup.lineItemGroups.map((group) => (
<LineItemGroupRow
key={group.groupKey}
group={group}
formatAmount={formatAmount}
formatPeriod={formatPeriod}
currency={invoice.currency}
/>
))}
</div>
</SheetSection>
))}
{/* Line item groups within this product */}
<div className="flex flex-col gap-2">
{productGroup.lineItemGroups.map((group) => (
<LineItemGroupRow
key={group.groupKey}
group={group}
formatAmount={formatAmount}
formatPeriod={formatPeriod}
currency={invoice.currency}
showDescriptions={showDescriptions}
/>
))}
</div>
</SheetSection>
))}
<SheetSection withSeparator={true}>
<div className="flex flex-col gap-1.5">
{/* Invoice Total */}
<SheetSection withSeparator={true}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Total</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatSignedAmount(invoice.total, invoice.currency)}
</span>
</div>
{invoice.amount_paid != null && invoice.amount_paid !== invoice.total && (
<div className="flex items-center justify-between">
<span className="text-sm text-t2">Amount Paid</span>
<span className="text-sm tabular-nums text-t2">
{formatSignedAmount(invoice.amount_paid, invoice.currency)}
</span>
</div>
)}
</div>
</SheetSection>
</SheetSection>
<SheetSection withSeparator={false}>
<div className="space-y-3">
<InfoRow
icon={<HashIcon size={16} weight="duotone" />}
label="Invoice ID"
value={
{/* Invoice Details - Compact */}
<SheetSection withSeparator={false}>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-t3">Invoice ID</span>
<MiniCopyButton
text={invoice.id}
innerClassName="text-sm text-t1 font-mono"
innerClassName="text-xs text-t1 font-mono"
/>
}
/>
<InfoRow
icon={<CreditCardIcon size={16} weight="duotone" />}
label="Stripe ID"
value={
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-t3">Stripe ID</span>
<MiniCopyButton
text={invoice.stripe_id}
innerClassName="text-sm text-t1 font-mono"
innerClassName="text-xs text-t1 font-mono"
/>
}
/>
<InfoRow
icon={<CalendarBlankIcon size={16} weight="duotone" />}
label="Created"
value={
<MiniCopyButton
text={format(new Date(invoice.created_at), "MMM d, yyyy HH:mm")}
innerClassName="text-sm text-t1"
/>
}
/>
</div>
</SheetSection>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-t3">Created</span>
<span className="text-t1">
{format(new Date(invoice.created_at), "MMM d, yyyy HH:mm")}
</span>
</div>
</div>
</SheetSection>
</div>
<div className="sticky bottom-0 p-4 flex gap-2 bg-card">
{/* Footer */}
<div className="p-4 flex gap-2 border-t border-border/40">
<Button
variant="secondary"
className="flex-1"
@@ -319,11 +324,13 @@ function LineItemGroupRow({
formatAmount,
formatPeriod,
currency,
showDescriptions,
}: {
group: LineItemGroup;
formatAmount: (amount: number, currency: string) => string;
formatPeriod: (startMs: number | null, endMs: number | null) => string | null;
currency: string;
showDescriptions: boolean;
}) {
const isSingleItem = group.items.length === 1;
const firstItem = group.items[0];
@@ -359,19 +366,20 @@ function LineItemGroupRow({
{/* Group header with label and total */}
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{totalQuantity > 0 && (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty: {totalQuantity}
</Badge>
)}
</div>
{firstItem.description && (
{showDescriptions ? (
<span className="text-xs text-t3">{firstItem.description}</span>
) : (
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{totalQuantity > 0 && (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty: {totalQuantity}
</Badge>
)}
</div>
)}
{period && <span className="text-xs text-t4">{period}</span>}
</div>
@@ -380,6 +388,7 @@ function LineItemGroupRow({
</span>
</div>
{/* Tier breakdown - each row has own hover */}
<div className="mt-1 ml-3 flex flex-col gap-0.5">
{group.items.map((item) => (
<TierRow
@@ -388,6 +397,7 @@ function LineItemGroupRow({
hoverTexts={getLineItemHoverTexts(item)}
formatAmount={formatAmount}
currency={currency}
showDescriptions={showDescriptions}
/>
))}
</div>
@@ -404,25 +414,26 @@ function LineItemGroupRow({
<div className="flex flex-col py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{(!group.isBasePrice && firstItem.total_quantity) ||
(group.isBasePrice &&
firstItem.stripe_quantity &&
firstItem.stripe_quantity > 1) ? (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty:{" "}
{group.isBasePrice
? firstItem.stripe_quantity
: firstItem.total_quantity}
</Badge>
) : null}
</div>
{firstItem.description && (
{showDescriptions ? (
<span className="text-xs text-t3">{firstItem.description}</span>
) : (
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{(!group.isBasePrice && firstItem.total_quantity) ||
(group.isBasePrice &&
firstItem.stripe_quantity &&
firstItem.stripe_quantity > 1) ? (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty:{" "}
{group.isBasePrice
? firstItem.stripe_quantity
: firstItem.total_quantity}
</Badge>
) : null}
</div>
)}
{period && <span className="text-xs text-t4">{period}</span>}
</div>
@@ -472,18 +483,23 @@ function TierRow({
hoverTexts,
formatAmount,
currency,
showDescriptions,
}: {
item: InvoiceLineItem;
hoverTexts: { key: string; value: string }[];
formatAmount: (amount: number, currency: string) => string;
currency: string;
showDescriptions: boolean;
}) {
const isRefund = item.direction === "refund";
const quantityLabel = item.total_quantity ? `${item.total_quantity}` : "";
return (
<AdminHover asChild texts={hoverTexts}>
<div className="flex items-center justify-between text-xs text-t3">
<span>{item.description}</span>
<span>
{showDescriptions ? item.description : `${quantityLabel} units`}
</span>
<span
className={cn(
"tabular-nums",

View File

@@ -70,8 +70,6 @@ export function RefundInvoiceDialog({
},
});
const refundableAmount = invoice.amount_paid ?? invoice.total;
const handleSubmit = () => {
if (mode === "partial") {
const parsed = Number.parseFloat(amount);
@@ -79,8 +77,8 @@ export function RefundInvoiceDialog({
toast.error("Please enter a valid refund amount");
return;
}
if (parsed > refundableAmount) {
toast.error("Refund amount cannot exceed the amount paid");
if (parsed > invoice.total) {
toast.error("Refund amount cannot exceed invoice total");
return;
}
}
@@ -88,7 +86,7 @@ export function RefundInvoiceDialog({
};
const formattedTotal = formatAmount({
amount: refundableAmount,
amount: invoice.total,
currency: invoice.currency,
minFractionDigits: 2,
amountFormatOptions: {
@@ -102,7 +100,7 @@ export function RefundInvoiceDialog({
<DialogHeader>
<DialogTitle>Refund Invoice</DialogTitle>
<DialogDescription>
Amount paid: {formattedTotal} {invoice.currency.toUpperCase()}
Invoice total: {formattedTotal} {invoice.currency.toUpperCase()}
</DialogDescription>
</DialogHeader>
@@ -131,7 +129,7 @@ export function RefundInvoiceDialog({
type="number"
min="0.01"
step="0.01"
max={refundableAmount}
max={invoice.total}
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}

View File

@@ -24,17 +24,14 @@ const statusConfig = {
};
const getRefundStatus = ({
amountPaid,
total,
refundedAmount,
}: {
amountPaid: number;
total: number;
refundedAmount: number;
}): { color: string; label: string } | null => {
if (refundedAmount <= 0) return null;
const refundableAmount = Math.abs(amountPaid ?? total);
if (refundedAmount >= refundableAmount) {
if (refundedAmount >= Math.abs(total)) {
return {
color: "bg-amber-500 dark:bg-amber-600",
label: "Fully Refunded",
@@ -48,12 +45,10 @@ const getRefundStatus = ({
export function CustomerInvoiceStatus({
status,
amountPaid,
total,
refundedAmount,
}: {
status: InvoiceStatus | null | undefined;
amountPaid?: number | null;
total?: number;
refundedAmount?: number;
}) {
@@ -64,11 +59,7 @@ export function CustomerInvoiceStatus({
status === InvoiceStatus.Paid &&
total !== undefined &&
refundedAmount !== undefined
? getRefundStatus({
amountPaid: amountPaid ?? total,
total,
refundedAmount,
})
? getRefundStatus({ total, refundedAmount })
: null;
const config = refundStatus ?? statusConfig[status];

View File

@@ -32,11 +32,10 @@ export const CustomerInvoicesColumns = [
accessorKey: "total",
cell: ({ row }: { row: Row<CustomerInvoice> }) => {
const invoice = row.original;
const displayTotal = invoice.amount_paid ?? invoice.total;
const discountAmount = getTotalDiscountAmount(invoice);
return (
<div>
{displayTotal.toFixed(2)} {invoice.currency.toUpperCase()}
{invoice.total.toFixed(2)} {invoice.currency.toUpperCase()}
{discountAmount > 0 && (
<span className="text-t3"> (-{discountAmount.toFixed(2)})</span>
)}
@@ -52,7 +51,6 @@ export const CustomerInvoicesColumns = [
return (
<CustomerInvoiceStatus
status={invoice.status}
amountPaid={invoice.amount_paid}
total={invoice.total}
refundedAmount={invoice.refunded_amount}
/>

View File

@@ -36,7 +36,7 @@ export function CustomerInvoicesTable() {
);
const { lineItemsByInvoiceId } = useInvoiceLineItemsQuery({
customerId: customer?.id || customer?.internal_id,
customerId: customer?.internal_id || customer?.id,
invoiceIds,
enabled: invoiceIds.length > 0,
});

View File

@@ -1,3 +1,4 @@
import type { Invoice, InvoiceLineItem } from "@autumn/shared";
import { AnimatePresence, motion } from "motion/react";
import { SheetContainer } from "@/components/v2/sheets/InlineSheet";
import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton";
@@ -31,6 +32,7 @@ import { SHEET_ANIMATION } from "./customerAnimations";
export function CustomerSheets() {
const isMobile = useIsMobile();
const sheetType = useSheetStore((s) => s.type);
const sheetData = useSheetStore((s) => s.data);
const closeSheet = useSheetStore((s) => s.closeSheet);
const closeBalanceSheet = useCustomerBalanceSheetStore((s) => s.closeSheet);
useSheetEscapeHandler();
@@ -62,8 +64,12 @@ export function CustomerSheets() {
return <BalanceDeleteSheet />;
case "balance-create":
return <BalanceCreateSheet />;
case "invoice-detail":
return <InvoiceDetailSheet />;
case "invoice-detail": {
const invoice = sheetData?.invoice as Invoice | undefined;
const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? [];
if (!invoice) return null;
return <InvoiceDetailSheet invoice={invoice} lineItems={lineItems} />;
}
case "sync-stripe":
return <SyncStripeSheet />;
case "billing-auto-topup-add":