Income and Expense Report with One Formula

How would you like to create complex, dynamic, fast performance Tiller sheets with useful reports using just one formula? I’ve been experimenting with a new way to build sheets with advanced formulas like LET() and some help from Google’s Gemini AI.

In the past, complex sheets would require a lot of hidden columns and helper formulas.

To show how this works, I’ll show you how you can build an Income And Expense report for any selected date range. You will get a Total Income, Total Expense and Net Income summary plus Income and Expense lists sorted by Amount.

If there is more interest, i’ll show some other one formula examples including a dynamic Transactions Report and more.

Rather than copying in a sheet, i’ll show you how to set it up with a few simple steps.

Setup

The approach requires setting up a new sheet and making sure your Transactions sheet has the columns it needs.

Create a New Sheet

  1. Create a new sheet in your Tiller Spreadsheet.
  2. In cell A1, name it “Income and Expense Statement.”
  3. In cell A2, type “Start Date.”
  4. In cell B2, enter a starting date, such as 1/1/26.
  5. In cell A3, type “End Date.”
  6. In cell B3, enter an ending date, such as 1/31/26.

Updating your Transaction Sheet

For the formula to work, you need to have these standard Columns in your Transactions Sheet: Date, Description, Category, Amount, and Account.

You will also need 2 extra lookup columns if you don’t have them already.

The column “Type” looks up the transactions Type (Income/Expense/Transfer) based on the Category.

The column “Group” looks up the transaction Group based on the Category.

If you already have Type and Group columns in your Transaction sheet, jump to the The Formula section below. If you don’t, add them using these instructions:

Check your Categories sheet to find the columns for Categories (usually A), Group (usually B), and Type (usually C). If your Categories sheet is different, map your column letters to where A, B, and C are in the formulas below.

If that’s a match, use this formula in row 1 in your Transaction sheet to the right of your existing columns for the new Type column.

=ARRAYFORMULA(IF(ROW(D:D)=1,"Type",XLOOKUP(D:D,Categories!A:A,Categories!C:C,"")))

Use with formula for your Group column.

=ARRAYFORMULA(IF(ROW(D:D)=1,"Group",XLOOKUP(D:D,Categories!A:A,Categories!B:B,"")))

The Formula

Now comes the key formula part. You don’t need to understand it but if you do, you (or Gemini AI) can build very useful custom sheets. I breakdown the formula later in this post if you are interested.

Put this formula in A5. (Make sure you copy the whole formula. You might need to scroll down in the formula to copy it all. If you cursor over the formula, you will see the Copy icon in the upper right. That’s the best way to copy it to your clipboard and then to the Google sheet.)

