From 65329398fdaa4d5ee16eead5afac28d2aa2831f7 Mon Sep 17 00:00:00 2001
From: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
Date: Wed, 7 Aug 2024 07:55:54 -0400
Subject: [PATCH] Support type-checking on spreadsheet fields (part 2) (#3095)

* Add rollover budget typing

* Fix lint

* Add release notes

* Fix strict typechecking
---
 .../budget/rollover/BalanceMenu.tsx           |  9 ++--
 .../budget/rollover/BalanceMovementMenu.tsx   |  7 +--
 .../budget/rollover/RolloverComponents.tsx    | 53 ++++++++++++++-----
 .../rollover/budgetsummary/ToBudget.tsx       | 11 ++--
 .../rollover/budgetsummary/ToBudgetAmount.tsx | 17 ++++--
 .../rollover/budgetsummary/ToBudgetMenu.tsx   |  6 +--
 .../rollover/budgetsummary/TotalsList.tsx     | 14 ++---
 .../src/components/modals/HoldBufferModal.tsx |  4 +-
 .../modals/RolloverBudgetMenuModal.tsx        |  6 ++-
 .../modals/RolloverBudgetSummaryModal.tsx     |  4 +-
 .../src/components/spreadsheet/CellValue.tsx  | 12 +++--
 .../src/components/spreadsheet/index.ts       | 27 +++++++++-
 .../desktop-client/src/components/table.tsx   |  2 +-
 packages/loot-core/src/client/queries.ts      | 27 ++++++----
 upcoming-release-notes/3095.md                |  6 +++
 15 files changed, 147 insertions(+), 58 deletions(-)
 create mode 100644 upcoming-release-notes/3095.md

diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
index a0df8edb6..550f150e5 100644
--- a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx
@@ -3,7 +3,8 @@ import React, { type ComponentPropsWithoutRef } from 'react';
 import { rolloverBudget } from 'loot-core/src/client/queries';
 
 import { Menu } from '../../common/Menu';
-import { useSheetValue } from '../../spreadsheet/useSheetValue';
+
+import { useRolloverSheetValue } from './RolloverComponents';
 
 type BalanceMenuProps = Omit<
   ComponentPropsWithoutRef<typeof Menu>,
@@ -22,8 +23,10 @@ export function BalanceMenu({
   onCover,
   ...props
 }: BalanceMenuProps) {
-  const carryover = useSheetValue(rolloverBudget.catCarryover(categoryId));
-  const balance = useSheetValue(rolloverBudget.catBalance(categoryId));
+  const carryover = useRolloverSheetValue(
+    rolloverBudget.catCarryover(categoryId),
+  );
+  const balance = useRolloverSheetValue(rolloverBudget.catBalance(categoryId));
   return (
     <Menu
       {...props}
diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
index ad87c8be6..3b8c24408 100644
--- a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx
@@ -2,10 +2,9 @@ import React, { useState } from 'react';
 
 import { rolloverBudget } from 'loot-core/src/client/queries';
 
-import { useSheetValue } from '../../spreadsheet/useSheetValue';
-
 import { BalanceMenu } from './BalanceMenu';
 import { CoverMenu } from './CoverMenu';
+import { useRolloverSheetValue } from './RolloverComponents';
 import { TransferMenu } from './TransferMenu';
 
 type BalanceMovementMenuProps = {
@@ -21,7 +20,9 @@ export function BalanceMovementMenu({
   onBudgetAction,
   onClose = () => {},
 }: BalanceMovementMenuProps) {
-  const catBalance = useSheetValue(rolloverBudget.catBalance(categoryId));
+  const catBalance = useRolloverSheetValue(
+    rolloverBudget.catBalance(categoryId),
+  );
   const [menu, setMenu] = useState('menu');
 
   return (
diff --git a/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx b/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx
index 7cd187d60..49aa03054 100644
--- a/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx
@@ -11,15 +11,44 @@ import { Button } from '../../common/Button2';
 import { Popover } from '../../common/Popover';
 import { Text } from '../../common/Text';
 import { View } from '../../common/View';
-import { CellValue } from '../../spreadsheet/CellValue';
+import { type Binding, type SheetFields } from '../../spreadsheet';
+import { CellValue, type CellValueProps } from '../../spreadsheet/CellValue';
 import { useFormat } from '../../spreadsheet/useFormat';
-import { Row, Field, SheetCell } from '../../table';
+import { useSheetName } from '../../spreadsheet/useSheetName';
+import { useSheetValue } from '../../spreadsheet/useSheetValue';
+import { Row, Field, SheetCell, type SheetCellProps } from '../../table';
 import { BalanceWithCarryover } from '../BalanceWithCarryover';
 import { makeAmountGrey } from '../util';
 
 import { BalanceMovementMenu } from './BalanceMovementMenu';
 import { BudgetMenu } from './BudgetMenu';
 
+export function useRolloverSheetName<
+  FieldName extends SheetFields<'rollover-budget'>,
+>(binding: Binding<'rollover-budget', FieldName>) {
+  return useSheetName(binding);
+}
+
+export function useRolloverSheetValue<
+  FieldName extends SheetFields<'rollover-budget'>,
+>(binding: Binding<'rollover-budget', FieldName>) {
+  return useSheetValue(binding);
+}
+
+export const RolloverCellValue = <
+  FieldName extends SheetFields<'rollover-budget'>,
+>(
+  props: CellValueProps<'rollover-budget', FieldName>,
+) => {
+  return <CellValue {...props} />;
+};
+
+const RolloverSheetCell = <FieldName extends SheetFields<'rollover-budget'>>(
+  props: SheetCellProps<'rollover-budget', FieldName>,
+) => {
+  return <SheetCell {...props} />;
+};
+
 const headerLabelStyle: CSSProperties = {
   flex: 1,
   padding: '0 5px',
@@ -40,7 +69,7 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
     >
       <View style={headerLabelStyle}>
         <Text style={{ color: theme.tableHeaderText }}>Budgeted</Text>
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.totalBudgeted}
           type="financial"
           style={{ color: theme.tableHeaderText, fontWeight: 600 }}
@@ -51,7 +80,7 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
       </View>
       <View style={headerLabelStyle}>
         <Text style={{ color: theme.tableHeaderText }}>Spent</Text>
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.totalSpent}
           type="financial"
           style={{ color: theme.tableHeaderText, fontWeight: 600 }}
@@ -59,7 +88,7 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
       </View>
       <View style={headerLabelStyle}>
         <Text style={{ color: theme.tableHeaderText }}>Balance</Text>
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.totalBalance}
           type="financial"
           style={{ color: theme.tableHeaderText, fontWeight: 600 }}
@@ -93,7 +122,7 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
 
   return (
     <View style={{ flex: 1, flexDirection: 'row' }}>
-      <SheetCell
+      <RolloverSheetCell
         name="budgeted"
         width="flex"
         textAlign="right"
@@ -103,7 +132,7 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
           type: 'financial',
         }}
       />
-      <SheetCell
+      <RolloverSheetCell
         name="spent"
         width="flex"
         textAlign="right"
@@ -113,7 +142,7 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
           type: 'financial',
         }}
       />
-      <SheetCell
+      <RolloverSheetCell
         name="balance"
         width="flex"
         textAlign="right"
@@ -249,7 +278,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
             </Popover>
           </View>
         ) : null}