=LET(
  start_date, B2, 
  end_date, B3,
  h, Transactions!1:1,
  data, Transactions!A:Z,
  
  col_date, XMATCH("Date", h),
  col_cat,  XMATCH("Category", h),
  col_amt,  XMATCH("Amount", h),
  col_type, XMATCH("Type", h),

  get_col, LAMBDA(idx, INDEX(data, 0, idx)),

  total_inc, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Income"),
  total_exp, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Expense"),
  net_inc, total_inc + total_exp,

  c_cat_let, SUBSTITUTE(ADDRESS(1, col_cat, 4), "1", ""),
  c_amt_let, SUBSTITUTE(ADDRESS(1, col_amt, 4), "1", ""),
  c_date_let, SUBSTITUTE(ADDRESS(1, col_date, 4), "1", ""),
  c_type_let, SUBSTITUTE(ADDRESS(1, col_type, 4), "1", ""),

  summary, {
    "Summary", "Amount";
    "Total Income", total_inc;
    "Total Expense", total_exp;
    "Net Income", net_inc;
    "", ""
  },

  income_list, QUERY(Transactions!A:Z, 
    "SELECT " & c_cat_let & ", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Income' 
     GROUP BY " & c_cat_let & " 
     ORDER BY SUM(" & c_amt_let & ") DESC 
     LABEL " & c_cat_let & " 'Income by Category', SUM(" & c_amt_let & ") 'Total Amount'"),

  expense_list, QUERY(Transactions!A:Z, 
    "SELECT " & c_cat_let & ", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Expense' 
     GROUP BY " & c_cat_let & " 
     ORDER BY SUM(" & c_amt_let & ") ASC 
     LABEL " & c_cat_let & " 'Expense by Category', SUM(" & c_amt_let & ") 'Total Amount'"),

  VSTACK(summary, income_list, {"", ""}, expense_list)
)

You should see the report.

I usually then remove the blank columns and rows (leaving some extra rows) to increase performance.

Formatting Tricks

To make the sheet look better and easier to read, now add some formatting.

  1. Make the A1 title bold
  2. Give the dates (the only fields you need to change) in B2 and B3 a light green background.
  3. Highlight the Income by Category row by selecting selecting add Format > Conditional Formatting with these setting:
    1. Apply to range A10:B
    2. Format Rules: Custom formula is =$A10=“Income by Category”
    3. Formatting Style: Light green background
  4. Highlight the Expense by Category row, by changing the rules from “Income by Category” to “Expense by Category” and style it light red.

That method of highlighting the rows works even if you add or remove the number of categories you have. This makes the formatting rules dynamically adjustable.

Options

In this example, i didn’t add the Category sheet feature to Hide From Report certain categories. But that can be done by adjusting the formula.

Sometimes, Gemini added comments to the formula when I was improving it. While comments are helpful, they caused errors and broke the formula. It seems Google Sheets doesn’t support standard comments in these formulas and Gemini doesn’t know that.

Important Note

I haven’t tested this on many sheets so it is possible this might not work for everyone. But if you have the correct inputs, i think it will work.

The formula itself only requires these 4 inputs:
B2 - date
B3 - date
Transactions!1:1 - the list of Column Headers in your Transactions sheet row 1
Transactions!A:Z - your Transactions sheet. If you have less than Z columns, you might need to adjust the Z to your last column. I’ve tried it with less columns and it worked thought.

If you don’t have those inputs, it won’t work. Also, if it doesn’t find any transactions in the data range, you will see errors. (There are ways to change the formula to improve how those errors display but I haven’t done that yet.)

Formula Breakdown

If you want to understand the formula, here is a quick summary:

=LET(
  start_date, B2, 
  end_date, B3,
  h, Transactions!1:1,
  data, Transactions!A:Z,
  
  col_date, XMATCH("Date", h),
  col_cat,  XMATCH("Category", h),
  col_amt,  XMATCH("Amount", h),
  col_type, XMATCH("Type", h),

  get_col, LAMBDA(idx, INDEX(data, 0, idx)),

The above section sets up the data sources, for the start date, end date, Transaction row headers and all the transaction data. It also figures out where the Date, Category, Amount and Type columns are.

  total_inc, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Income"),
  total_exp, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Expense"),
  net_inc, total_inc + total_exp,

This calculates the totals for income, expense and net income.

  c_cat_let, SUBSTITUTE(ADDRESS(1, col_cat, 4), "1", ""),
  c_amt_let, SUBSTITUTE(ADDRESS(1, col_amt, 4), "1", ""),
  c_date_let, SUBSTITUTE(ADDRESS(1, col_date, 4), "1", ""),
  c_type_let, SUBSTITUTE(ADDRESS(1, col_type, 4), "1", ""),

This figures out the column letter for those 4 columns so it can use them in the query formula below.

  summary, {
    "Summary", "Amount";
    "Total Income", total_inc;
    "Total Expense", total_exp;
    "Net Income", net_inc;
    "", ""
  },

This creates the Summary section at the top.

  income_list, QUERY(Transactions!A:Z, 
    "SELECT " & c_cat_let & ", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Income' 
     GROUP BY " & c_cat_let & " 
     ORDER BY SUM(" & c_amt_let & ") DESC 
     LABEL " & c_cat_let & " 'Income by Category', SUM(" & c_amt_let & ") 'Total Amount'"),

This query gets the sorted Income list.

  expense_list, QUERY(Transactions!A:Z, 
    "SELECT " & c_cat_let & ", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Expense' 
     GROUP BY " & c_cat_let & " 
     ORDER BY SUM(" & c_amt_let & ") ASC 
     LABEL " & c_cat_let & " 'Expense by Category', SUM(" & c_amt_let & ") 'Total Amount'"),

This query gets the sorted Expense list.

  VSTACK(summary, income_list, {"", ""}, expense_list)
)

VSTACK is used to create the different sections in the correct order. It also generates a blank line between the income and expense sections.

What’s Next

Give it a try. Let us know what you think in the comments. I’ll share some other examples if there is interest.

If you want to understand the formulas better, you can read up about the functions; LET and VSTACK.

3 Likes

This is an example of one of my favorite ways to construct formulas :slight_smile:

It looks like the formula has stylized quotes in it:
Error Formula parse error.

1 Like

Thanks for the feedback and pointing out the parse error @Mark.S

I have updated the formulas to fix the stylized quotes problem.

If anyone tried this earlier, try it again. Quotes need to be straight up and down like this ", not start and end quotes that are slightly angled. Something this is hard to notice.

1 Like

The updated formula is working for me now, thanks!

This formula will break with any Transactions sheet row insertion operations above row 2, including Manual Transaction. For example, the D2:D range will shift to D3:D after one row insertion.

One option would be to wrap D2:D inside an INDIRECT string: INDIRECT("D2:D")

={"Type";ARRAYFORMULA(XLOOKUP(INDIRECT("D2:D"),Categories!A:A,Categories!C:C,""))}

Or, some other options that use the full column D:D range, similar to these that you may recognize :slight_smile:

=ARRAYFORMULA(IF(ISTEXT(D:D),XLOOKUP(D:D,Categories!A:A,Categories!C:C,""),))
=ARRAYFORMULA(XLOOKUP(D:D,Categories!A:A,Categories!C:C,""))
=ARRAYFORMULA(IF(ROW(D:D)=1,"Type",XLOOKUP(D:D,Categories!A:A,Categories!C:C,"")))

Thanks again @Mark.S !

I haven’t experienced those issues myself but i totally see how that could happen.

I revised the instructions with your suggested more bulletproof lookup formulas.

I also updated the default Category rows for Type usually being column C not E.

2 Likes

Thank you for this @jono…. I put it together this morning and works very well. Please add other on formula solutions… Thanks again!

1 Like

This is really cool. I wonder if there is a way to show Categories into their Groups and also total by Group. Like this…

thanks, this is very helpful as I learn more about using Sheets!

1 Like

Hi @Cowboy13
Adjusting the formula can certainly display it the way you want to see it.

I have made a Transaction report by Group then Category with totals that I hope to share soon.

I would recommend opening the sheet and use Gemini to adjust the formulas to fit your liking. You might ask it to show you only what needs to change, since sometimes it changes things you don’t want to change.

Be very clear in your request, such as “I want to put the Group totals in Column D on each group line.”

You can also ask it to show you the whole formula. You might save a copy of working formulas just in case.

Also, I’ve noticed that Gemini sometimes adds Comment lines to the formula code, even though I tell it not to. Those comments will result in parse errors. So remove any comment lines if you see them.

Let us know how that goes.

@jono

Thanks for the feedback. I have never used Gemini to help create sheet formulas so this will be something new for me. Will let you know how it goes.

Thanks again.

1 Like

Give it a try. There might be a little learning curve.

I just clicked on the Gemini button on the upper right of Chrome Browser to get going. I then put it in it’s own window. When your sheet is open, it can understand it.

I have a feeling you (and others) will be glad you tried it.

Having some knowledge of the formulas helps. And you learn by seeing what Gemini suggests.

Looks like you need to pay $19.99 per month for Google AI Pro so I can use Gemini in Google Sheets. Not ready for that yet.

I may still play around with the formula and see if I can figure it out the old fashion way. :grin:

Hi @Cowboy13 ,

I’ve been using Gemini for free.

Do you have a Gemini button in the top right of your Chrome browser? Click on that to open the Gemini window. That window can then be popped outside the browser.

Genimi will be able to look at the open Google sheet in the browser. With the + button in the query box, you can have it look at other tabs open with other sheets.

You will have to copy and paste the formulas Gemini provides into your Sheet.

With Google AI Pro, it will automatically update the cells. With the free version, you have to copy and paste.

I see my mistake. I was using the Gemini button inside Google Sheets…

This then opens this…

I now see the Gemini button in Chrome.

Before I noticed this, I played around with the formula and was able to produce this…

I changed the formula to this…

=LET(
  start_date, I1, 
  end_date, I2,
  h, Transactions!1:1,
  data, Transactions!A:AA,
  
  col_date, XMATCH("Date", h),
  col_cat,  XMATCH("Category", h),
  col_amt,  XMATCH("Amount", h),
  col_group, xmatch("Group",h),
  col_type, XMATCH("Type", h),

  get_col, LAMBDA(idx, INDEX(data, 0, idx)),

  total_inc, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Income"),
  total_exp, SUMIFS(get_col(col_amt), get_col(col_date), ">="&start_date, get_col(col_date), "<="&end_date, get_col(col_type), "Expense"),
  net_inc, total_inc + total_exp,

  c_cat_let, SUBSTITUTE(ADDRESS(1, col_cat, 4), "1", ""),
  c_amt_let, SUBSTITUTE(ADDRESS(1, col_amt, 4), "1", ""),
  c_group_let, substitute(address(1, col_group, 4), "1", ""),
  c_date_let, SUBSTITUTE(ADDRESS(1, col_date, 4), "1", ""),
  c_type_let, SUBSTITUTE(ADDRESS(1, col_type, 4), "1", ""),

  summary, {
    "Summary", "", "Amount";
    "Total Income", "",total_inc;
    "Total Expense", "",total_exp;
    "Net Income", "",net_inc;
    "", "",""
  },

  income_list, QUERY(Transactions!A:AA, 
    "SELECT " & c_group_let & ", "& c_cat_let&", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Income' 
     GROUP BY " & c_group_let &","& c_cat_let & " 
     ORDER BY " & c_group_let & " ASC, "& c_cat_let & " ASC
     LABEL " & c_group_let & " 'Income Group',"& c_cat_let &" 'Income Category', SUM(" & c_amt_let & ") ' Amount'"),

  expense_list, QUERY(Transactions!A:AA, 
    "SELECT " & c_group_let & ", "& c_cat_let&", SUM(" & c_amt_let & ") 
     WHERE " & c_date_let & " >= date '" & TEXT(start_date, "yyyy-mm-dd") & "' 
     AND " & c_date_let & " <= date '" & TEXT(end_date, "yyyy-mm-dd") & "' 
     AND " & c_type_let & " = 'Expense' 
     GROUP BY " & c_group_let &","& c_cat_let & " 
     ORDER BY " & c_group_let & " ASC, "& c_cat_let & " ASC   
     LABEL " & c_group_let & " 'Expense Group',"& c_cat_let &" 'Expense Category', SUM(" & c_amt_let & ") 'Amount'"),

  VSTACK(summary, {"Income","",""},income_list, {"", "", ""}, {"Expense","",""},expense_list)
)

Basically had to add the col_group types and insert them into the Query. Then a few others formatting tweaks to align the columns.

The Groups and Categories are in separate columns, which is not horrible, but not what I would like. I can’t think of any way a Query would be able to intersperse Groups and Categories in the same column without building a separate reference that does the layout (like I do now).

Maybe Gemini can figure this out though.

1 Like

@Cowboy13 ,
If you ask Gemini to do something like:

For each Group, give it is own row, with the Group name in column H. Then start the Categories in that group on the next row. For Categories, Col H should be blank and Col J should be the Category name.

After the last category in a row, skip a row. Then show the next Group row if any.

The Group row should contain the Group total in Col K.

You might also suggest putting the Row type (Group or Category or blank) in Col L. Then you can format the rows based on the value in L.

It does take some tweaking but you can request very specific things.
Then you can see hope it handles this in the formulas. If you can understand the formulas, you can continue to tweak them to your liking.

Jon

The free plan also has a limited Pro model for advanced code. Make your questions count though, because you don’t get that many before it silently auto-switches back to the Fast model.

@jono and @Mark.S

Holy s%#@ ! (after a lot of trial and error) It was able to produce a formula to do what I wanted along with the conditional formatting for the Groups (Didn’t format the Types yet)…

Here is the formula…

=QUERY(LET(
  catSheet, INDIRECT("Categories!A1:Z1"),
  transSheet, INDIRECT("Transactions!A1:Z1"),
  catCol, XMATCH("Category", catSheet),
  groupCol, XMATCH("Group", catSheet),
  typeCol, XMATCH("Type", catSheet),
  transDateCol, XMATCH("Date", transSheet),
  transCatCol, XMATCH("Category", transSheet),
  transGroupCol, XMATCH("Group", transSheet),
  transAmtCol, XMATCH("Amount", transSheet),

  allCatData, INDIRECT("Categories!A2:Z"),
  incomeGroups, UNIQUE(FILTER(CHOOSECOLS(allCatData, groupCol), CHOOSECOLS(allCatData, typeCol) = "Income")),
  expenseGroups, UNIQUE(FILTER(CHOOSECOLS(allCatData, groupCol), CHOOSECOLS(allCatData, typeCol) = "Expense")),
  
  buildRows, LAMBDA(groupList, REDUCE(IFERROR(SEQUENCE(0, 2)/0), groupList, LAMBDA(acc, group, 
    LET(
      groupSum, IFERROR(SUM(FILTER(CHOOSECOLS(INDIRECT("Transactions!A:Z"), transAmtCol), 
                                   CHOOSECOLS(INDIRECT("Transactions!A:Z"), transGroupCol) = group, 
                                   CHOOSECOLS(INDIRECT("Transactions!A:Z"), transDateCol) >= S1, 
                                   CHOOSECOLS(INDIRECT("Transactions!A:Z"), transDateCol) <= S2)), 0),
      IF(groupSum = 0, acc,
        VSTACK(
          acc, 
          {UPPER(group), groupSum},
          LET(
            cats, FILTER(CHOOSECOLS(allCatData, catCol), CHOOSECOLS(allCatData, groupCol) = group),
            HSTACK(cats, MAP(cats, LAMBDA(c, 
              IFERROR(SUM(FILTER(CHOOSECOLS(INDIRECT("Transactions!A:Z"), transAmtCol), 
                                 CHOOSECOLS(INDIRECT("Transactions!A:Z"), transCatCol) = c, 
                                 CHOOSECOLS(INDIRECT("Transactions!A:Z"), transDateCol) >= S1, 
                                 CHOOSECOLS(INDIRECT("Transactions!A:Z"), transDateCol) <= S2)), 0))))
          )
        )
      )
    )
  ))),

  incSection, buildRows(incomeGroups),
  expSection, buildRows(expenseGroups),

  finalData, VSTACK(
    IF(ROWS(incSection)=0, IFERROR(SEQUENCE(0, 2)/0), VSTACK({"INCOME", ""}, incSection)),
    {" ", " "}, 
    IF(ROWS(expSection)=0, IFERROR(SEQUENCE(0, 2)/0), VSTACK({"EXPENSES", ""}, expSection))
  )
, finalData), "where Col1 is not null and Col2 <> 0", 0)

Took a lot of trial and error, learning the right prompts and I also had to correct some things Gemini couldn’t figure out.

For this specific formula, I’m going to see if I can remove the dependence on having a Group column in the Transaction sheet as that is not a standard Tiller column. If I ever created a sheet to share, it wouldn’t work unless people added this column.

Also, this will not be as as easy to debug when things go wrong because I am not up very strong yet on the Lambda and Reduce functions. I have more experience with the other ones in this formula.

Thanks for highlighting this feature. I will play with it more.

1 Like

@Cowboy13
I’m glad you keep working away at it and then discovered how powerful it can be.

I will also say it gets easier the more you do it. But for me, and hopefully others, it is worth it.

Very interested. Really appreciate the work and showing us how you did this. I’m going to implement this as soon as I can. Not to mention there is so much in your formula I can learn from.

1 Like

Now I want an Income and Expenss report showing a monthly trend.