-        <SheetCell
+        <RolloverSheetCell
           name="budget"
           exposed={editing}
           focused={editing}
@@ -299,7 +328,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
           data-testid="category-month-spent"
           onClick={() => onShowActivity(category.id, month)}
         >
-          <CellValue
+          <RolloverCellValue
             binding={rolloverBudget.catSumAmount(category.id)}
             type="financial"
             getStyle={makeAmountGrey}
@@ -353,7 +382,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
 export function IncomeGroupMonth() {
   return (
     <View style={{ flex: 1 }}>
-      <SheetCell
+      <RolloverSheetCell
         name="received"
         width="flex"
         textAlign="right"
@@ -400,7 +429,7 @@ export function IncomeCategoryMonth({
         }}
       >
         <span onClick={() => onShowActivity(category.id, month)}>
-          <CellValue
+          <RolloverCellValue
             binding={rolloverBudget.catSumAmount(category.id)}
             type="financial"
             style={{
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
index 538cb59d3..3a13460b8 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx
@@ -5,9 +5,9 @@ import { rolloverBudget } from 'loot-core/src/client/queries';
 import { type CSSProperties } from '../../../../style';
 import { Popover } from '../../../common/Popover';
 import { View } from '../../../common/View';
-import { useSheetValue } from '../../../spreadsheet/useSheetValue';
 import { CoverMenu } from '../CoverMenu';
 import { HoldMenu } from '../HoldMenu';
+import { useRolloverSheetValue } from '../RolloverComponents';
 import { TransferMenu } from '../TransferMenu';
 
 import { ToBudgetAmount } from './ToBudgetAmount';
@@ -31,11 +31,16 @@ export function ToBudget({
 }: ToBudgetProps) {
   const [menuOpen, setMenuOpen] = useState<string | null>(null);
   const triggerRef = useRef(null);
-  const sheetValue = useSheetValue({
+  const sheetValue = useRolloverSheetValue({
     name: rolloverBudget.toBudget,
     value: 0,
   });
-  const availableValue = parseInt(sheetValue);
+  const availableValue = sheetValue;
+  if (typeof availableValue !== 'number') {
+    throw new Error(
+      'Expected availableValue to be a number but got ' + availableValue,
+    );
+  }
   const isMenuOpen = Boolean(menuOpen);
 
   return (
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx
index c9cd0e843..befa00180 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx
@@ -10,8 +10,10 @@ import { Tooltip } from '../../../common/Tooltip';
 import { View } from '../../../common/View';
 import { PrivacyFilter } from '../../../PrivacyFilter';
 import { useFormat } from '../../../spreadsheet/useFormat';
-import { useSheetName } from '../../../spreadsheet/useSheetName';
-import { useSheetValue } from '../../../spreadsheet/useSheetValue';
+import {
+  useRolloverSheetName,
+  useRolloverSheetValue,
+} from '../RolloverComponents';
 
 import { TotalsList } from './TotalsList';
 
@@ -30,13 +32,18 @@ export function ToBudgetAmount({
   onClick,
   isTotalsListTooltipDisabled = false,
 }: ToBudgetAmountProps) {
-  const sheetName = useSheetName(rolloverBudget.toBudget);
-  const sheetValue = useSheetValue({
+  const sheetName = useRolloverSheetName(rolloverBudget.toBudget);
+  const sheetValue = useRolloverSheetValue({
     name: rolloverBudget.toBudget,
     value: 0,
   });
   const format = useFormat();
-  const availableValue = parseInt(sheetValue);
+  const availableValue = sheetValue;
+  if (typeof availableValue !== 'number') {
+    throw new Error(
+      'Expected availableValue to be a number but got ' + availableValue,
+    );
+  }
   const num = isNaN(availableValue) ? 0 : availableValue;
   const isNegative = num < 0;
 
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
index 37ddf5507..348333476 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx
@@ -3,7 +3,7 @@ import React, { type ComponentPropsWithoutRef } from 'react';
 import { rolloverBudget } from 'loot-core/client/queries';
 
 import { Menu } from '../../../common/Menu';
-import { useSheetValue } from '../../../spreadsheet/useSheetValue';
+import { useRolloverSheetValue } from '../RolloverComponents';
 
 type ToBudgetMenuProps = Omit<
   ComponentPropsWithoutRef<typeof Menu>,
@@ -21,8 +21,8 @@ export function ToBudgetMenu({
   onResetHoldBuffer,
   ...props
 }: ToBudgetMenuProps) {
-  const toBudget = useSheetValue(rolloverBudget.toBudget);
-  const forNextMonth = useSheetValue(rolloverBudget.forNextMonth);
+  const toBudget = useRolloverSheetValue(rolloverBudget.toBudget);
+  const forNextMonth = useRolloverSheetValue(rolloverBudget.forNextMonth);
   const items = [
     ...(toBudget > 0
       ? [
diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/TotalsList.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/TotalsList.tsx
index fe3fb0b81..a007d6026 100644
--- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/TotalsList.tsx
+++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/TotalsList.tsx
@@ -7,8 +7,8 @@ import { AlignedText } from '../../../common/AlignedText';
 import { Block } from '../../../common/Block';
 import { Tooltip } from '../../../common/Tooltip';
 import { View } from '../../../common/View';
-import { CellValue } from '../../../spreadsheet/CellValue';
 import { useFormat } from '../../../spreadsheet/useFormat';
+import { RolloverCellValue } from '../RolloverComponents';
 
 type TotalsListProps = {
   prevMonthName: string;
@@ -40,7 +40,7 @@ export function TotalsList({ prevMonthName, style }: TotalsListProps) {
               <AlignedText
                 left="Income:"
                 right={
-                  <CellValue
+                  <RolloverCellValue
                     binding={rolloverBudget.totalIncome}
                     type="financial"
                     privacyFilter={false}
@@ -50,7 +50,7 @@ export function TotalsList({ prevMonthName, style }: TotalsListProps) {
               <AlignedText
                 left="From Last Month:"
                 right={
-                  <CellValue
+                  <RolloverCellValue
                     binding={rolloverBudget.fromLastMonth}
                     type="financial"
                     privacyFilter={false}
@@ -61,14 +61,14 @@ export function TotalsList({ prevMonthName, style }: TotalsListProps) {
           }
           placement="bottom end"
         >
-          <CellValue
+          <RolloverCellValue
             binding={rolloverBudget.incomeAvailable}
             type="financial"
             style={{ fontWeight: 600 }}
           />
         </Tooltip>
 
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.lastMonthOverspent}
           type="financial"
           formatter={value => {
@@ -78,7 +78,7 @@ export function TotalsList({ prevMonthName, style }: TotalsListProps) {
           style={{ fontWeight: 600, ...styles.tnum }}
         />
 
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.totalBudgeted}
           type="financial"
           formatter={value => {
@@ -88,7 +88,7 @@ export function TotalsList({ prevMonthName, style }: TotalsListProps) {
           style={{ fontWeight: 600, ...styles.tnum }}
         />
 
-        <CellValue
+        <RolloverCellValue
           binding={rolloverBudget.forNextMonth}
           type="financial"
           formatter={value => {
diff --git a/packages/desktop-client/src/components/modals/HoldBufferModal.tsx b/packages/desktop-client/src/components/modals/HoldBufferModal.tsx
index d2fe1cb4a..801c435f7 100644
--- a/packages/desktop-client/src/components/modals/HoldBufferModal.tsx
+++ b/packages/desktop-client/src/components/modals/HoldBufferModal.tsx
@@ -3,12 +3,12 @@ import React, { useState } from 'react';
 import { rolloverBudget } from 'loot-core/client/queries';
 
 import { styles } from '../../style';
+import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
 import { Button } from '../common/Button2';
 import { InitialFocus } from '../common/InitialFocus';
 import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
 import { View } from '../common/View';
 import { FieldLabel } from '../mobile/MobileForms';
-import { useSheetValue } from '../spreadsheet/useSheetValue';
 import { AmountInput } from '../util/AmountInput';
 
 type HoldBufferModalProps = {
@@ -17,7 +17,7 @@ type HoldBufferModalProps = {
 };
 
 export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
-  const available = useSheetValue(rolloverBudget.toBudget);
+  const available = useRolloverSheetValue(rolloverBudget.toBudget);
   const [amount, setAmount] = useState<number>(0);
 
   const _onSubmit = (newAmount: number) => {
diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetMenuModal.tsx
index 5bd9c2440..9dc74a2df 100644
--- a/packages/desktop-client/src/components/modals/RolloverBudgetMenuModal.tsx
+++ b/packages/desktop-client/src/components/modals/RolloverBudgetMenuModal.tsx
@@ -10,6 +10,7 @@ import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
 import { useCategory } from '../../hooks/useCategory';
 import { type CSSProperties, theme, styles } from '../../style';
 import { BudgetMenu } from '../budget/rollover/BudgetMenu';
+import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
 import {
   Modal,
   ModalCloseButton,
@@ -19,7 +20,6 @@ import {
 import { Text } from '../common/Text';
 import { View } from '../common/View';
 import { FocusableAmountInput } from '../mobile/transactions/FocusableAmountInput';
-import { useSheetValue } from '../spreadsheet/useSheetValue';
 
 type RolloverBudgetMenuModalProps = ComponentPropsWithoutRef<
   typeof BudgetMenu
@@ -42,7 +42,9 @@ export function RolloverBudgetMenuModal({
     borderTop: `1px solid ${theme.pillBorder}`,
   };
 
-  const budgeted = useSheetValue(rolloverBudget.catBudgeted(categoryId));
+  const budgeted = useRolloverSheetValue(
+    rolloverBudget.catBudgeted(categoryId),
+  );
   const category = useCategory(categoryId);
   const [amountFocused, setAmountFocused] = useState(false);
 
diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
index cbde09f1b..5abfafef6 100644
--- a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
+++ b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
@@ -8,9 +8,9 @@ import { format, sheetForMonth, prevMonth } from 'loot-core/src/shared/months';
 import { styles } from '../../style';
 import { ToBudgetAmount } from '../budget/rollover/budgetsummary/ToBudgetAmount';
 import { TotalsList } from '../budget/rollover/budgetsummary/TotalsList';
+import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
 import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
 import { NamespaceContext } from '../spreadsheet/NamespaceContext';
-import { useSheetValue } from '../spreadsheet/useSheetValue';
 
 type RolloverBudgetSummaryModalProps = {
   onBudgetAction: (month: string, action: string, arg?: unknown) => void;
@@ -23,7 +23,7 @@ export function RolloverBudgetSummaryModal({
 }: RolloverBudgetSummaryModalProps) {
   const dispatch = useDispatch();
   const prevMonthName = format(prevMonth(month), 'MMM');
-  const sheetValue = useSheetValue({
+  const sheetValue = useRolloverSheetValue({
     name: rolloverBudget.toBudget,
     value: 0,
   });
diff --git a/packages/desktop-client/src/components/spreadsheet/CellValue.tsx b/packages/desktop-client/src/components/spreadsheet/CellValue.tsx
index 7a298c3ff..ef1228c79 100644
--- a/packages/desktop-client/src/components/spreadsheet/CellValue.tsx
+++ b/packages/desktop-client/src/components/spreadsheet/CellValue.tsx
@@ -9,10 +9,13 @@ import { type FormatType, useFormat } from './useFormat';
 import { useSheetName } from './useSheetName';
 import { useSheetValue } from './useSheetValue';
 
-import { type Binding } from '.';
+import { type Binding, type SheetNames, type SheetFields } from '.';
 
-type CellValueProps = {
-  binding: string | Binding;
+export type CellValueProps<
+  SheetName extends SheetNames,
+  FieldName extends SheetFields<SheetName>,
+> = {
+  binding: Binding<SheetName, FieldName>;
   type?: FormatType;
   formatter?: (value) => ReactNode;
   style?: CSSProperties;
@@ -32,7 +35,8 @@ export function CellValue({
   privacyFilter,
   'data-testid': testId,
   ...props
-}: CellValueProps) {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+}: CellValueProps<any, any>) {
   const { fullSheetName } = useSheetName(binding);
   const sheetValue = useSheetValue(binding);
   const format = useFormat();
diff --git a/packages/desktop-client/src/components/spreadsheet/index.ts b/packages/desktop-client/src/components/spreadsheet/index.ts
index 5e3df0052..429e50441 100644
--- a/packages/desktop-client/src/components/spreadsheet/index.ts
+++ b/packages/desktop-client/src/components/spreadsheet/index.ts
@@ -14,6 +14,31 @@ export type Spreadsheets = {
     balanceCleared: number;
     balanceUncleared: number;
   };
+  'rollover-budget': {
+    // Common fields
+    'uncategorized-amount': number;
+    'uncategorized-balance': number;
+
+    // Rollover fields
+    'available-funds': number;
+    'last-month-overspent': number;
+    buffered: number;
+    'to-budget': number;
+    'from-last-month': number;
+    'total-budgeted': number;
+    'total-income': number;
+    'total-spent': number;
+    'total-leftover': number;
+    'group-sum-amount': number;
+    'group-budget': number;
+    'group-leftover': number;
+    budget: number;
+    'sum-amount': number;
+    leftover: number;
+    carryover: number;
+    goal: number;
+    'long-goal': number;
+  };
 };
 
 export type SheetNames = keyof Spreadsheets & string;
@@ -36,5 +61,5 @@ export type Binding<
 export const parametrizedField =
   <SheetName extends SheetNames>() =>
   <SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
-  (id: string): SheetFieldName =>
+  (id?: string): SheetFieldName =>
     `${field}-${id}` as SheetFieldName;
diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx
index 0d3317d9a..90fd3fae9 100644
--- a/packages/desktop-client/src/components/table.tsx
+++ b/packages/desktop-client/src/components/table.tsx
@@ -690,7 +690,7 @@ type SheetCellValueProps<
   >['privacyFilter'];
 };
 
-type SheetCellProps<
+export type SheetCellProps<
   SheetName extends SheetNames,
   FieldName extends SheetFields<SheetName>,
 > = ComponentProps<typeof Cell> & {
diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts
index 3c6402249..ac37e57de 100644
--- a/packages/loot-core/src/client/queries.ts
+++ b/packages/loot-core/src/client/queries.ts
@@ -3,6 +3,7 @@ import { parse as parseDate, isValid as isDateValid } from 'date-fns';
 
 import {
   parametrizedField,
+  type SheetFields,
   type Binding,
   type SheetNames,
 } from '../../../desktop-client/src/components/spreadsheet';
@@ -18,7 +19,13 @@ import { currencyToAmount, amountToInteger } from '../shared/util';
 import { type CategoryEntity, type AccountEntity } from '../types/models';
 import { type LocalPrefs } from '../types/prefs';
 
+type BudgetType<SheetName extends SheetNames> = Record<
+  string,
+  SheetFields<SheetName> | ((id: string) => SheetFields<SheetName>)
+>;
+
 const accountParametrizedField = parametrizedField<'account'>();
+const rolloverParametrizedField = parametrizedField<'rollover-budget'>();
 
 export function getAccountFilter(accountId: string, field = 'account') {
   if (accountId) {
@@ -263,19 +270,19 @@ export const rolloverBudget = {
   totalSpent: 'total-spent',
   totalBalance: 'total-leftover',
 
-  groupSumAmount: id => `group-sum-amount-${id}`,
+  groupSumAmount: rolloverParametrizedField('group-sum-amount'),
   groupIncomeReceived: 'total-income',
 
-  groupBudgeted: id => `group-budget-${id}`,
-  groupBalance: id => `group-leftover-${id}`,
+  groupBudgeted: rolloverParametrizedField('group-budget'),
+  groupBalance: rolloverParametrizedField('group-leftover'),
 
-  catBudgeted: id => `budget-${id}`,
-  catSumAmount: id => `sum-amount-${id}`,
-  catBalance: id => `leftover-${id}`,
-  catCarryover: id => `carryover-${id}`,
-  catGoal: id => `goal-${id}`,
-  catLongGoal: id => `long-goal-${id}`,
-};
+  catBudgeted: rolloverParametrizedField('budget'),
+  catSumAmount: rolloverParametrizedField('sum-amount'),
+  catBalance: rolloverParametrizedField('leftover'),
+  catCarryover: rolloverParametrizedField('carryover'),
+  catGoal: rolloverParametrizedField('goal'),
+  catLongGoal: rolloverParametrizedField('long-goal'),
+} satisfies BudgetType<'rollover-budget'>;
 
 export const reportBudget = {
   totalBudgetedExpense: 'total-budgeted',
diff --git a/upcoming-release-notes/3095.md b/upcoming-release-notes/3095.md
new file mode 100644
index 000000000..d31dd21d2
--- /dev/null
+++ b/upcoming-release-notes/3095.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [jfdoming]
+---
+
+Support type-checking on spreadsheet fields (part 2)
-- 
GitLab