Release notes

Every user-visible change in WholeBudget, newest first.

Coming up· updated October 9, 2026

Now

  • Review on the Mac — check what WholeBudget read from a receipt before it's saved

Next

  • Choose the AI for each job, and ChatGPT support
  • The iPhone catches up with the Mac

Later

  • Send feedback and ask for help from inside the app

Release history

v2.150.1

wholebudget.io

Improved

  • The website now lives at wholebudget.io, and support mail goes to support@wholebudget.io. Old wholebudget.vercel.app links forward to the new address.

v2.150.0

Codes on the website

New

  • Codes can now be redeemed on the WholeBudget website: type or open the code, then approve with your iPhone and pick a household. The team's Codes page can also copy a ready-made link for each code.

v2.149.2

Jev waits for its go-ahead

Improved

  • Jev now says it isn't available yet for most households while its sorting service is confirmed for app use. Categorizing works as before without it, and the Jev settings and Insights show one calm line instead of controls.
  • Bank imports now categorise transactions in fewer, larger AI calls, about a quarter cheaper with the same accuracy on a hand-checked set. The old pre-filter setting is switched off once and can still be changed in Settings.

v2.149.1

Every category, its own pill

Improved

  • A purchase with several categories now shows each one as its own coloured category pill with its amount inside, wrapping within the row beside the other marks, with a "+N" pill for the rest. Ad-hoc, Split view, and the Receipts list and cards all use it.

v2.149.0

Categories come from the items

New

  • In Ad-hoc, a bank charge from a store whose receipts usually span several categories, and that has no receipt yet, now says "Usually several categories — add the receipt to split it" beside its category.

Improved

  • A transaction with a receipt now takes its categories from the receipt's items wherever spending is counted, single-item receipts and purchases split over several charges included, and stays in step when you change an item. Unlinking the receipt returns it to its bank category.
  • Ad-hoc now finds a purchase under any category on its receipt, adds up only the matching items when you filter by category, and names each category with its amount on the row ("Groceries $50 · Electronics $30"). The categories of a purchase with a receipt are changed on its items.
  • The Receipts page now finds a receipt under any category of its items, shows each category with its amount on the row, and offers every item category in the Category filter. Group by category files a mixed receipt in each of its groups, and a new "Needs a category" filter lists receipts with items the AI could not place.
  • On a receipt that has items, the category now reads from its items, with an "Edit items" button in place of the picker. Changing a receipt's category no longer rewrites the category of the bank transaction it is linked to; linking only fills in a transaction that had none.

v2.148.3

French pages get their own address

Improved

  • The French privacy policy and terms now have their own web addresses, and the English and Français switch on the website links straight to them. Old ?lang=fr links still land on the French page.

v2.148.2

Terra at its real price

Fixed

  • AI Usage now counts GPT-5.6 Terra at OpenAI's actual price, so a hard receipt that climbs uses a little less of your allowance.

v2.148.1

Clearer legal pages

Improved

  • The privacy policy and terms now name the person behind WholeBudget in full, explain up front that your synced receipt copies are stored in Canada, and, in French, show the English on-screen name of each button and menu next to its French translation.

Fixed

  • The French privacy policy and terms read more naturally: a few awkward phrases are reworded, and one sentence about who stores your receipt copies now says what the English version says.

v2.148.0

Codes for the team

New

  • Settings now has a Codes page for the WholeBudget team. Make a free-trial or AI-included code and copy it, see who used it, and withdraw it. Apple's friends-and-family offer codes are listed beside them, ready to save as a file.

Fixed

  • If your household already pays for Plus through the App Store, a code now asks you to cancel that subscription first, so you are never billed while on a free plan. Withdrawing one free-trial code no longer leaves its days added onto another.

v2.147.0

Codes and a normal month

New

  • Got a code for a longer free trial or WholeBudget AI included forever? Enter it under "Have a code?" in Settings. Included plans now show your AI use against a normal month, and how far over you are if you go past it.

v2.146.0

Terms, notices and a safer PDF reader

New

  • Open-source notices: a new page lists every open-source package WholeBudget is built with and its licence, with the full text one click away. Find it in Settings › About and the website footer.
  • WholeBudget now has Terms of Service, linked from the website and Settings › About. The privacy policy and terms are also available in French, and Support explains how to cancel Plus, ask for a refund and request your data.

Improved

  • AI usage records and App Store notification records are now deleted after two years, and the activity log no longer keeps store names or totals from receipt scans.

Fixed

  • A booby-trapped PDF can no longer run code on your Mac when you import it or scan it as a receipt, statement or pay stub. The PDF reader was updated to a version that closes the hole.

v2.145.0

Pick a card, then its ending

Improved

  • Fixing how a receipt was paid now starts with the card. Pick it, then pick its ending (the physical number, Apple Pay on someone's phone, or an old number) in one click, on the receipt, in Your Cards, and in the card-number editor.

v2.144.0

The Mac reads your iCloud pictures

Improved

  • When it is signed in to iCloud, the Mac now also picks up receipt pictures your household's iPhones keep in iCloud, so a picture still on its way can arrive without the phone coming home. It only reads, and the pictures stay encrypted until they reach the Mac.

v2.143.1

Jev's pick, pinned

Improved

  • "Use Jev's category" now also works on a receipt line that is already in Jev's category: it pins the line as your own choice, so a later AI re-scan leaves it alone, and the pin syncs to your phone. A line you already set says "Already your choice".

v2.143.0

Pictures and changes arrive in seconds

Improved

  • Pictures of receipts scanned on a phone away from home now reach the Mac through an encrypted mailbox, even without Plus, and the Mac clears each one from the mailbox once it has safely stored it.
  • Changes made on your phone or another device now reach this Mac within a few seconds while the app is open, instead of waiting for the next minute's check.

v2.142.0

Hard receipts climb the ladder

Fixed

  • A very long receipt that WholeBudget AI ran out of room on is now read again automatically on stronger models, GPT-5.6 Terra and then Claude Sonnet 5.5, stopping at the first one that reads it. Each try counts against your allowance and shows in Scan details and AI Usage.

v2.141.0

Phone receipts reach the Mac anywhere

Improved

  • Receipts scanned on your phone away from home now show on the Mac right away, read and categorized; the picture follows over Wi-Fi or the cloud, and Settings says when the Mac last synced and how many pictures are still on their way.
  • Syncing receipts between your phone and Mac no longer needs Plus, and a phone away from home can now leave a new receipt's picture in an encrypted mailbox for your Mac, which collects it and clears it (kept 14 days at most).

v2.140.0

How it was read, on every phone

New

  • When someone reads a household receipt on their iPhone, a short summary of how it was read now travels through your Mac and the household cloud, so every phone in the household can show it.

v2.139.0

Is Jev ready?

New

  • Insights › Jev now says in one sentence whether Jev is ready, at 90% confidence against the AI model, and lets you change the bar. Comparisons adds agreement week by week, the categories where they agree least, and whether each answers the same item the same way.
  • When you mark a Jev disagreement "Jev was right", it now offers "Use Jev's category", which sets that receipt line's category by hand and syncs like any edit. If it can't tell which line you mean, it offers to open the receipt instead.

Fixed

  • A card picked (or taken back) on your iPhone now also changes the payment method on the expense already linked to that receipt, as picking it on the Mac does. Expenses you edited yourself, split payments and bank-statement lines are left alone.

v2.138.0

Categories, everywhere

New

  • Categories in Settings now tag the ones your household made as Custom, and an All, Library and Custom switch shows just the group you want. The built-in categories carry no tag.
  • Removing a built-in category now retires it: it leaves the pickers and scanning, receipts keep it, and a collapsed Retired list brings it back in one click. A category you made is deleted if nothing uses it, otherwise retired, and the confirmation says which.

Improved

  • Renaming a category now moves everything filed under it, such as expenses, receipts, line items and remembered store choices, and keeps its colour, instead of leaving them under the old name.
  • Descriptions the AI wrote are marked AI, an edited category shows when and where it was last changed, and saving a description now changes only that one, so an edit made on your phone is no longer undone.
  • The privacy policy now says your household's categories sync end-to-end encrypted through the account, and what "Describe with AI" sends about a category.

v2.137.0

Crash reports, only when you say yes

New

  • If WholeBudget crashes or hits an error, it asks next time whether to send the report so it can be fixed. Settings › About lets you always send, never send, or save the reports yourself.

v2.136.0

See the receipt, fix the card

New

  • On the Receipts page, a note appears when a receipt's payments don't add up to its total, and the card number on each payment can be edited right there.

Improved

  • In Settings, Your cards, click any receipt in a card's list to see the whole receipt, zoom in, fix its card number and step through the rest with the arrow keys. A card's Paid by names and ending counts now open their receipts too.
  • On Your cards, a receipt can now be marked "Paid with…" a saved card, and it drops out of the unrecognized ending. When a card number can't be fixed because the payments don't add up, it says by how much and offers to make the payment match the total, or to open the receipt.

v2.135.0

Household receipts are everyone's

New

  • Anyone in your household can now edit, re-read, crop, rotate or delete any household receipt from their iPhone, not only the ones they scanned, and a receipt remembers who last changed it.

Improved

  • A receipt on your Mac now shows who scanned it, whose card paid, and who last changed it, so a household receipt that someone else edited says so right in its summary.

Fixed

  • A receipt you rescan on your iPhone now sends its new picture to your Mac and to WholeBudget cloud, instead of changing it only on the phone. The old picture is kept, so Restore still brings it back.

v2.134.0

Change a card's number from your iPhone

Improved

  • A card's number change can now be made, taken back and dated from the iPhone too. Unlinking an account from its card stops counting the old- and new-number statements as one account, and a changed date can go back to the estimated one.

v2.133.0

Events on receipts

New

  • Tag a receipt with an event on the Mac or the iPhone. The tag follows the receipt to its card transaction, and a tagged receipt that isn't linked yet counts on its event until it is.

v2.132.0

Bills where they belong

New

  • Payment schedules and monthly plans have "Mark last payment unpaid" in the row menu. The ledger entry Mark paid made goes with it unless you have edited that entry, and a finished plan comes back on when you undo its last payment.

Fixed

  • The Balance now shows what a bill that is still due this month will cost, even when another bill in the same row already has a charge. A yearly or quarterly bill shows its full amount, only in the month it is due. Past months are unchanged.
  • A bill you have archived no longer appears in the Balance forecast or in the monthly totals from the month you archived it, even if it was still switched on. Its past charges still count in the months they happened, including on the daily chart.
  • Picking the Reimbursements category on an income payment, in the editor or while importing, now ticks the reimbursement box so it files under Reimbursements. Moving to another category takes back that tick, but never one you ticked yourself.
  • A payment schedule or plan length of the wrong kind, such as a month of 14, a year typed as text or a plan length that does not match its payments, is now refused with a clear message instead of breaking the Balance later.

v2.131.2

Cards, read right

Fixed

  • Choosing a different card on a receipt, or going back to the printed one, now also changes the card on an expense that receipt is already linked to, instead of leaving the old card there. An expense you edited yourself is left alone.
  • A date in a receipt's card-ending field is no longer mistaken for the card's last four digits, so the receipt finds your card. A gift card from Visa, Mastercard or Amex that matches none of your cards now reads "Visa gift card" instead of just "Visa".

v2.131.1

Who paid, after two folds

Fixed

  • A shared card folded into someone's card, which was later folded into another person's card, still counts as the person it did before the second fold when you pick it on a receipt.

v2.131.0

Exchange or deposit, your call

New

  • For a bank record, "What is this paper?" now lets you choose Deposit or Currency exchange.

Fixed

  • A bank slip that pays out foreign cash, like "600.00 EUR @ 1.6699", is now read as a currency exchange even when it also prints a small deposit.

v2.130.1

Jev, tidied

Improved

  • The daily limit on Jev comparing receipts from your phone now holds exactly, even when many arrive at once or a comparison can't be priced. Receipts held back are compared later.
  • Jev's comparison history now keeps the last two years and clears older months on its own, so it no longer grows without end. Your marks on Jev's disagreements are kept.

v2.130.0

Bad scans say why

Improved

  • A receipt that can't be read now says why instead of "Could not extract receipt data": part of it is cut off, the picture is blurry or too dark, it isn't a receipt, or only some of it could be read, with the advice to fix it and rescan.

v2.129.0

Your own colours, one household view

New

  • Pick a theme in Settings, from seven presets or your own two colours, and it is remembered for whoever is signed in. The Alex / Chelsea / Duo switch is gone, so every screen shows the whole household, and the sidebar footer is tidier.

v2.128.0

Foreign cash, printed rates, tidy follow-ups

New

  • A receipt or bank slip that prints its own exchange rate is now valued at that rate instead of the Bank of Canada's.
  • Foreign cash you buy at the bank or a foreign ATM is tracked like your Canadian cash: link euro purchases to it in one click, and Cash on hand shows what's left in each currency.
  • A foreign-currency receipt paid in several card charges is suggested against them when they add up within 2.5% of its value.

Improved

  • The Cash on hand card follows the dashboard's Alex / Chelsea / Duo view.
  • Create Split Expenses now offers to link a cash part to the ATM withdrawal it came from, like a single expense does.
  • The receipts list shows "3 charges" on a receipt paid in several taps.

Fixed

  • Deleting a bank deposit line now lets go of the deposit slip that was matched to it, so the slip can be matched again instead of showing as linked to nothing.
  • A bank slip changed from a deposit to an exchange, or to anything else, no longer reduces the ATM withdrawal it was linked to.
  • The "Paid with cash from…" line on the phone now refreshes whenever a withdrawal or cash purchase changes, including from a transfer, a link, a vendor fix or a date change.
  • A receipt that could be paid by several different sets of charges now holds them all, so another receipt can no longer take a charge it needs.
  • A refund paid back in several credits now links every credit to the original purchase's charges, not only the largest, so none is left as an unlinked refund.
  • Correcting a receipt's date now moves every charge of a receipt paid in several charges, and the data export lists all of its charges and values a foreign one on all of them.
  • A date changed on the phone is now re-checked right away, so a receipt no longer keeps an old "dated …" date-check note after its day and month were fixed.

v2.127.0

Same card, new number

New

  • A card replaced with a new number stays one card. Tell Your Cards the new number and the old one is kept with the date it stopped, its Apple Pay numbers and their owners come along, and it can be taken back.

Improved

  • Once an account is linked to its card, statements under the new number land on the same account as the old ones, so rows already imported from the old number's statements are recognised.
  • Your Cards shows the receipts behind an unknown card ending, so you can see the paper before deciding, and lets you fix a misread card number right there. A likely misread is suggested for you to confirm, and a card that changed number shows its earlier one.

v2.126.0

Allowance or top-up, call by call

Improved

  • The AI usage page shows how much of each WholeBudget AI call came from your monthly allowance and how much from top-up credit.

v2.125.0

Jev on a budget

New

  • Insights › Jev › Comparisons can now be narrowed to everything, receipts scanned on this Mac, phone receipts or the one-time backfill, so you can judge each source on its own.

Improved

  • Phone receipts compared with Jev as they arrive now stop at a daily spending limit, and a change you undo is not compared a second time. Anything held back is picked up later by "Compare on my existing receipts".

Fixed

  • Two sentences that ran words together now read properly: the call-count note when reclassifying recurring expenses in Settings, and the notice after rejecting suggested matches in Transactions.
  • A category or prompt setting named like a built-in word, such as "constructor", no longer breaks category descriptions or stops AI classification from working.

v2.124.1

Who paid, after a fold

Improved

  • Folding a shared card into someone's card now says plainly that its receipts will count as that person's from then on.
  • Moving past expenses to whoever's phone a card ending is on now lists them first, all ticked, so you can untick any you want to leave as they are.

Fixed

  • A receipt you picked as a card that was later folded into another card now still counts as the folded card's owner, instead of switching to the other card's owner.
  • Undoing a past-expense move only puts back expenses that still belong to that card ending and haven't been changed since.

v2.124.0

Categories follow the items

Improved

  • An event's category breakdown and a vendor's category count now follow the items on each receipt, with their share of tax and tip. The full export's item list gains a Total paid column, so summing it by category matches the app.

v2.123.0

Know what to export, overlaps handled

New

  • Re-analyze all in the statement library reads every file that isn't imported yet again with the current version, so older files line up with your newer exports. It only re-reads the files: nothing is imported and no AI is used.

Improved

  • When a statement overlaps one you already imported for the same account, the overlapping rows are now recognised as the very same bank lines and left out as "Already imported from" that file, instead of waiting for you as possible duplicates.
  • The statement library now opens with what to do: which exports to get from your bank, and which statements are ready to import with how many rows are new in each. Imported and covered statements fold into one line per account.

v2.122.0

Whose phone paid

New

  • Mark whose phone an Apple Pay, Google Pay or other card number lives on, so receipts paid with it count as that person's. Each card shows who used it, and past expenses that still name the card's owner move over in one click, with Undo.

v2.121.1

AI usage export, as your allowance

Improved

  • The AI usage export shows WholeBudget AI as a share of the month's allowance instead of dollars; calls on your own API keys keep their dollar cost.

v2.121.0

WholeBudget AI, as your allowance

Improved

  • The AI usage page now splits WholeBudget AI from your own API keys, shows WholeBudget AI as a share of your monthly allowance instead of dollars, and adds an allowance meter.

Fixed

  • WholeBudget AI now shows its real providers (OpenAI, Anthropic, TypeSafe) on the AI usage page, with the right prices, and a failed WholeBudget AI call is no longer marked "unpriced".

v2.120.0

Spending by what you bought

New

  • The Receipts page shows how many receipts since tracking began are linked to card transactions, plus how many have a likely card transaction waiting to be linked, so you can see which statements still need importing.

Improved

  • Spending on the dashboard and Trends now splits each purchase by its receipt items, each carrying its share of tax and tip. A receipt scanned since tracking began also counts until its card transaction is imported, then hands over to it so nothing counts twice.
  • The privacy policy now says that WholeBudget's AI server counts the requests it turns away and why, that the WholeBudget team can see AI usage by household in a private dashboard, and that a deleted household's AI usage stays only as anonymous monthly totals.

v2.119.0

Windfalls kept out of your averages

New

  • A new income category, Reimbursements › Insurance Claim Payout, for a settlement on a damaged, stolen or written-off car or property. It sits next to Insurance Reimbursement, which is still for a covered expense paid back.

Improved

  • Income tagged to an Event, or switched to Leave out of averages (offered for payments over $2,000), now stays out of the average income Runway uses, like spending. Your totals and Balance still count it.

v2.118.0

A home for every bill, and 2025 tidied up

New

  • Categories for things that had no home: a warehouse-club membership, mortgage and critical illness insurance, a phone paid off through your carrier, and a Home Services group for cleaning, lawn, snow and pest contracts. Wills, taxes owed, baby-shower gifts and tire swaps get spots too, and there is a place for team fees and golf rounds.

Fixed

  • Your 2025 expenses still filed under the old categories, and a few receipts one step off the list, move onto today's categories and out of Classify Legacy. Anything that could reasonably go two ways stays there until you choose.

v2.117.2

Share the pairing link

New

  • When you pair an iPhone, a Share button sends the pairing link by Messages or AirDrop. Tapping it on the iPhone opens WholeBudget straight to pairing, so nobody has to type the long link.

v2.117.1

Your cards, and every PDF

Improved

  • Settings › Payment methods is now called Your cards, and it says plainly that your WholeBudget subscription isn't one of them: Apple bills it through your Apple ID.

Fixed

  • A PDF receipt your Mac can't turn into a picture for your iPhone, such as a very large scan, is now sent as the PDF itself so your iPhone can show it, instead of "no picture".

v2.117.0

Who's signed in, and where it all lives

New

  • Once this Mac is signed in, the sidebar shows who is signed in and your household's name, just under WholeBudget. Click it to open Settings › Account. When you're signed out, a small Sign in link takes you there instead.
  • Settings › Storage shows how many receipts and pictures you keep, how much room they take on this Mac, who scanned them on which device, and where the pictures live: this Mac, WholeBudget cloud, and each iPhone's own storage and iCloud.

Improved

  • Your household's name now titles Settings › Household, and it no longer goes blank after the app restarts. You can rename the household right there too, and the new name reaches your iPhones with the next cloud sync.
  • The privacy policy now says the account holds a short, encrypted storage note from each household iPhone for the Mac's Settings › Storage: its model and how many pictures it keeps where, never the pictures themselves.

v2.116.2

PDFs work again

Fixed

  • PDF receipts show their picture again on the Mac, and now reach your iPhone. The Mac app couldn't draw any PDF, so every PDF showed a blank page icon and the iPhone was told there was no picture.

v2.116.1

Several charges, seen at import

New

  • The Import preview now shows when several charges are one receipt's purchase, like "3 charges → RONA receipt", and links them all when you import.

v2.116.0

Keep your import work, statements that fit, credits that make sense

New

  • Your work on a statement's preview is now kept as you go. Leave the page or restart the app and it comes back as you left it, AI answers included, with nothing run again. Save for later keeps it in Statements; Discard drops it.

Improved

  • The Import preview now fits a 1024px window with no sideways scrolling. Category and Sub share one cell, and the card payment, links and flags open from a chevron on each row. The statement viewer's table fits too.

Fixed

  • The statement viewer and the Import preview now count already-recorded rows the same way, so the numbers match. A charge that lands on a bill's scheduled payment no longer reads as a duplicate in the viewer.
  • Imported rows like groceries or a takeout meal no longer land in Miscellaneous because a shortened category list left out the right one. A row that comes back Miscellaneous is now asked again against every category.
  • Credits on a card statement keep what they are: a payment stays a card payment and no longer counts as income, a store refund keeps its name and lowers spending in that store's category, and cash back lands as a Credit Card Rewards rebate.
  • Card payments from statements you imported earlier no longer count as income in past months' totals. Store refunds and cash back from those statements still count.
  • A charge your bank lists twice on one statement now imports as two charges instead of one being left out as a duplicate, and the statement reads Partly imported until both are in.

v2.115.1

Whole PDFs for your iPhone

Improved

  • Your iPhone can now get the whole PDF of a receipt you uploaded on this Mac, every page, instead of only the first page it could show before. Photos and receipts your phone captured stay as they were.

v2.115.0

One purchase, several charges

New

  • A receipt paid in several taps, like Apple Pay's $250 limit, now links to all of its card charges, and its refund to all of its credits.
  • A receipt's Bank card lets you pick 2–4 charges to link together, and offers charges that cover part of a purchase paid partly another way ("Covers $500.00 of $805.20").

v2.114.1

Household gift cards counted right

Fixed

  • A household receipt paid partly with a gift card now reaches your iPhone with the amount that actually counted, so the phone's totals match this Mac's instead of counting the gift card as spending.

v2.114.0

Jev, measured

New

  • With Jev set to On, you can choose where it decides: everywhere, or only on your iPhones while this Mac keeps comparing in Shadow.
  • Insights › Jev has a Decisions view: how many items Jev sorted on this Mac and on each iPhone, how often you changed its answer at each confidence level, where it disagreed with the AI model, and its cost per receipt.

v2.113.0

A car loan, paid down in plain sight

New

  • Add a car loan as a Loan on the Recurring page and open its own loan view. It shows how each payment splits between interest and principal, the payoff date, what you still owe today and the full schedule, and it can work out the payment from the amount, rate and term.

v2.112.0

Statements you can finish, links you can refuse

New

  • A partly imported statement now has Import the rest. Its preview leaves out the rows already in your books, so you only import what's missing.
  • A receipt the Import preview would link to a charge now has a Don't link button. That receipt is never matched to that charge again, but stays free for other charges, and you can still link the two by hand.
  • The Import preview shows which bank slip a deposit matches, and suggests a receipt for a bill payment that you can link with one click. The statement viewer shows both too.
  • Each account in the Import library now has a month-by-month strip showing which statements are imported, partly imported, ready or covered. A month with no statement stays as a dashed gap, and hovering a month names the files or the missing days.

Improved

  • A statement you deleted and drop again comes back as imported, partly imported or ready to import, depending on how many of its rows are still in your books.
  • Settings → Imports now points to the Import library instead of keeping its own lists of statements, and the dashboard's Imports card shows how many statements are ready. The statement viewer also pairs a transfer with its other leg in another statement or your books.

Fixed

  • Deleting a statement now stops instead of losing it when its backup copy can't be made, and dropping a file whose copy had gone missing puts it back. Files the app can't read are retried at most once an hour.
  • Opening an Ad-hoc row from a statement now clears whatever hid it: a quick filter, the search box, or a receipt or event chip. Before, the page could open without the row on it.
  • A held statement now remembers when you unlink a transfer pair, accept a row as new or mark it a duplicate, and a row you accepted as new loses its duplicate badge when the file is read again. Confirming a suggested transfer with another file now also leaves that row out of the import.

v2.111.0

Plans that end, lumps left out, every bill counted

New

  • Add a monthly plan with a set number of payments, like a 12-month prepaid package that runs from November into next October. Recurring shows how many you've paid (5 of 12), and the plan archives itself after the last payment, with no copy offered into next year.
  • Mark a one-off expense, like a car down payment, as left out of averages. It still counts in your totals and Balance, but stays out of Runway, the usual ad-hoc figure on the dashboard and the Trends callouts. You're offered it over $2,000 and for vehicle or house purchase costs.
  • Balance has new Vehicle, Kids and Other bills columns. Bills filed under Vehicle and Kids & Childcare, like a car loan or daycare, get their own columns, and any other bill that had no column now counts in Other bills.

Fixed

  • Balance now counts a recurring Cell Phone bill in its Phone column and your AI subscriptions in Subscriptions, converting US-dollar ones to Canadian dollars. Both were missing from Total Payments.
  • The Trends callouts no longer count Event spending, so a one-off trip or purchase doesn't show up as a spending spike against your three-month baseline.
  • An installment schedule you add from Settings now keeps its payment months. The schedule was being dropped when the new template was saved.

v2.110.1

Where pictures live

Improved

  • Settings › Account says where pictures live: iCloud holds the ones taken on your iPhones, and pictures that live only on this Mac reach the cloud through WholeBudget cloud.

v2.110.0

Purchases that hold, money that's counted right

New

  • When reading a receipt again changes its total, the receipt now says the new total differs from its expense and offers a one-tap "Update the expense". The expense never changes on its own, and hand-edited or split expenses are left alone.

Improved

  • When your iPhone or another Mac puts a receipt in a purchase that this Mac can't accept, such as a refund with the charge it undoes, the receipt now says it was kept on its own and why, instead of the grouping quietly not sticking.

Fixed

  • A receipt link you removed on an older iPhone no longer comes back when the Mac restarts after it could not tidy its own copy of that link.
  • Unlinking a tip slip from its bill while also changing what the paper is no longer leaves the tip's expense split over the bill's categories. It goes back to its own category.
  • Two Macs that disagree about whether a receipt belongs in a purchase no longer keep undoing each other's grouping as they sync. Each keeps its own answer and the receipt says why.
  • A card you pick for one payment of a split receipt now counts for the linked purchase it belongs to, on the receipts page and in what your Mac sends your iPhone. When two papers name the same payment, the latest pick wins.
  • Changing the total of a receipt you split into several expenses no longer resizes the first part to the whole total. Each part keeps its amount, and the receipt tells you why.

v2.109.0

Cash from a withdrawal, counted once

New

  • Cash from an ATM withdrawal counts as spending only until you say what it bought. Link a cash purchase to its withdrawal and the purchase replaces that part of it, so nothing is counted twice.
  • A Cash on hand card shows money you've withdrawn that isn't tied to purchases yet, and what each withdrawal paid for.

Fixed

  • Expenses linked as a transfer, card payment or fee reversal are now left out of every spending total, not just some.

v2.108.1

Tidier tools, kept card wording

Improved

  • In Split view, VoiceOver now reads a grouped receipts list as one list, with each month or category as a row of its own, so “row 4 of 20” counts the whole list instead of starting over in every group.
  • A receipt’s ⋯ menu item “Refund / tip / final charge for…” now opens a picker of its own. A refund, exchange or tip slip goes straight to choosing its purchase; any other paper is asked what it is for first, and its kind is set as it links.

Fixed

  • A scanned receipt whose card line reads “Apple Pay Visa” or “Google Pay Mastercard”, with no separate payment line, now keeps the wallet, so the Apple Pay mark shows instead of a plain Visa.

v2.108.0

Safer statement imports

Improved

  • The privacy policy now says the iPhone test app checks for a newer build each time you open it, and covers its new “Tell me about every test build” switch.
  • In a statement's "As WholeBudget reads it" view, "Already in your books" now opens the exact row. The Ad-hoc, Recurring or Income page scrolls to that payment, unfolds its month and flashes it, even if a filter was hiding it.

Fixed

  • Receipt links from a statement import are checked again at the moment you import. If a receipt changed since the preview and no longer fits its row, that link is skipped and the import summary tells you how many were.
  • Importing a paycheque no longer moves a paystub that is already linked to another deposit, the summary counts only paystubs linked to the deposits you imported, and a refund receipt linked at import joins its original purchase right away.
  • A statement whose rows are all in a wider statement you already imported now reads Imported via that statement's period and no longer offers Preview & import. If the wider one is only partly imported, it says so and Preview stays.
  • Deleting a statement now moves the file to a backup instead of erasing it. Drop one you had imported again later and the app says when you imported it and brings it back as imported, not as a new file to import.
  • An old statement the app could not read while tidying the library now says Couldn't read yet instead of guessing, and can't be previewed until Re-analyze reads it and checks whether its rows are already in your books.
  • Imported statement records now keep only the last four digits of a card number. Some card exports put the whole number in the file, and statements already in your library are trimmed the next time you open it.
  • On the Import page, linking similar rows to a bill, or moving a flagged duplicate onto its bill, is now remembered with a held statement. Re-analyzing the file keeps those links instead of making you redo them.

v2.107.0

Room for the baby and the car

New

  • Categories for a new baby and a car. Kids & Baby is its own category now, with nursery, gear, feeding, clothing and childcare, and Education keeps the learning side. A car's down payment, its loan and lease, and roadside help each have a place.
  • A New Baby event type, so everything bought for the nursery and the new arrival can be grouped under one event, with its own planned budget, across all the new baby categories.

v2.106.0

See a statement before you import it

New

  • Open any statement in the Import library to see it two ways: exactly as the bank sent it, and as WholeBudget reads it, with each row marked already in your books, new or a possible duplicate. Held files now remember a note and the choices you made on their rows, and keep them when you re-analyze.

v2.105.0

Ask first, and an up-to-date privacy policy

New

  • WholeBudget now asks once before sending anything to WholeBudget AI. Until you tap Allow, no receipts, paystubs, transactions or search words leave your Mac for it, and you can withdraw any time in Settings.

Improved

  • The privacy policy is up to date with recent releases: WholeBudget AI on the Mac and the question both apps ask first, the household name the account keeps, Face ID, Share diagnostics and Export everything.

v2.104.0

Statement library, links at import, Account for everyone

New

  • Importing a statement now shows which scanned receipt goes with each purchase, and which paystub and payday go with each paycheque. Sure matches link when you import; close ones show Link and Dismiss so you decide.

Improved

  • The privacy policy no longer calls the account a Labs feature, and now says the server keeps Apple's sign-in token only to end that link when you delete your account, and that your household sees your device names.
  • Account is now a regular part of Settings for everyone, with no Labs switch to turn on first, and the Mac's sign-in hint points you to the right place on your iPhone.
  • The Import page now keeps one copy of each statement in a library grouped by account. The repeat copies are gone, statements you held back read Ready and can be previewed or re-analyzed with the latest app, and dropping a file you already have opens that one instead of storing another.

v2.103.0

Safer connection, money that adds up, update banner

Improved

  • A safer local connection: only WholeBudget's own iPhone link can speak for a paired phone, so another program on your Mac can no longer pretend to be one to read your shared AI keys.
  • The Mac app now checks for new versions every 15 minutes and when you come back to it, downloads them quietly, and tells you when one is ready — a banner, a notification, a Dock dot and a "Restart to Install" menu item.

Fixed

  • A coffee shop's reloadable card, such as a Starbucks or Tim card, is now treated like a gift card: what it paid is shown on the receipt but not counted as spending, because the money left your account when the card was loaded.
  • A single receipt paid partly with a gift card, store credit or points now matches, creates and updates its expense for only the part that left your account. Before, it used the full receipt total, while the receipt counts already left the gift card out.
  • When you pick a card for one payment of a split receipt, the vendors quick look, the Ad-hoc receipt preview and your iPhone's view of a household receipt now show that card too, as the Receipts page already did.
  • The expense for a tip slip linked to its bill is now split over the bill's categories, the same way the receipt shows it, instead of sitting in one category. Your own edits to that expense's split are left alone.
  • Reading a receipt again now updates how its linked expense was paid when the new reading changes the payments. If you edited that expense's payments yourself, they are kept and a note tells you so.

v2.102.0

Export everything

New

  • Settings › Data & backup has a new Export everything button. It makes one zip with your receipts and each of their lines, your expenses and your income as spreadsheets, and a folder with every receipt's picture. It saves straight to disk as it goes, so a big library is fine, and it leaves out your keys and paired-phone details.
  • The ⌘K search now finds every page of Settings, including Account, Devices, Data & backup, About & help and each AI & automation tab, and opens it directly. Searching for words like "export" or "iphone" works too.

Fixed

  • Messages that sent you to Settings tabs that no longer exist now name the right place, such as the WholeBudget AI sign-in note, the paused-AI note and the missing TypeSafe key message.

v2.101.0

Bank slips are bank records

New

  • Bank slips (cash deposits and currency exchanges) are recognised as bank records. They stay out of your spending and match the deposit on your bank statement.

Fixed

  • A card number printed with only its last digit no longer shows as ••0003.

v2.100.2

Small fixes, sharper split view

Improved

  • In Split view on a wide screen, the receipt pane now opens wide enough to show the paper beside the cards, with no need to drag the divider first. A width you set yourself is still kept.
  • Dark mode now reaches the last few spots: the duplicate-match bars and icons on the Receipts page, the custom parser wizard, and the receipt picture's full-size view and crop screen. They follow your theme, and no text is smaller than 11px.

Fixed

  • The Backend Logging switch in Settings can be seen again when it is off. It was invisible, so it looked like there was nothing to turn on. The AI privacy switch had the same problem and is fixed too.
  • The pairing screen on your Mac now tells you the right steps on the iPhone: Settings, Pair with a Mac, Type code. It used to name a "Connect to Mac" screen that no longer exists, so the code was hard to find.
  • Adding a new card from an unrecognized ending in Settings › Payment methods no longer picks an owner for you. Before, it copied the owner of other cards on that network, so a partner's card could end up as yours. You now choose who it belongs to.
  • A receipt your iPhone read without a category now reaches your Mac through cloud sync as "not yet analyzed", ready for the Mac to read it, instead of being filed under an invented "Uncategorized" category that also went back to the phone.
  • Create Split Expenses now keeps every way you paid on each split, such as a card plus a gift card, shared out in proportion to that split's amount. Before, only the first payment was kept on every split.
  • The release notes for version 2.16.0 are no longer listed twice. The notes had two full drafts of the same changes, and both showed on the Releases page. Only the shorter, tidier draft is kept.
  • When a re-read turned a refund into a charge, the receipt's warning was blank apart from "Review this receipt." It now says the re-read turned the refund into a charge and that your previous amounts were kept.
  • Stepping through receipts with the ‹ and › buttons in Split view no longer drops keyboard focus. The button you used stays selected, so you can keep stepping from the keyboard.

v2.100.1

Your household's name, and PDF pictures again

Improved

  • The Account card in Settings now shows your household's name once your iPhone has set one, as "Household: The Smiths". The name travels end-to-end encrypted through cloud sync, and the newest one wins.

Fixed

  • PDF receipts show their picture again, in the receipts list and on your iPhone.

v2.100.0

Linked purchases, editable anywhere

New

  • Linked purchases now sync both ways. Group an itemized bill with its card slip, or take a paper out of a purchase, on your Mac or your iPhone, and the other device follows. The latest change wins.

Improved

  • Sync now keeps receipt details from a newer app version that this one doesn't know yet and passes them on unchanged, so a device that is one update behind no longer drops them.
  • At start-up the Mac fixes any receipt decisions (paper role, kind, links, purchases, card picks) that a failed save left out of step with what syncs between your devices.

Fixed

  • When merging copies left one receipt with two links (say a refund and a tip), the other devices kept only one of them. Every device now keeps both.
  • A paper role, "What is this paper?" answer or link set on your iPhone before a new receipt first reached your Mac could be undone when it arrived. It now stays on both devices.

v2.99.3

Analyzed means analyzed, the right payer

Fixed

  • A receipt scanned on a phone that already has its categories is no longer stuck as "Not analyzed yet" on the Mac and the phones. It is filed as analyzed the next time the Mac starts or the receipt changes.
  • Creating an expense from a receipt scanned on a phone now gives it to the person whose phone scanned it when the card isn't known, instead of the default person. A card the receipt names, or one you pick, still decides who paid.

v2.99.2

WholeBudget AI verifies iPhones

Improved

  • The AI card in Settings now shows how much of this month's AI you have used as a percentage, like "12% of this month's AI used", and notes when you have extra top-up credit available, instead of dollar amounts.

Fixed

  • WholeBudget AI now works on a real iPhone. The AI service rejected every real phone when it first checked it was genuine, so AI never switched on there; it now verifies them correctly, and so does the check on App Store purchases.

v2.99.1

Phone receipts without a category arrive

Fixed

  • A receipt your iPhone read without AI, so with no category, now reaches your Mac instead of failing with error 400 on every sync. It waits in the Mac's inbox to be analyzed, with everything the phone read kept, and the category it gets goes back to the phone.

v2.99.0

Settings, reorganized

New

  • Account is always in Settings. With Accounts off it explains what an account is for and how to turn it on. Devices now shows cloud sync, and a new About & help page has the version and links to release notes, Support and the privacy policy.
  • Settings › Data & backup now shows where your data lives on this Mac, with Show in Finder and Copy path, and how much space your backups use. Copy that folder to take your data with you.

Improved

  • Settings is now a list of areas down the left: Account, Household, Devices, Payment methods, Categories, Recurring templates, Imports, General, Data & backup, About & help, and AI & automation, which holds the five AI pages as tabs. Old Settings links still work, and Settings reopens where you left it.

Fixed

  • Choosing Alex, Chelsea or Duo in Settings now recolours the app straight away instead of after a reload, and the choice always matches the palette you are looking at.

v2.98.1

Plus from the WholeBudget team

Improved

  • The Mac's WholeBudget AI card now shows Plus from the WholeBudget team for a household with complimentary Plus.

v2.98.0

Split view, wider and on Ad-hoc

New

  • Ad-hoc expenses now has the same optional Split view as Receipts: pick Split in the header and the chosen expense, with its details, receipt lines and actions, sits beside the list; ↑ and ↓ move, Enter edits, Esc clears, and the divider remembers its width.

Improved

  • In Split view, a wide receipt pane (about 900 pixels or more) now shows the paper beside the cards, like the big sheet, with a divider to resize them; a narrower pane keeps the paper above the cards behind the Paper button.
  • Screen readers now read the Split view's list as a grid of rows: ↑ and ↓ move real focus, so VoiceOver says which receipt is selected and its place in the list, and the focused row shows a clear outline.
  • In a narrow window or a half-width list, Ad-hoc rows now fold into two lines instead of scrolling sideways, keeping the vendor, amount, category and date.

v2.97.0

WholeBudget AI on the Mac

New

  • WholeBudget AI on the Mac. With Plus, a free trial or a Labs account, receipt reading, categories, descriptions and insights now run on your monthly allowance, with no AI keys to set up. Settings shows the plan, what is left this month and the models in use.
  • A "Use my own keys instead" switch in Settings under AI Providers, Advanced. It is off by default, and your OpenAI, Anthropic and TypeSafe keys now live inside that fold. Turn it on and the Mac leaves your allowance alone.
  • "Looks right" on a receipt's items, total or tax now syncs between your Mac and your iPhone like "Keep as is" on a date. Answer it on either device and the other stops asking, until the figures change.
  • If your iPhone is signed in to a WholeBudget account, Settings on your Mac now says so and offers one button that turns on Accounts and shows the code to sign this Mac in.

Improved

  • When this month's allowance runs out, AI pauses and tells you to top up on your iPhone, instead of quietly spending your own keys. Busy moments wait and retry on their own.
  • Jev's Shadow runs are on us and never touch your allowance. When Jev is On, its answers count toward the allowance like any other AI call.
  • The "Share AI keys" tick for each paired iPhone is hidden while your household is on WholeBudget AI, since this Mac then has no keys of its own to lend.

Fixed

  • A tax-free receipt with six or more pastries, donuts, muffins, croissants or other baked goods is no longer flagged as "tax may be missing". Basic baked goods sold that many at a time are zero-rated in Canada.

v2.96.0

Settings that stick, darker polish

New

  • A quick Appearance switch in the sidebar footer, beside the Alex, Chelsea and Duo buttons: System, Light or Dark in one click, without opening Settings. It saves the same choice Settings does.

Improved

  • A phone you share the card list with now also sees which recurring bill a receipt is matched to (the bill's name, date and amount), the same way it already sees the bank line for a card purchase.
  • In dark mode the receipts page's action buttons (link, merge, restore, accept) and the Merge the copies sheet now use the app's own colours, so their lettering is easy to read.
  • The privacy policy now also says that for a receipt matched to a recurring bill, a phone allowed to see your card list receives that bill's name, date and amount instead of a bank line.

Fixed

  • The Mac app now remembers your list settings between launches: receipt columns, Simple or Detailed, Sheet or Split and the divider width, sort and grouping, and which sidebar sections are collapsed no longer reset when you reopen it.
  • Charts on the Dashboard, Trends and Insights now change colour straight away when you switch between light and dark, instead of keeping the old colours until you reload.

v2.95.0

Your phone sees the bank line

New

  • A phone you share the card list with can now show which bank line your Mac matched a receipt to: the card, date, amount and the bank's own wording. It needs the matching iPhone update, and phones without the card list are sent nothing.

Improved

  • The privacy policy now says a paired iPhone allowed to see your card list also receives the bank line each receipt is matched to, over your home network only.

v2.94.0

Edit anywhere

Improved

  • Edit any receipt on your Mac or your iPhone: its paper role, "What is this paper?", "Keep as is" on its date and the card that paid now sync both ways like its other fields, and the most recent person's change wins on every device.
  • Merging copies of a receipt on the Mac no longer asks you to take a copy over from your iPhone once the phone is up to date — neither device owns a receipt any more.
  • Links between receipts — a refund to its purchase, an exchange, a final charge to its hold, a tip to its bill — can now be made or removed on your Mac or your iPhone, and the most recent change wins on every device.
  • The card picked for each part of a split payment now syncs between your devices, and a purchase with several papers shows the card most recently picked on any of them.
  • Answering "Released" to a hold's "Still held?" question on one device now counts on all of your devices, and the answer stays put even if the paper's kind changes later.

Fixed

  • After an update, a Mac in a household re-reads the shared receipts once, so a currency or other change another device made while it was on an older version is no longer missed.

v2.93.0

Dark mode on the Mac

New

  • The Mac app now has a dark mode for all three palettes. Choose System, Light or Dark in Settings under General › Appearance. System follows your Mac, even when it switches at sunset, and the window opens dark with no white flash.

v2.92.0

Split view on Receipts

New

  • Receipts has an optional Split view. Choose Split in the header and the list stays on the left while the receipt you pick opens on the right; ↑ and ↓ move through the list, Enter opens the big sheet, Esc clears, and you can drag the divider to resize.

v2.91.0

Small fixes, foreign money edge cases

Improved

  • Opening the match picker for a foreign-currency receipt no longer waits on the Bank of Canada; the rate is looked up in the background.

Fixed

  • On the Income page, the note about reimbursements kept out of Earnings now has a button that opens the Reimbursements tab, instead of one that did nothing. The unused "hide reimbursements" setting is gone from Settings.
  • An event's "Tagged transactions" count now matches its table: receipts tagged to the event are listed too, in their own group that says they aren't added to Spent.
  • "Dates to check" on the Receipts page no longer counts a date you marked "Keep as is", so the Mac and your iPhone now report the same number.
  • A foreign receipt paid partly with a gift card or cash now takes its Canadian-dollar rate from what the card was actually charged, so spend by card and the match totals no longer come out too low.
  • A foreign-currency expense dated today now waits for the Bank of Canada's published rate instead of saving yesterday's rate for good; the receipt says "today's rate isn't out yet" until then.

v2.90.1

Plus, ready for everyone

Improved

  • Without Plus, cloud sync now waits quietly instead of failing. Your receipts and pictures stay queued, the Account card says to start Plus on your iPhone, and syncing resumes by itself once Plus is on.

v2.90.0

Currency that syncs, checks on your phone

Improved

  • A receipt's currency now syncs both ways: change it on the Mac, your iPhone or another household Mac and the others follow, the newest edit winning. A synced change re-prices the expense made from that receipt, and when no rate is available yet the receipt says so and offers "Re-price now".

Fixed

  • Changing the amounts or currency of a receipt that's part of a purchase on your iPhone now updates the purchase's expense, just as the same edit on the Mac does.

v2.89.3

Privacy policy: Plus and AI top-ups

Improved

  • The privacy policy now explains Plus and AI top-ups (Labs): bought through Apple, with only the purchase's product, dates, status and Apple's transaction ids kept against your household.

v2.89.2

Privacy policy: WholeBudget AI

Improved

  • The privacy policy now explains WholeBudget AI (Labs): what an iPhone sends to read a receipt through our server, that the server keeps no photo or text, and that it counts only the cost.

v2.89.1

Privacy policy: household pictures in iCloud

Improved

  • The privacy policy now explains sharing pictures through one household member's iCloud: still encrypted with your household's key, stored in their iCloud, and ended with one switch.

v2.89.0

Trips abroad, counted right

New

  • Ad-hoc expenses can be entered in another currency: pick it beside the amount, see what it comes to in Canadian dollars, and it is saved at the Bank of Canada rate of its date. Changing an old entry's currency works it out again.

Improved

  • The privacy policy now says the Mac app downloads daily exchange rates from the Bank of Canada to show foreign-currency receipts in Canadian dollars. The request names only the currency and dates.

Fixed

  • Receipts from trips abroad are no longer counted as Canadian dollars. Each receipt now knows its currency (change it on the receipt if needed), is counted at your card's rate or the Bank of Canada rate of that day, and still links to its bank charge.
  • Changing a receipt's currency now re-prices the expense made from it, so the receipt and the expense always agree. A charge imported from your bank statement keeps the bank's own figure.
  • In Quick add, a pre-tax amount typed in another currency is now converted along with the total. Correcting the amount or date of a foreign charge imported from your bank keeps the card's own rate and fee.

v2.88.1

Privacy policy: pictures in your iCloud

Improved

  • The privacy policy now explains the optional iCloud home for iPhone pictures: encrypted with your household's key before it leaves the phone, stored in your own iCloud, and removable from Settings › Storage.

v2.88.0

Recurring and events, fixed

Improved

  • Recurring now opens on Templates (your commitments grouped by kind, with this month's amounts) instead of the Ledger, which is still one tab away.

Fixed

  • Recurring group totals now count a subscription priced in US dollars at its Canadian-dollar value, using the same exchange rate as the row, instead of adding the US amount as if it were CAD.
  • The Recurring Ledger now includes expenses that were tagged to a template by the pattern scan. They show read-only with a retro-tagged badge, and count in the month's figures and category totals.
  • In a past month on Recurring, the figure labelled Total now shows that month's real total instead of $0. Current and future months still show what remains.
  • An event no longer switches to Active or Completed a day early on evenings after 8 p.m.; its status now follows the Toronto date.
  • In the evening, Recurring no longer treats tomorrow as today, so this month's paid and remaining figures stay on the right day.

v2.87.0

Pictures, where you want them

New

  • Labs: when WholeBudget cloud pictures are on, your Mac stores the household's receipt pictures there and fetches the ones it lacks, so receipts from phones away from home now appear on the Mac with their pictures.

Improved

  • The privacy policy explains picture storage: pictures stay on your devices by default, and WholeBudget cloud for pictures is optional, end-to-end encrypted, and deleted 30 days after you turn it off.
  • Easier to find: Settings > Experiments has a "Sign in with your iPhone" button under the Accounts switch, and the Account tab says where to start on your iPhone.

v2.86.0

Settings and Import, restyled

Improved

  • Settings has the new look: status colours that follow your theme, small print no longer under 11 pixels, and the same buttons, badges and pinned title as every other page. Nothing moved, and every setting works as before.
  • The Transaction Import page now uses the same theme colours, buttons and chips as the rest of the app, and its smallest text is larger and easier to read, in every palette.

v2.85.0

Planning pages, redesigned

Improved

  • Mortgage and Balance Projection have the new look: each loan's numbers sit in cards, charts follow your palette with a key under them, long tables scroll inside their own card, and the breakdown behind any Balance figure opens in a window. The numbers are unchanged.
  • Accounts and each account's page have the new look: net position as cards, accounts grouped by type with their balance, how current it is and a trend line, and "Snapshot now" and "New account" in standard windows. An account's page keeps its history, overrides and linked transactions.
  • Events and each event's page have the new look: type and status filters pinned under the title, a calm card per event with its spending against plan, and an event page with its summary, profit, timeline, reimbursements and tagged transactions. New event opens in a standard window.
  • The Annual Obligations, Reimbursements and Seasonal Profiles pages match the new look, with dialogs for new entries and for deleting and notices on the page instead of browser pop-ups. (Today those routes forward to Recurring, Money back and Settings.)

Fixed

  • The Mortgage charts now draw in your palette (the balance line and the axes were showing in browser defaults), and the page uses today's date in Toronto in the evening instead of tomorrow's.
  • An account's page shows each snapshot, override and transaction on its real day (they could read a day early), its chart follows your palette, and the add-snapshot form starts on today's date in Toronto in the evening.
  • Event pages show every date on its real day; an event's start and end and its transactions could read a day early in Toronto.

v2.84.0

Your Mac in cloud sync (Labs)

New

  • Labs: your Mac joins the household's encrypted cloud sync and brings every receipt it holds with it, so a phone away from home sees the household's receipts and edits flow both ways. Pictures follow later; card details now reach everyone in the household, on Wi-Fi too.

Improved

  • The privacy policy now explains Labs cloud sync: what your devices upload, that it's end-to-end encrypted so WholeBudget can't read it, where it's stored, and how to stop or delete it. Pictures still stay on your devices.

v2.83.0

Income and tool pages, redesigned

Improved

  • Income Scenarios has the new look: the baseline and the scenario sit side by side as cards, adding an adjustment uses a labelled form, and what you have layered on is a clear list. The sums are unchanged.
  • Money back has the new look: this year's refunds and reimbursements as cards, the three tabs pinned under the title, and tidy lists that fit the window. Promoting income to a reimbursement now opens in the standard window.
  • Paystubs has the new look: the privacy note and upload sit at the top, the status tabs and Read All Locally stay pinned, and each stub is one calm line with Simple and Detailed views. A paystub's detail and the delete question now open in the standard window instead of a browser prompt.
  • Income has the new look: Earnings, Rebates and Reimbursements as pinned tabs, this month's income as cards, and a ledger that fits the window with Simple and Detailed views and a Columns menu. Adding or editing a payment and managing roles use the standard windows and forms.
  • Features and the README have the new look: a calm reading column, pinned tabs for Features, Bug Fixes and Release Notes, filter chips, sortable tables, and release notes that open one release at a time. All the same content.
  • Logs, Accuracy and Problem Vendors (the last three Insights tabs) have the new look: a pinned controls row, filter chips that carry their counts, calm cards and tables that never push the page sideways, and a proper dialog before the log is cleared.
  • The Review Queue and each vendor's page have the new look: Approve is the one clear button on every item, queues are calm tables with readable dates, and a vendor's page opens with four figures, the monthly chart and every transaction with its split lines.
  • Review Vendors has the new look: the four tabs stay pinned, each suggestion leads with one clear button, the receipts behind a count open in a window, the logo menu is no longer clipped by a short card, and renaming receipts asks in a dialog first. Everything it did before is still here.

Fixed

  • Parsing an income in the evening no longer suggests tomorrow's date; it uses today's date in Toronto.
  • The Review Queue's Correct link now opens the expense on Ad-hoc, scrolled to and highlighted, instead of landing on Recurring.

v2.82.0

Dashboard, Insights and Trends, redesigned

Improved

  • The Dashboard has the new look: every card shares one layout, grouped into This month, Cash flow, Where it goes, What's coming and more. The category breakdown opens in a window, charts follow your palette, and a card with nothing to show now says so.
  • Insights has the new look: pinned tabs, the date range as a switch, and AI cost, tokens, the Jev comparison, the call log and the prompt list in the same calm cards as the Dashboard. The numbers are unchanged.
  • Spending Trends has the new look: category chips with a colour dot (and a clear nudge at six), the biggest movers as cards, the chart with its key underneath, and a month-by-month table. Colours follow your palette.
  • Dashboard card titles now show in full on narrow cards, with a forecast tag on its own line instead of both being cut off.

v2.81.0

Recurring, redesigned

Improved

  • Recurring has the new look: templates grouped by kind with each group's monthly total, Simple and Detailed views, a search box, a Filter menu with removable chips, and a clearer ledger, scan review and New template window. Everything it did before is still here.

v2.80.0

Readable item lines in Detailed

Fixed

  • In a receipt's Detailed view, item names no longer squeeze into a narrow column letter by letter: each line shows its name and amount first, with the full category path on its own line underneath, and the Items buttons drop below the title when space is short.

v2.79.0

Transactions, redesigned

Improved

  • Transactions has the new look: one calm line per row with Simple and Detailed views, a search box, a Filter menu with removable chips and one clear link status per row. The pair you are building now waits at the bottom, so the filters stay in view.
  • Transactions shows dates the same way as Receipts and Ad-hoc (for example "Apr 29, 2026"), and the Suggested links panel starts folded when there is nothing to review.

v2.78.0

Ad-hoc expenses, redesigned

Improved

  • The sidebar is tidier and easier to read: every label is a readable size, the page you are on is clearly marked, the Alex / Chelsea / Duo switch always shows all three names, and the colours follow your palette. A wide page can no longer squeeze it.
  • Ad-hoc expenses now has the Receipts look: one calm line per expense with Simple and Detailed views, a search box, a Filter menu with removable chips, a pinned one-line Quick Add, and the full form in a window instead of far above the list. Everything it did before is still here, and nothing scrolls sideways.

v2.77.0

Invite your household (Labs)

New

  • Invite links have a page on the website. Opening one without the iPhone app shows the invite code in large type, a button to open it in WholeBudget, and the App Store link.
  • Labs: the Mac joins your household when you sign it in with your iPhone, and Settings › Account now lists who has joined and who is still waiting for an invite.

v2.76.0

Paste to add, and a better category picker

New

  • Press ⌘V on the Receipts page to add a copied screenshot, photo or PDF as a receipt. Several at once become several receipts in your Inbox, and a notice lets you Analyze them straight away or Undo.

Improved

  • Editing a receipt's items now files each line with the same category chip as the reading view: click it, type to search the whole list with full paths, and pick. Enter opens it and Esc closes it.

v2.75.0

Accounts, in Labs

New

  • Labs: sign in on the Mac by scanning a code with your iPhone. Turn on Accounts in Settings › Experiments › Labs, then open the new Account tab. It is off by default and the app works fully without it.

Improved

  • The privacy policy now explains the optional account that is in testing: what it holds, where it is stored, and how to delete it. The app still works fully without one.

v2.74.0

A calmer receipt window

Improved

  • Opening a receipt now shows a calmer window: the vendor as the title, the figures once, each problem in plain words with a button to fix it, and the items, payment, bank link and purchase in cards. The arrows and arrow keys step through the list, and Edit gives the items the whole pane with Save and Cancel pinned.
  • A linked purchase now opens in the same calm window as a single receipt, and receipts mentioned in its notes and in the bank matches are named by vendor, day and total instead of by file name or id.

v2.73.0

A calmer receipts list

Improved

  • The receipts list now fits the window with no sideways scrolling: each row is one calm line with a picture, vendor, date, category, card, total and status, and a Quick look shows what was bought. Simple and Detailed views, filter, sort and group-by-month menus, drop-anywhere uploads and arrow-key navigation are all new.

v2.72.0

Tidier receipts, honest counts

Improved

  • The receipts page now counts the same way everywhere: tabs and totals count a linked purchase once, "Linked to bank" leaves out receipts from before tracking began, "Select all shown" picks only what you can see, and the review bar spells out what it counts. Your tab, view and sort are remembered.

Fixed

  • Secondary buttons such as Run Matching and Find Duplicates now look like real buttons across the app, and card rows no longer carry extra padding that made lists taller than they should be.
  • The Captured column is no longer blank for receipts scanned on your iPhone; it now shows the day the phone recorded each one, and sorting by it works for them too.
  • In the receipt preview, the AI Describer now opens in front instead of behind it, Analyze Now no longer closes the window, and a receipt that came back with no items lets you add one.

v2.71.0

Privacy and support, in the app

Improved

  • The website's Support and Privacy pages now give a dedicated support address, wholebudget.support@gmail.com, instead of a personal one.
  • The WholeBudget website's header now shows the app icon instead of a plain "W".
  • The privacy policy now says that a test version of the iPhone app checks GitHub every few hours for a newer build, downloading only a version number and sending nothing about you.
  • The Privacy policy and Support links at the bottom of the sidebar now open inside the Mac app, with the sidebar still beside them, instead of sending you to your web browser.

v2.70.0

Cheaper AI reads

Improved

  • AI reads cost less: prompts now put the fixed instructions first and the receipt last, so providers can reuse the cached part and bill more of each scan at the cheaper cached rate.

Fixed

  • AI usage counts for categorizing were too low: the usage page left out input the provider billed as a cache write, so those calls showed about 3 input tokens. It now adds fresh, cached and written input together.

v2.69.0

Polish and a welcome

Improved

  • Restoring a receipt from Recently deleted now puts it back on its expense when that expense is still free (and says so when it isn't), and every row has a Delete forever button, with a confirm, to clear it for good.
  • The Household card shows a photo or name set from a phone as soon as you come back to the window, with no reload.

Fixed

  • A receipt's overall category and card amounts that the Mac works out after a phone's edit now settle on the same value on both devices, instead of flipping back and forth between them.

v2.68.0

Recently deleted, and a tidier review

New

  • "Recently deleted" on the Receipts page lists receipts deleted in the last 60 days, from the Mac or an iPhone, with Restore to bring one back on the Mac and on your phones.
  • Cropping or rotating a receipt's photo now syncs both ways: when the Mac and an iPhone both edit it, the newest edit wins and the other device shows that picture.

Improved

  • In a review, a pair of receipts that look like copies is now one item instead of two, "N need review" counts copies and link suggestions too, and a "Not duplicates" button sits beside Merge copies.

v2.67.0

Privacy and support

New

  • The WholeBudget website now has a Support page: how to get started on your Mac, how to pair your iPhone, and who to write to if something goes wrong. It is linked from the footer.
  • The release notes page now has a "Coming up" section above the history, grouped into Now, Next and Later, so you can see what is being worked on before it ships.
  • The Mac app's sidebar now has Privacy policy and Support links under the version number. They open the WholeBudget website in your default browser.

Improved

  • The privacy policy now covers both apps in full: what each AI feature sends and to whom, the few other requests the Mac makes, what the iPhone asks permission for, and how to delete your data on each device.

Fixed

  • The Privacy page on the WholeBudget website now opens. It was linked from the footer but the site sent every visit to it back to the home page.
  • The website and the in-app Readme no longer say your data never leaves your Mac or that there is no sync. They now say it stays on your own devices, syncs to your iPhone over your home network, and that receipts are read by the AI provider you add.

v2.66.0

Faces and fixes

New

  • Review sessions now have buttons under each reason: use the date (the linked paper's first), keep it, show me the figure, mark it looks right, merge copies, or link and mark not related. Answers stick until you edit those figures.

Fixed

  • A photo taken on your iPhone now reaches the Mac as a household member's picture: its location and other hidden details are removed instead of the photo being turned away. Any phone can set any member's photo, and the newest one wins.

v2.65.0

Two-way sync

New

  • Edit any receipt on your Mac or your iPhone, even with no connection. When they sync, the newest change to each field wins and both devices show the same receipt, with no more "Your Mac owns these" hold-backs.

v2.64.0

Merge the copies

New

  • "Merge the copies" now merges two scans of the same receipt into one: it keeps what each reading found, lets the AI pick where they differ (you can change any pick), keeps the picture you choose, and moves every link to the receipt that stays.
  • Filter the receipts page by No picture, with a count, to find receipts that were saved without a photo or whose file has gone missing. It combines with the other filters.

v2.63.1

Copies, pictures and your contact photo

Improved

  • In Link suggestions, papers that look like the same receipt scanned twice now sit together in their own box, one per pair, each with a Merge the copies button. Every paper also lists its items, so copies can be told apart without a picture.

v2.63.0

A purchase that reads like a receipt

Improved

  • Opening a linked purchase on the Mac now looks like one receipt. Its papers page through the zoomable viewer beside the usual summary, breakdown, items with categories and what came back, and a figure taken from another paper says so.

v2.62.0

Links you can judge

Improved

  • Link suggestions now fold papers that belong to one purchase into a single card with their photos side by side. When two look like the same receipt scanned twice it says so, and each paper shows when it was scanned.

v2.61.0

Honest sync

Fixed

  • A receipt scanned or uploaded on the Mac is now checked for duplicates the way one from your phone is, so the Duplicate chip shows when it repeats an earlier receipt. Pairs you marked Not duplicates stay cleared.

v2.60.1

A bill and its tipped slip

Fixed

  • A bill and its tipped card slip scanned together now count as one purchase even when the shop name prints differently, like “One Eyed Jack’s” and “One Eyed Jacks Toronto”, so Links to review shows the pair first.

v2.60.0

Your keys on your phone

New

  • A new "Share AI keys" tick on each phone's card lets an iPhone borrow this Mac's AI keys, so it can scan receipts and use Jev without keys of its own; it is off by default and used only where the phone has none.

v2.59.0

Faces and your Mac

New

  • Give each household member a photo in Settings, cropped square on your Mac and shown on the Devices card and beside who scanned each receipt; each iPhone's model shows there too.
  • The Devices card now shows each phone with its person, model and last sync, pairs a new iPhone in simple steps, and keeps the technical details under Advanced; a phone can also tell the Mac whose it is.

v2.58.0

A preview you can read

New

  • The receipt preview is easier to read: the paper zooms and pans (buttons, wheel, pinch) with a full-size view, the editor is wider with larger text, and a new breakdown lists items, tax, tip, payments and returns, marking any line that is off.

Fixed

  • A receipt opened from the date review, the duplicate scanner or the link suggestions no longer opens hidden behind that list; it now appears on top.

v2.57.0

Review, in order

New

  • Review receipts in order: a bar above the list counts what needs a look, and Start review walks you through them with Previous / Next (or the arrow keys). Each says why, with the amounts, and a fixed one stays in place.
  • Search finds a receipt by its id (or a pasted list of ids), its receipt number with #, a date such as Sep 12 or 2026-09, or an amount such as $12.50 on the total, items or payments.

Fixed

  • A save that fails now says "Not saved" above the receipt preview and keeps your edits, instead of looking saved.

v2.56.0

Edit the payments

New

  • Edit a receipt's payments: fix how it was paid, the card, the amount and the tip, see at a glance whether they add up to the total, and pick a card for each payment so split payments count on the right cards.

v2.55.0

Holds, deposits and tips

New

  • Say what a paper is — a hold, deposit, gift card purchase or tip slip — right on the receipt. A hold counts nothing and asks "Still held?" after 30 days; a final charge links to its hold, and a tip slip links to its meal and counts in its own month.

v2.54.0

One view per purchase

New

  • The receipts list shows one row per purchase, with a chip for each paper (bill, payment slip, refund) and a paper count. Open it for the merged details and a strip of every paper in order, with Unlink on each.
  • A Link suggestions button lists every pair that looks like one purchase, a refund or an exchange. Go through them one card at a time, the two papers side by side, then Confirm or Separate, by mouse or by keyboard.

v2.53.0

Scan together

New

  • When you upload several receipts at once on the Mac, you can now mark which ones belong to the same purchase. Once they’re read, they’re joined as one purchase, or linked as a refund or exchange of it.

v2.52.0

Refunds, linked

New

  • A refund can now be linked to the purchase it came from, with the returned items matched to the ones you bought. The purchase shows what you spent, got back and net, and the refund counts against the returned items’ categories in its own month.

v2.51.0

Smarter refund reading

Improved

  • Refund and exchange receipts now read the original purchase's receipt number and date when they print them, and an exchange keeps both the items you returned and the ones you took home instead of folding the returns away as discounts.

Fixed

  • Store credit, French gift-card wording such as “carte-cadeau”, stored-value cards and points redemptions now count like gift cards — what they pay is shown, but no longer counted as spending — and a gift-card payment always shows its name.

v2.50.0

Jev on your phone

New

  • With Jev in Shadow or On, your Mac now compares each receipt your phone sends as it arrives, so the Jev agreement in Insights covers phone scans too. It never changes a phone receipt, the phone never waits for it, and Jev calls a phone makes on its own key stay out of the agreement's request count.
  • When Jev is less than 90% sure of an item, your Mac now asks the AI once more, showing it Jev's best guesses, and Insights shows which answer gets your categories right: the AI's first, its second, or Jev's. It only keeps score and never changes a category.
  • iPhones paired with this Mac now follow its Jev setting, which you can set before this Mac has a TypeSafe key of its own: when it's On, a phone with its own key sorts its scans with Jev, and Settings › AI Providers says so.

Improved

  • Your Mac now tells a paired phone which household items' AI answers weren't in your category list, so those items show "not listed" there, as your own receipts do.
  • With Jev On, a receipt whose AI item sorting fails now still gets Jev's answers for the items it's sure of, and its error says how many it sorted, such as “Classification failed — Jev sorted 3 of 5 line items”.

Fixed

  • AI settings no longer offers “Claude Opus 4.6” or the plain “Claude Sonnet 4.6” — both ids always failed — and a setting already pointed at either one switches to the real id on its own.
  • A phone bound to no one in particular now reads “This iPhone,” not “Me’s iPhone,” on the Devices card and the AI usage page — and a phone paired twice under the same person shows as two distinct rows instead of one.
  • An AI answer that names a subcategory as the category, such as "Snacks & Treats › Candy / Dessert", is now filed in that subcategory's place on your list instead of losing its category.
  • A receipt with nothing the AI could identify now files under Miscellaneous › Other, a category on your list, instead of an "Other" category your list doesn't have.
  • A date printed in a receipt's payment line now stays readable instead of turning into dots, while a card's expiry is always hidden, and an expiry with a one-digit month is no longer mistaken for the card's last four digits.
  • A gift-card slip known only by its payments now names its gift card instead of showing nothing, and a receipt whose one payment names a card is no longer taken for a gift card.
  • Taking back an older card fold after the same card was folded again now leaves the newer fold where you put it, instead of moving that card's number and receipts back.
  • A receipt paid with a Visa gift card, including a scan whose card type reads "Visa gift card", now counts as a gift card instead of as spending on your own Visa, and the card you pick for a receipt now also sets the payment row of the expense made from it.
  • At first pairing, a phone's card is no longer matched to a Mac card of the same name on another network, and the Vendors page's quick look now reads card endings aloud as "ending 4242" and a hidden number as "number hidden".

v2.49.0

A second opinion

New

  • Jev, a second item classifier from TypeSafe, can now sort receipt items beside the AI model. Add a TypeSafe key in Settings › AI Providers and it runs in Shadow, after each scan so you never wait for it, keeping its answers for comparison without changing your categories, until you turn it On.
  • A Detailed view on receipts shows how each item’s category was decided — the AI’s confidence, Jev’s match score, or that you set it — plus a per-receipt summary. Insights now compares Jev with the AI so you can review and mark disagreements.

Fixed

  • Item categories no longer drift outside your own category list: the AI is no longer shown example categories that don't exist, and an answer naming one is trimmed to the part of it that does exist.
  • Product sizes and doses such as a vitamin's "D3 1000IU" now reach the AI as printed instead of being masked like an account number. Long card and account numbers are still masked to their last four digits, now also when a card is printed with extra spaces or dashes.

v2.48.0

Apple Pay, sorted

New

  • A card you set up that turns out to be another card's Apple Pay number — or its Google Pay, replacement or other number — can now be folded into that card in Settings › Payment methods: its number and receipts move over, its own name and owner go away, and Undo or Take back puts it back as it was.
  • Wherever a payment went through Apple Pay — on your receipts, in the receipt editor, on your cards' Apple Pay endings and in the unrecognized endings — the official Apple Pay mark now shows in place of the words.

Improved

  • On your Mac, screen readers now say a card ending as “ending 4242” — on the Receipts page, in the receipt editor and in Settings › Payment methods — instead of reading out the dots before it.

v2.47.0

Cards on your phone

New

  • A "Share card list" tick for each phone on the Devices card — with the phone app's next update, that phone can see your cards and add cards or Apple Pay endings, and a paired phone's store-name fixes reach your Mac.

Fixed

  • A linked purchase paid partly with a gift card again counts only what left your accounts when the gift-card slip listed its payment without a payment line — and Spend by card now shows such a slip on its own as a gift card, not as card spend.
  • A card number printed with unusual spaces, dots, slashes or wide digits now shows only its last four digits, and an expiry date printed beside a card's ending is no longer mistaken for it.

v2.46.0

Which card paid

New

  • Settings › Payment methods lists your cards by name — with their Apple Pay endings — asks about card endings it hasn't seen before, and shows anything from your old card-owner list that needs a decision.
  • Receipts show which card paid — Apple Pay included — and you can pick the card, fix the vendor or change the date right in the editor, then filter and total your spending by card or by person.

Fixed

  • Accepting a corrected date on a receipt now saves it instead of failing — and the expenses it belongs to take the new date too, through a linked purchase or a split included, unless you've edited that expense yourself or the new date still looks wrong.

v2.45.0

One name per store

New

  • New scans check the store name against your list and fix look-alike spellings — asking the AI only when unsure — and Review Vendors can find look-alike stores for you to merge.

Improved

  • The scan panel and "How this was read" now show each AI call's tokens and cost.

v2.44.0

Every AI call, counted

Improved

  • Insights › AI usage now records every AI call's real tokens and cost — from the Mac and your phones — by model, purpose, device and day, with an optional monthly budget warning.

Fixed

  • Receipts read from now on store a card's network one way, so "American Express", "AMEX" and "amex" arrive as the same card instead of three different ones.

v2.43.0

A scan you can watch

New

  • Analyzing receipts on the Mac now shows each one coming to life with a step rail, even for a single receipt, and "Show scan details" in Settings adds a live log you can page through.

v2.42.0

Brand logos

New

  • Brands on Review Vendors show the store's logo — found from its own website, or one you add — and your dashboard's vendor lists and the receipts list show it too; brands without one keep their initials.

v2.41.0

Review Vendors, clearer

New

  • Review Vendors shows the receipts behind any suggestion or brand — open one to see its photo, date, total and items — and you can work through suggestions from the keyboard.

Improved

  • Every suggestion on Review Vendors now says whether it creates a new brand or adds spellings to one you already have, and a spelling two brands both claim can be settled in one click.
  • A receipt you preview on Review Vendors now opens on the Receipts page in one click, ready to edit, instead of leaving you to find it in the list.

Fixed

  • Buttons and links that are meant to change colour when you point at them now do — Merge on Review Vendors no longer turns into a blank gold box.
  • Merging on Review Vendors no longer creates a second brand under a name another brand already uses — it adds the spellings to that brand instead.
  • Your vendor list is now part of the daily backup, and Review Vendors refuses to save over a vendor list it can't read instead of replacing it with an empty one.

v2.40.0

A phone of its own

New

  • Pairing a phone asks whose it is, so each person's receipts are theirs from the start — and "Fix who scanned it" corrects a phone's older receipts, with an undo.
  • Categories and stores you made on a phone used on its own are added to your Mac when you pair it — nothing already on the Mac is renamed, moved or removed.

Improved

  • A brand-new install now starts clean, with one household member, "Me", ready to rename — and none of the built-in categories or stores carried over from anyone else's setup.

v2.39.0

Linked purchases, counted once

New

  • Link a receipt and its card slip — or several papers of one purchase — so the purchase counts once and creates one expense, for what left your accounts: a gift card's share is shown, not counted.
  • Find Duplicates suggests papers that look like one purchase — a bill and its slip, or one visit paid in parts — to link in one click or dismiss for good; nothing is ever linked on its own.

v2.38.0

Receipt photos on your phone

New

  • Your iPhone can show receipt photos and PDF pages — and crop or straighten a Mac receipt's photo — once you tick "Show receipt photos" for it on the Mac; off for every phone until you do.

Improved

  • If a receipt's photo is cropped or straightened on your phone after the Mac already read it, the receipt now offers a quick "Re-read" so your records catch up to the corrected picture without you needing to remember to check.

v2.37.0

Your household, on your phone

New

  • Your iPhone can show the household's receipts. Phones paired from now on start send-only until you tick "See the household library" for them on the Mac; a phone that's already paired can see it right away.
  • Hand a receipt the Mac has taken over back to the phone that scanned it, from Settings › iPhone Companion, so the phone's own copy replaces the Mac's the next time that phone sends it.

v2.36.0

Bills and their slips

New

  • Every receipt shows what kind of paper it is — itemized, a payment slip, or both — read from the paper and yours to correct, and payment slips no longer get flagged for missing tax.

Improved

  • A receipt paid more than one way — two taps on the same card, or a gift card and a card on one receipt — now keeps each payment with its own amount, tip and authorization code instead of folding them into one.

Fixed

  • The Mac no longer treats an itemized receipt and its card slip as duplicates — the duplicate finder lists them together without a delete button, and a slip your phone sends is no longer marked a copy of its bill. A true second scan can still be deleted.

v2.35.0

Who scanned it

New

  • Receipts now remember who scanned them, shown next to the phone or laptop icon on the receipts list — sortable and filterable like the device it came from. Manage household members, their colours and whose money they're tied to, from a new Household tab in Settings.

Improved

  • Settings › Household can now fill in who scanned your older receipts, grouped by device or by Mac upload, with a preview before anything changes and a one-click undo afterwards.

v2.34.0

New vendors, added for you

New

  • A vendor you buy from for the first time now joins Review Vendors on its own, under a tidy name and marked new so you can check it. A vendor name you typed yourself is never replaced, and renaming receipts leaves those alone.

v2.33.0

A second look when the math is off

New

  • The receipts list can now be sorted by when a receipt was added, right beside the existing "Captured" sort, so recent uploads show in upload order instead of only by capture or analysis time.
  • Review Vendors can now put brand names on your receipts for you. It lists every rename and how many receipts each one changes, saves a backup before it starts, and one click undoes the whole batch, leaving alone any receipt you have changed since.
  • When a newly scanned receipt’s figures don’t add up, an AI reviewer now looks at it again. It fixes the receipt itself only when the fix adds no new number; otherwise it shows you a before-and-after to accept or reject. “Figure out why” on the warning asks it about any receipt, and each fix is listed under What changed.
  • Settings › AI Providers has a daily limit for the receipt reviewer, starting at 20 reviews and $0.50 a day. Once either is reached it stops reviewing on its own, and “Figure out why” tells you how much of today’s limit is used and offers to run anyway.

Improved

  • The receipts list's source column now shows a laptop icon for receipts added directly on the desktop instead of leaving that cell blank; the phone icon for synced receipts is unchanged.
  • Every bulk re-analyze button now confirms first, showing how many receipts are affected and a conservative cost estimate, before it starts spending AI credits.
  • Category search now shows whether a match is a top-level group, a subgroup, or a specific item, instead of a vague "whole group" label. Picking a group jumps its dropdowns to that spot so you can choose what's inside right away.

Fixed

  • Correcting a receipt’s vendor, date or category no longer stops a later re-read from fixing its totals, because a re-read now keeps just the fields you changed. Receipts you corrected before this version keep every figure until you clear their “manually edited” mark.

v2.32.0

When the tax is already in the price

New

  • Receipts whose tax went missing — a pump receipt with the tax already inside the price, or a meal that came back tax-free — now turn up under Needs review with a quiet marker beside the green tick, and the receipt panel says what is missing and what on the paper says so.

Fixed

  • When a receipt prints its prices with the tax already included, reading it now puts the pre-tax amount on each line instead of leaving the till price there, so the lines add up to the subtotal and the tax the receipt charged you is no longer thrown away.
  • A second look at a receipt can no longer raise the printed subtotal to swallow a charge printed below it. The subtotal you were charged is kept, the receipt says the re-read tried it, and the missing charge is yours to add as a line.
  • When a re-read repairs a receipt whose lines over-ran its printed subtotal, the hand-titled line that caused the over-run is no longer carried back in. The receipt tells you it was held back rather than quietly counting that money twice.

v2.31.1

Dates and notes that read true

Fixed

  • Marking a pair "Not duplicates" now works in the installed app instead of silently doing nothing, and a duplicate action that fails now says so. Deleting a receipt from the duplicate scanner stays unavailable until it can be undone.
  • Sorting receipts by when they were last analyzed no longer puts the never-analyzed ones at the top. They now sit at the end whichever way you sort, so the first click on that column shows your most recently read receipts.
  • Release dates now read as the day the release was made. The Release Notes tab dated recent releases a day early with an invented evening time, and the website’s older entries showed a raw timestamp where a date belonged.
  • The release notes you read in the app and on the website now describe what each release actually gave you. Older entries ran long and overstated a few changes, describing as something you could use work that never reached a screen. They now read the way the app really behaves.

v2.31.0

Refunds stay refunds, and items stop doubling

Improved

  • The Bug Fixes tab now reads straight from the release notes, so it can no longer fall out of sync with them. Entries display in full, the count at the bottom is accurate again, and a search box replaces the old severity and status filters.

Fixed

  • When a receipt prints how many items it sold and that many have already been read, the second pass can no longer add another copy of a line you already have — unless the addition closes the remaining shortfall to the cent.
  • A refund keeps its sign. Editing the tax or tip on a refund can no longer turn it into a charge, and a refund whose printed total runs past its parts now keeps the printed number, exactly as a purchase does.
  • Re-reading a refund can no longer turn it into a charge. A fresh reading that comes back positive over a stored refund is refused and the receipt records why; a reading that corrects a charge into a refund still lands.
  • Describing a receipt can no longer turn a refund into a charge when you mention an amount, and no longer undoes a change — such as a scan arriving from your phone — that landed while the description was being written.
  • A purchase and the refund that reversed it are no longer offered as near-certain duplicates of each other, so the pair stops surfacing at the top of the duplicates list.

v2.30.0

Your phone keeps what it scanned

Fixed

  • A receipt you scan on your phone is no longer taken away from it the first time this Mac re-reads the receipt with AI. A routine re-analyze used to look exactly like a hand edit, so the phone's next push came back as a hard conflict and the phone stopped offering the receipt at all.
  • The desktop now tells a change made by a person from one made by the machine, and most of the machine's own rewrites leave the receipt yours — an AI re-read, a vendor rename, a math repair. A few cases were not covered. Editing a receipt here by hand still takes it over, exactly as before.
  • Receipts already stuck in that state are not touched: whether to hand one back to the phone is a decision about your data, not something a code fix should make quietly.

v2.29.0

Pairing that survives a new address

Improved

  • Pairing now offers the right address on a Mac with more than one. A VM, a VPN, Internet Sharing or a USB adapter could previously hand your phone an address it cannot reach, simply because the system happened to list it first.
  • Real Wi-Fi and Ethernet are now preferred over VPN tunnels and virtual adapters, a self-assigned address is never offered, and an address that is genuinely your only route out is still used rather than discarded.
  • New pairing certificates no longer name a network address that can go stale. Your existing certificate is left exactly as it is: nothing here asks you to pair again.
  • The app now warns you 60 days before its pairing certificate expires. Renewing one means pairing every phone again, so an expiry that arrived unannounced looked exactly like the desktop having been reset.

Fixed

  • Pairing your iPhone now survives your router giving this Mac a new address. Pairing hands the phone this Mac's network name rather than a number, and the name keeps pointing at the Mac wherever the router moves it.
  • Nothing changes on the phone — no new permission to allow, nothing to re-pair. A Mac whose name cannot be used as a network address falls back to the old numeric behaviour exactly as before, so this can only ever help.
  • Discovery no longer stays dead after your Mac's networking restarts. Announcing this Mac on Wi-Fi used to stop for good whenever macOS recycled its networking — joining a different network was enough — leaving the phone no way to learn where the Mac had moved to.
  • It now comes back on its own, backing off and giving up after a few tries rather than retrying forever, and it never retries at all when the underlying system tool is missing outright.

v2.28.0

Receipts that add up, and a photo you can straighten

New

  • Money back now tells you when a refund has been recorded as spending. A refund has to be stored as a negative amount, so one typed in the obvious way is counted as money spent while the Refunds tab stays empty.
  • A new panel at the top of Money back names those rows, shows what they cost, explains that the fix is a minus sign, and links straight to each one. It reads the description text and nothing else: it never guesses which purchase a refund belongs to, never changes anything for you, and stays quiet when there is nothing to say.
  • Scans now keep the authorization code the card terminal printed as its own field, separate from the receipt number. It is the identifier that tells two charges apart when a shop puts both on one slip, and ties two slips together when a bill is split across them.
  • A labelled auth code now survives the digit masking that a text-readable PDF goes through before a scan, which used to destroy it. Card and account numbers are still masked as before.
  • Receipts photographed on the laptop can now be straightened and cropped here, instead of only on your phone. The preview gains Rotate left, Rotate right, and a four-corner Crop with a magnifier for placing each handle exactly on the paper's corner.
  • The crop squares the paper up as well as trimming the desk away, so a receipt shot at an angle comes out rectangular rather than merely smaller.
  • The photograph you started with is always kept: the first edit files a copy away, no later edit ever replaces it, and one Original button puts it back exactly, byte for byte, however many crops and rotations happened in between.
  • After an edit you are offered a re-analysis rather than given one, since reading the receipt again costs credits. PDF receipts are deliberately left alone and say so on screen — many are read from their own text layer, which is cheaper and more accurate than reading a picture.

Fixed

  • The payments recorded on an expense now have to add up to what the expense cost. Every expense write — new, edited or bulk — refuses a set of payments that does not sum to the amount rather than storing it.
  • A receipt that knows the card's last four digits now fills those digits in on the payment already recorded, instead of adding a second payment and claiming twice what the expense cost.
  • A receipt describing a purchase paid in several taps on the same card is now taken as it stands, all of it, instead of collapsing to one payment and leaving the row claiming half of what was paid.
  • Nothing is ever adjusted to make a set of payments fit, and rows already stored wrong this way are left untouched — which of two identical payments was the real one is not something the row can say.
  • Editing an ad-hoc expense no longer quietly rewrites how it was paid. An expense paid across two cards used to have both payments replaced by a single one at the full amount, and lose the card's last four digits, just for a change to its category or its notes.
  • A payment you did not touch is now left exactly as it was, digits included. Changing the amount under a split is refused with the reason rather than divided up by guesswork, collapsing a split to one payment asks first, and a refused save says so on the form instead of looking like it worked.
  • Editing a refund receipt can no longer turn it into a purchase. A recompute may still repair what a total says, but it can no longer change whether the receipt is money out or money back.
  • Typing a total yourself is untouched — your number still wins, in either direction. Nothing was repaired retroactively: a receipt already stored the wrong way round keeps its figure and is still owed a real fix.
  • A receipt's second reading can no longer append the whole receipt to itself and double its items. That second read may now only add up to the money the receipt is actually still missing, and a reading that claims more than that is refused whole rather than trimmed.
  • The limit is deliberately judged on money and never on wording, because shops really do print the same product on several lines and those repeats are often exactly what makes a total come out right. Your own receipts were checked and none of them carry the fault.

v2.27.0

One name per brand

New

  • One canonical name per vendor brand, consulted on every scan. A registry holds the name you chose for a brand and every spelling that means it, and any receipt scanned from then on — on the desktop or on your phone — lands under that name.
  • Merging cannot be automated: similarity scores do not separate two spellings of one shop from two genuinely different shops, so you accept every merge yourself. A merge you make by hand is stored exactly like one you accepted from a suggestion.
  • A suggester proposes the work, pairing spellings rather than chaining them, so a wrong guess can never drag a right one along with it. Nothing is renamed and nothing happens until you accept something.
  • The list of accepted vendor brand names is settled here, so a brand you name is the one every future scan files under. Applying it to receipts you already have is not yet something the app can do for you — older receipts keep the several spellings they were read with.
  • The rename writes the vendor and nothing else: it does not mark those receipts hand-edited, does not touch their categories, their confidence or their totals, and does not go near the amounts.
  • The safety rules for that future pass are settled too: nothing would be written without being shown and confirmed first, every spelling it replaced would be kept so a batch can be put back, and a re-run with nothing to do would change nothing at all.
  • Receipts your phone still owns keep their ownership across the rename, and ones the desktop already took over are left as they are.
  • A Review Vendors screen, under Receipts, where you work through those merges. Each suggestion shows the actual spellings and how many receipts carry each one, and leads with the reason it was found — the score is deliberately a footnote, because it ranks true merges below pairs that are plainly wrong.
  • The suggested name is only ever a default and sits in an editable box, so a shouted or lowercase spelling becomes a properly written brand name as you accept it.
  • A second tab lists every spelling with a search box, and that is where you make the merges no score will ever suggest — tick two spellings and name them. Merges made that way are stored exactly like accepted suggestions.
  • Turning a suggestion down is remembered and it does not come back. A string that is not a business at all — your own name, a street address, a card-terminal descriptor — can be marked as such, which never renames anything; it just stops the screen asking.
  • Everything is reversible: brands can be renamed or unmerged, and both turned-down suggestions and not-a-vendor marks can be restored.

Improved

  • The vendor brands you accept now reach your phone. The names, and every spelling that means them, ride the taxonomy the phone already downloads after a sync, so a merge you make here starts applying to phone scans with nothing to pair, allow or rebuild.
  • Only the finished list of names crosses to the phone. Suggestions you turned down, and strings you marked "not a vendor", stay on the desktop where they belong.

Fixed

  • Renaming a vendor to its brand name no longer weakens the match between a receipt and the bank charge that paid for it. A bank descriptor is now scored against the brand's name and every spelling the registry holds for it, keeping the best — which can only ever raise a score, never lower one.

v2.26.0

Receipts that add up wrong, and the ones that only look right

New

  • Receipts page — a "Captured" column and sort on the receipt file's own date (its photo's EXIF capture date, or a PDF/XMP creation date), independent of when it was uploaded or last analyzed; receipts with no readable file date always sort to the end.
  • A zero-tax receipt that should carry tax is now spotted as it is read. Such a receipt passes every arithmetic check there is and shows a green tick, so nothing else would have questioned it. On the desktop nothing is put on screen for you — it is the phone that shows the marker.
  • It asks for positive evidence that what was bought cannot have been tax-free — a meal, a coffee, a delivery — because groceries, bank transfers, property tax and health services genuinely carry none. Nothing is written and no rate is ever guessed at.
  • Groundwork for reconciling a receipt whose subtotal does not add up against what the paper actually prints. The plain cases — tax-inclusive menu prices, a rounding gap, or totals you corrected by hand, where your figures win — are settled by arithmetic alone. There is nothing to press yet.
  • The rest would go back to the image with a plain-language account of what is off, and the answer "I can't tell from this receipt" would be kept rather than retried into a guess, so a creased or cropped receipt stays flagged instead of being quietly balanced.
  • Nothing would ever be invented: every figure has to be quoted from a printed line, a line is only dropped when the stored data independently shows it was never a purchase, and the receipt has to genuinely balance afterwards or the whole correction is discarded.
  • A change that touched only the subtotal, tax, tip or total would be shown to you rather than applied, because arithmetic cannot tell a printed tip from a hole. Nothing runs by default, and nothing would be written without a second explicit confirmation.

Fixed

  • A receipt that prints a net total above a larger gross payment is no longer read short. A scan now takes the gross payment as the total when the gap is exactly the printed tax, and refuses to mistake cash tendered for what was owed. It applies to receipts read from here on.
  • A tip printed on a card-terminal slip no longer disappears: an "AMOUNT / TIP / TOTAL" slip — the Canadian card-terminal layout — now records the printed tip instead of leaving it at zero between two endpoints that happen to add up.
  • Re-analyzing a receipt can no longer quietly break its own arithmetic. A re-read whose total stops agreeing with its own subtotal, tax, tip and after-subtotal charges — by more than a cent AND by more than the stored read already did — now keeps the four previous figures and is flagged for review.
  • A re-read that moves a receipt TOWARD balancing always lands, however far it still has to go, so a receipt whose totals are already off can still be repaired by a better read.
  • Only those four figures are ever refused: the new lines are kept, and unlike every other refusal the receipt's category is still recomputed from them.
  • Correcting a receipt's vendor name can no longer change what it says you paid. A rename, a date fix, a re-category, or a save that changes nothing now leaves the total, subtotal, tax and tip exactly as they were, instead of silently recomputing them.
  • Correcting a real figure still recomputes the total, and so does flagging or voiding a charge printed after the subtotal, which moves the sum even though no amount changed.
  • Nothing was repaired retroactively: receipts that were rewritten this way keep the figures they have, and a couple of them are stored wrong today and still owed a real fix.

v2.25.1

A deleted line no longer freezes re-analysis

Fixed

  • A receipt you deleted a line from can be re-analyzed again. Your own deletion was being counted as detail a new read had lost, so every re-analysis was refused and the receipt was frozen for good.
  • A read that really does collapse a receipt's itemisation is still refused, deleted lines or not, and deleted lines still stay deleted.

v2.25.0

Charges after the subtotal, detected

Fixed

  • Re-analyzing a receipt can no longer quietly trade its itemisation away. A re-read that collapses to one line standing in for the whole receipt, or that returns fewer lines which ALSO add up worse than the ones already stored, is refused — the receipt keeps what it had and is flagged for review.
  • Genuinely removing a duplicated line makes the arithmetic better rather than worse, so those corrections still land.
  • On a receipt whose totals you corrected by hand, the over-collection check now measures against YOUR subtotal — the one that is actually kept — rather than the number the new read proposed.
  • A charge a receipt prints between its subtotal and its total — damage protection, a bottle deposit, an eco fee, cash rounding — is now recognised from the receipt's own arithmetic instead of relying on the scan to say so. Receipts already stored were not re-read.
  • A fee that is really INSIDE the printed subtotal is no longer marked as sitting outside it, which used to break a receipt that balanced perfectly. Both halves have to close to the cent before anything changes, so a receipt that was simply misread is left alone.
  • Re-analyzing a receipt no longer loses its printed transaction time or the item count the receipt prints for itself when the new read simply does not mention them — the same protection the receipt number, card type, last four digits and payment method already had.
  • A printed item count of zero is treated as "not printed" rather than as a reading, so a stored count is never erased by one, while a genuinely different time or count from the new read still wins.

v2.24.0

Category search, receipt source, and safer re-analysis

New

  • Category search is now one rule shared with the phone — type part of a category name or its description and get ranked full paths, with typo tolerance ("makup" still finds Makeup) — pinned by a new parity fixture.
  • Every category picker on the desktop — each receipt line, the overall category in the row and the preview, the Vendor Learning card and the Ad-hoc expense form — has a search box above the dropdowns: type a few letters, ↑/↓ then Enter, and all three dropdowns fill in with the full path; whole groups are pickable too.
  • Receipts page — a phone icon now marks receipts synced from a paired iPhone (list view gets its own column, card view keeps its "iPhone" chip), with a "Source" sort (phone-captured first, then by date) and an "All / From phone / Not from phone" filter that composes with search and the other filters.

Fixed

  • A receipt or line whose stored category has since been renamed or removed now shows it in the dropdowns as "(legacy)" instead of a blank "—" (and no longer hides its sub-levels), and the Receipts page now merges the ad-hoc and recurring hierarchies with the ad-hoc copy winning a name clash — the rule the phone already uses.
  • Setting a receipt's overall category to "—" now really clears it; it used to save nothing and silently keep the old category, and clearing it no longer risks blanking the linked expense's category.
  • Re-analyzing a receipt can no longer quietly lose what was already known about it: a receipt number, card type, last four digits or payment method the new read simply did not mention is kept rather than erased.
  • A description you typed yourself is re-attached to the matching line on a re-read, or the line is kept whole at its printed position rather than dropped.
  • A re-read whose lines add up to more than the printed subtotal, or that comes back with no lines at all, is now refused outright — the receipt keeps what it had, keeps its category, and is flagged for review instead.
  • Scans no longer read an address, a phone or account number, a currency-conversion line, a per-litre restatement of the same fuel, or an estimated-vs-actual pair as things you bought.
  • A saving a store has ALREADY taken off the shelf price is no longer subtracted a second time. Scans now know the words receipts print for it — "Saving", "You saved", "Promotional discount", "Économie" — though only the arithmetic, never the wording, tells it apart from an ordinary discount.
  • When a receipt's lines fall short of its printed subtotal by exactly the discounts applied, the shelf price is restored automatically: the receipt balances on its own and the saving stays visible instead of having to be deleted by hand.
  • A receipt that prints its tax inside the price now has that tax read rather than reported as zero, wherever the receipt states the figure plainly. Receipts that only say tax is included without printing it — fuel pumps especially — stayed a known gap.
  • A tax printed in several parts, such as federal plus provincial or two TVA rates, is now reported as their sum, and a tax you can read is never zeroed to close the arithmetic.
  • A fuel receipt that still lands with no tax at all never has a tax invented for it. The read leaves the zero it was given rather than guessing a rate — but nothing on screen tells you it happened, so such a receipt still looks as settled as any other.
  • Correcting a receipt by hand now re-scores its extraction confidence instead of leaving the number the broken scan earned — repair the arithmetic and the score rises, mistype an amount and it falls. Receipts nobody edited are never re-scored.
  • The receipt detail and preview now show that score, labelled "you checked it" once a person has edited the figures and plain "AI" until then, so a receipt you fixed no longer reads as though the scan got it right.

v2.23.0

After-subtotal charges, tax-inclusive receipts & the future-date guard

New

  • A charge a receipt prints between its subtotal and its total — damage protection, a bottle deposit, an eco, bag or delivery fee, a warranty, cash rounding — is now kept as its own "after subtotal" line, out of the items-vs-subtotal check and the item count.
  • An after-subtotal charge is added to subtotal + tax + tip, shares the tax with the rest of the receipt, and gets its own line on the linked expense. The phone learns the same shape.
  • The line-item editor gains an "after subtotal" toggle on every row, a "Charges after subtotal" line in the totals, and — when the printed total is more than the parts — an "Add it as a line" button instead of a "click to fix" that would have overwritten the printed total.

Improved

  • The date review names a date the scan cleared ("future date cleared at scan") and the preview pane shows how a swapped date was read; a future-dated receipt with no photo capture date is now offered its day/month swap as a suggestion instead of nothing.

Fixed

  • Re-analyzing a receipt no longer throws away what was already read from the paper: a line's quantity and price-each, a line marked voided, and a charge marked "after subtotal" all survive the fresh read, and a line whose amount the fresh read corrects gets an honest price-each instead of keeping the old one.
  • Saving a receipt whose printed total exceeds subtotal + tax + tip no longer rewrites that total, and a slip with a zero subtotal is no longer zeroed by an edit to its header.
  • The extraction-confidence subtotal check now judges the same rows as the ✓/⚠ chip, so the stored confidence and the chip can no longer disagree.
  • A receipt printing "HST included" now reads its subtotal as the total minus tax, with the fuel or item lines at their pre-tax amounts, instead of leaving a line carrying the full tax-inclusive price.
  • A receipt carrying a tip is left untouched by that correction, and one whose prices were already pre-tax but whose subtotal had been copied from the total gets just the subtotal corrected.
  • A scan can no longer date a purchase after the day the receipt was scanned. A future date is repaired on the spot by swapping the day and month when that lands on a real, non-future day — "02/06" now reads Feb 6, not Jun 2 — and the phone applies the same rule.
  • A future date that cannot be repaired that way is cleared instead, and the receipt joins the "dates need review" queue showing both the original reading and the scan day. Receipts already stored are never changed.
  • The rule holds even if you saved a custom scan prompt before this release, though that prompt will not mention the scan date until you reset it.

v2.22.0

Math checks, printed quantities & voided lines

New

  • Receipts that print the same item on several lines now read at a glance: the line-item editor shows a repeated-lines summary and tags each of those rows ×N, and the receipt preview folds them into a single ×N row — while every printed line stays its own editable row. The phone folds them the same way.
  • Receipts now remember the item count printed on the paper and whether a line was voided, and both survive a sync from your phone.
  • A quantity left inside an item's name is now folded into a proper quantity and unit price after every scan — never for menu numbers, "2 for $4" promos, a leading "1", item codes or percentage lines.
  • Every analyzed receipt now shows whether its math checks out: a green check when the lines add up to the subtotal and the total equals subtotal + tax + tip, a red ⚠ with the amounts when either fails, and "Slip" or "Refund" markers where there is nothing to add up. The badges show in the list and on cards.
  • The receipt editor shows "Items (N · receipt says M)" — red when they disagree — quantity × price-each fields on every row (the amount stays the line total and the other two follow it), an "Add quantity" button on rows without one, and a void toggle that strikes the line through and takes it out of the count.
  • A receipt now lists the lines you've deleted from it and lets you restore one or clear them all; restored lines come back the next time the receipt is analyzed, which the desktop offers right away. On a receipt your phone owns, the phone's own list still wins on its next sync until the desktop re-analyzes it.
  • Every line in the receipt editor now has a "+1" button that adds an identical copy directly below it — name, item number, amount, quantity and category included — for receipts where the same item was rung up several times but only one line was read. Same as the iPhone app's "+1".

Improved

  • A card-terminal payment slip — no items, just a card or a tip printed — no longer spends a second verification read, and a correctly read slip now scores 70% confidence on its next analysis instead of 35%.
  • A struck-through line no longer counts as a mismatch when the printed subtotal already excludes it, and a refund receipt keeps its negative signs and can now balance instead of failing every arithmetic check. The phone shares the same math check, receipt kind and slip-aware scoring.
  • Receipt scanning now reads a quantity printed before the item name, or as a "(2 @19.00)" marker, and folds it into one line with a quantity and a price each. It also reads the item count the receipt prints for itself.
  • Crossed-out and VOID lines are now kept as voided rows rather than counted, and a modifier line is named after the item it belongs to.
  • A percentage service charge inside the subtotal now reads as an item, an auto-gratuity printed between the subtotal and the total reads as the tip, and a refund whose amounts print with a trailing minus keeps them negative.
  • The accuracy chip now reads "N need review" and filters to every flagged receipt. The accuracy percentage itself is unchanged.
  • Voided lines are now left out of the item count, the classification, the linked expense's split and the overall category, and line-item edits can set a quantity and unit price — the amount stays the line total and the other two follow — or mark a line as voided.
  • A refund or exchange receipt no longer has its negative rows coerced into discounts or merged away during cleanup: a discount line on a return still reduces the return, toward zero rather than past it.

v2.21.0

Repeated receipt lines stay separate

Improved

  • The second verification read now also runs on small receipts — when the items read fall more than 10% short of the printed subtotal, or when no items were read at all, not only on receipts over 10 items — and it runs on both scan paths. It is now asked to report every extra printed occurrence of a repeated line.
  • The iOS parity fixtures now pin the verification gate and the twin-aware duplicate and deletion rules.

Fixed

  • Receipts that print the same product on several lines — one line per unit — now keep every printed line as its own row instead of collapsing them into one, which used to leave the receipt short and its subtotal mismatched.
  • A repeated line is now only ever removed when the receipt's items add up to MORE than a printed subtotal that reconciles with the tax and total. On a receipt with no printed subtotal to check against, nothing is deleted.
  • Deleting one of several identical rows on a receipt no longer marks that item as "never bring this back" — a later re-analyze keeps the copies you left in place. Deleting the last copy still sticks.

v2.20.1

Clean guard run

Fixed

  • The internal dead-code guard no longer prints a stale debt note about two receipt line-item helpers, so the pre-release check suite runs clean.

v2.20.0

Cross-device duplicate marks

Improved

  • The desktop now honors the duplicate marks and store-name equivalences you make on your phone — a receipt your phone already flagged as a duplicate shows up (and is labeled) in Find Duplicates, and store-name aliases you've taught your phone (e.g. "Costco" = "Costco Wholesale #1234") strengthen automatic duplicate detection on both the phone-sync and the desktop scanner.

v2.19.0

Smarter duplicate matching

Improved

  • Duplicate detection now treats "The Home Depot" and "Home Depot" (and store numbers/provinces, e.g. "Costco #1234" or "Shell ON") as the same store, and flags near-misses — a similarly-spelled vendor, a total off by a penny, or a missing date on one copy — as a possible duplicate instead of missing them entirely.

v2.18.0

Duplicate receipts + printed time

New

  • The desktop flags receipts arriving from your phone that look like duplicates of one already on file — same store, day and amount, with a stronger "Duplicate" badge when the receipt number, line items, or time also match.
  • Receipt scanning now captures the printed transaction time when your phone can read one, off the receipt itself.

Improved

  • Deleting a receipt on your phone now removes it from the desktop too, and a desktop-side delete cannot be undone by a phone that syncs back later.

v2.17.1

Easier iPhone pairing

New

  • Your Mac now advertises itself on the local network, so the iPhone app can find it by name and pair by typing the 8-digit code — no scanning required. The pairing screen now presents all three ways to pair (type a code, scan, or paste a link) with the quick-vs-secure trade-off spelled out.

Improved

  • The iPhone pairing QR is bigger and has a proper quiet zone around it, so it actually scans instead of failing on real phone cameras.

v2.17.0

iPhone companion link

New

  • Pair your iPhone from Configurations → iPhone Companion — a QR code (or copyable pairing string) connects the WholeBudget iOS app over your home network, secured end-to-end with a pinned certificate and revocable per-device tokens.
  • Receipts scanned on your iPhone flow straight into the desktop — arriving fully analyzed with their categories and corrections intact, filed and auto-matched against imported bank rows immediately, with an "iPhone" chip showing where they came from; phone edits keep syncing until the desktop takes ownership.
  • Export scripts generate the iOS companion's taxonomy/prompt snapshot and golden parity fixtures straight from the desktop's own pipeline code.
  • The iOS companion's parity fixtures now cover manual receipt editing and re-analysis merging.

Improved

  • The AI model registry catches up — GPT-5.6 Luna and Terra, Claude Sonnet 5, and corrected Haiku 4.5 pricing.
  • Receipt post-processing (discount merging, tax and tip allocation) now lives in a reusable library module with its own tests — groundwork for the WholeBudget iOS companion app.

Fixed

  • Selecting a GPT-5.6 model no longer breaks scanning — the app now omits the temperature parameter those models reject (and retries without it for future models).
  • AI fallback no longer targets a retired Claude model id — dead ids are repaired to a live model automatically, un-breaking the fallback path.

v2.16.0

Accounts that know their own balance

New

  • Account balances roll forward from your imported statements — record a balance once and the app carries it through every transaction imported since, so Net Position and Runway answer "what is in there today" instead of "what was in there the day you last typed it in". Both cards state exactly what each number rests on, an account with no recorded balance shows nothing rather than a made-up zero, and a partially-covered total says so instead of describing itself by its freshest part.
  • Statements now say which account they belong to — every uploaded file shows the account it landed on, and when nothing matches it offers to link an existing account or create one pre-filled from the statement.
  • After importing into a linked account, the app offers to bring that account's balance up to date — it works out the new balance from what you just imported and asks; it never writes it for you. Accepting files an ordinary snapshot marked as one you accepted rather than typed.
  • A balance you type that disagrees with the app's own arithmetic is recorded with the gap — your number always wins, and the app keeps score of how often its math was wrong.
  • The dashboard charts net worth over time — one point per month from your recorded balances, today's point rolled forward through imports; a month with no data is a gap, never a guess, and one data point is labelled as not-a-trend.
  • The Accounts page groups by type with a 12-month balance sparkline per account, a staleness flag past 30 days, and one-click Snapshot now; an account's own page lists every imported transaction linked to it, its rate/limit/maturity, and the balance-disagreement log.

Improved

  • Accounts moved out of Analytics into its own collapsible Financial section in the sidebar.

Fixed

  • The Net Position card no longer tells you to add accounts you already have — with accounts set up and no balance recorded, it now names the real gap and links to where to fix it.

v2.15.1

Category descriptions catch up with the rename

Fixed

  • The renamed categories now describe themselves correctly to the classifier. Most had carried their old wording along and some had none at all, so a category meant for any occasion still read as being about one — which would have quietly undone the point of the rename. The descriptions now match what they cover.

v2.15.0

Occasion becomes a tag, categories become reusable

New

  • An event can now be the kind of occasion it actually is — a shower, a birthday, a graduation, a funeral — alongside the weddings and vacations already there. Imported expenses dated inside an event's window arrive already tagged to it, and a tag you set or clear yourself is always kept.

Improved

  • Categories no longer name the occasion an expense was for. Each is now the kind of vendor you would hire for any occasion, so a shower or a housewarming files exactly as a wedding does. Nothing got less specific — every leaf still exists, one level deeper — and the occasion is now a tag.

Fixed

  • Subscriptions now file under a real category. Fifteen of the seventeen subscription templates sat on `Subscriptions & Software`, a category that existed in no hierarchy — it could not be chosen from any dropdown, skipped validation, and lumped news and sports subscriptions in with design and cloud-storage tools. Each one now lands under Media & Creators or Software & AI with a proper subcategory, and the older subscriptions list stops inventing that category too.
  • Everything the classifier had already decided now points at the new categories too, not just the transactions. Vendor memory, the per-merchant shortcuts, receipt classifications and per-item splits were re-pointed together, so nothing keeps pulling new rows onto a category that is gone.
  • The upcoming-expenses calculation no longer counts a subscription twice. Six of the eight records in the older subscriptions list also exist as recurring templates under a different amount — the same subscription carrying a different price in each place — and both were being added. The template wins, since it is the one every other screen reads.

v2.14.1

Website build fix

Fixed

  • The website build no longer fails. A helper that reads receipt files was reachable from a page that runs in the browser, so the build tried to bundle Node's filesystem module for the browser and stopped. Nothing about the app changed — only where one line of code lives.

v2.14.0

Receipt dates read from the file itself + cloud-mirror groundwork

New

  • The app copes with the website's database being switched off. If the cloud mirror is paused or this Mac is offline, syncing says so plainly — that nothing was lost, your data is here, and it will publish next time — instead of reporting an error, and it tells you the date of the snapshot the website is still showing so a stale page is never mistaken for a current one. Rejected sign-in details are reported separately, because that one you do have to fix.
  • Groundwork for viewing your budget on the web. The app can publish a snapshot of the ledger to a private cloud mirror that only you and Chelsea can read; the app stays the only thing that writes, so nothing can be changed from a browser and there is no syncing to go wrong. Receipts and their images never leave your Mac.
  • Anything that could identify a real bank account is stripped before a snapshot leaves the machine — the mortgage account and transit numbers, the last four digits, and the free-text account notes that quietly contain a transit number. If a new field ever slips past that list the upload is refused outright rather than sent.
  • A date-review panel on Receipts that offers the fix, not just the complaint. Each flagged receipt shows what its file says, why that contradicts the extracted date, and a corrected date you can accept in one click — four of yours are a day and month read the wrong way round, landing exactly on the file's own date, and the eight receipts with no date at all get the date their photo was taken. Twenty-one can be accepted together; the rest offer an AI re-extract instead of a guess.

Improved

  • The website now builds from exactly the dependency versions the app was tested against. The repo has no lockfile of its own — the workspace owns it — so the hosted build was resolving 37 dependencies fresh each time and a routine upstream release could have broken the site with nothing changed here. This matters more now that the site is about to hold real data.
  • "Receipts linked to a transaction" is a number that means something again. It read under 1% because 148 of the receipts counted against it are from before you started tracking — real expenses whose bank statements were never imported and never will be. Those are now counted separately and named as such, so the figure measures receipts that could actually be matched.
  • The review tracker now lists what v2.13.0 shipped — the website and the reworked Balance page — so nothing that went out is left unexercised, and it records that the site is publicly reachable rather than login-gated, which is a call worth making deliberately.

Fixed

  • A receipt's date is now judged against the receipt's own file — the photo's capture stamp, or the document's creation date — rather than against an assumption about how old your paper can be. Genuinely old receipts stop being called misread, and their dates are trusted for matching again.

v2.13.0

The website goes live + Balance charts and drill-downs

New

  • WholeBudget has a website. It leads with what the app does, links straight to the newest signed macOS build, and carries the full release-note history — the same history the app's own Features tab shows, so the two can never disagree. Cutting a release and pushing updates the site and the app together with no extra step.
  • A /download page that always resolves to the latest published DMG, with the install steps and what the build requires — an M-series Mac, notarized, self-updating.
  • The Balance Projection has a Charts view — a waterfall from what you earned down to what is left, a runway line that names the month it first goes negative, a stacked view of spending under your income, and a heatmap that makes lumpy bills stand out. Every bar opens its own breakdown.
  • Every figure on the Balance page is now clickable and says what it is made of. A breakdown names what produced the number and states plainly whether it is real transactions from your ledger, a forecast from your recurring templates, or the older subscriptions list.
  • The Balance page follows the Alex / Chelsea / Duo switch. Income and ad-hoc spending re-scope to the person you pick, and a banner plus a marker on each affected column say what the switch does not change: recurring household bills are not split by person, so a single-person view weighs one income against all of the household's bills.

Improved

  • The website is a download page, not a copy of the app. It ships with no data directory and no database, the whole financial API refuses every request that does not come from your own machine, and the app's pages redirect to the landing page rather than rendering an empty shell.

Fixed

  • The website now builds. Every deployment since 16 August failed before compiling a single file, because the build configuration pointed the file tracer at a folder that only exists on Alex's Mac and the build aborted with "distDirRoot should not navigate out of the projectPath". The desktop-packaging options are now applied only when packaging the desktop app, and the path is worked out from where the project actually is instead of being written down.
  • The Year-over-Year comparison no longer reads as all zeros. It was taking the year off a month label the wrong way and never asking for the prior year at all, so both sides came back empty while the panel looked perfectly loaded. It now compares real figures against real figures.
  • Today's row is highlighted on the Balance page again. The check compared "Aug 2026" against "Aug-2026", so the tint and bold marking the current month had never once appeared.

v2.12.0

Safe re-imports, auto-link suggestions + a guard against dead features

New

  • Dedupe settings now have an auto-link detection card and an audit panel. The card turns the detectors on or off and decides what a match may do, spelling out that automatic linking changes your totals without asking; the panel lists every pair proposed, with its rule and your decision.
  • Transactions now opens with a suggested-links queue, so a monthly fee and the rebate that cancels it arrive already found. Each suggestion names the rule that matched it, says how confident it is in plain English, and spells out the effect on your totals before you accept. Dismissing is free.
  • A build check now fails the moment a piece of the app is finished, tested and then never actually called. Three features had shipped that way — the worksheet memory and the multi-account transfers above, and paystub matching before them — each with a full set of passing tests that only ever exercised the part nobody was calling.

Fixed

  • Duplicates found *within* a single drop now really merge. The second copy was matched against a row the server had never seen, so the merge — and anything you typed into that row's merge panel — was thrown away without a word. Any merge that still cannot be applied is now counted.
  • A failed import no longer reports success. If the server rejected the write, the status bar still read "✓ Imported 0 ad-hoc · 0 recurring · 0 income" — a checkmark over lost financial data. It now leads with what failed, how many rows, the reason the server gave, and where to re-import them; partly-failed batches report the real saved count instead of quietly under-counting.
  • Clicking Preview twice on the same pending upload no longer loads that file's rows a second time. The duplicated expenses excluded themselves, but duplicated deposits stayed checked and would have been imported twice; a re-preview now replaces the file's rows the way switching a workbook sheet already did.
  • Statements from before commit tracking existed can no longer be re-analyzed and re-imported. Every one of your 246 archived files is treated as already imported everywhere else in the app, but the re-analyze endpoint read the marker differently and let them through — exactly the files most likely to double-import.
  • Re-importing a statement that overlaps one you already imported no longer duplicates your deposits. Deposits, card payments and cash-back rows were exempt from duplicate exclusion, so an exact copy stayed ticked and imported again; those now merge into the row that already holds the money.
  • The import preview's counters now say what the Import button will actually do. Rows about to be imported were counted as merges and then counted again as skips, so the labels could add up to more than the batch in front of you. Everything now reads from one shared calculation.
  • Previewing a spreadsheet statement again now reads the same tab the first import read. Nothing had actually recorded the choice, so a re-parse took the first sheet — and a workbook whose ledger sits behind a cover page came back with the cover's rows instead of its ledger.
  • A transfer that moves through three accounts now clears completely. When money left chequing, landed in savings and moved on to an account you don't import a statement for — an investment or registered account — the last leg was left behind as real "spending" for money that only changed accounts. All the legs that moved together are now cleared as one, with the final leg marked as needing a second look and one click to unlink it if it really was a purchase.
  • Money that passed straight through one account is no longer cancelled out. A deposit that arrived to fund a same-day payment from the same account was being treated as a transfer, so both real amounts vanished from your totals; they are still shown as linked, but both now count.
  • The transfer amount tolerance and date window now apply while you are reviewing an import, not just afterwards. Matching against transactions you had already imported ignored both settings, so the panel on screen and the panel beneath it followed different rules; widening the window also only ever pulled more rows in and then rejected them, so the setting worked in one direction only.
  • The import screen's "near-miss" explanations now quote your own settings. They described the built-in ±$0.01 and ±3 days as fact, and — worse — a pair your narrowed window had just rejected was the one pair the panel refused to list, because it was checking against the old numbers.
  • Marking one of two identical charges no longer marks the other. The import review told rows apart by date, amount, vendor and owner alone, so a same-day repeat charge had no identity of its own and one link landed on both. Each row now has its own, and two links land on two rows.
  • Pairs you mark on a supplementary card now survive the import. Rows on the second cardholder's card were identified as one person's while you reviewed and saved as the other's a moment later, so every pair marked on them failed to link. The card's own cardmember column now decides.
  • A joint-account row is no longer offered as a transfer match it can never keep. Your joint-savings statement imports as Shared, which the review screen would pair and the scan that runs right after committing would then refuse — the two used different rules. They now use the same one.

v2.11.0

Review fixes: honest dashboard math, live receipt truth, date sanity

New

  • Receipts now flags dates the extractor got wrong — dates falling before you tracked anything, dates after the receipt was uploaded, and receipts with no date at all. A chip filters straight to them, the matcher ignores a bad date instead of scoring against it, and correcting one clears the flag.

Improved

  • The Committed card's sparkline now plots committed dollars per month instead of committed-as-a-share-of-income, so it stops collapsing to a single bar in months with no income recorded; the share moved into the tooltip, which says "No income recorded" rather than showing a flattering 0%.

Fixed

  • The dashboard's Imports & Receipts card now counts receipts whose line items don't add up live — 37, the same number the Receipts page shows — instead of 103 from a stale flag that also counted receipts with no line items at all, and it no longer labels the last upload date as the last receipt date.
  • The Income page's Earnings / Rebates / Reimbursements tab counts and dollar totals now follow the owner and event filters, so they match the table underneath — viewing one person used to read a 21-payment total over a 19-payment table and never changed when you switched owner. The "N reimbursements hidden" pill counts the current view too.
  • The transfer-pair amount tolerance in settings now actually does something. It saved fine but every matcher ignored it, so legs a few cents apart never paired however far you loosened it. The date window is fixed the same way, and the help text quotes your settings rather than a default.
  • Annualized by Category was treating a whole calendar year as year-to-date and then annualizing it again, so both the spend so far and the projection came out badly overstated. Year-to-date now stops at today, scheduled bills count at face value, and the change column compares like with like.
  • The Pacing card no longer invents spending that cannot happen — in a month with no discretionary spending at all it still projected more than the scheduled bills came to and warned "Over pace". Committed bills are now projected from their schedule and only ad-hoc spending is run-rated, so a month with no ad-hoc spending projects exactly its bills.
  • Disposable Income and Projected Balance now turn red when they are negative — the colour was only ever applied to the card's icon, so a negative Disposable looked identical to a healthy one.

v2.10.0

Dashboard chart overhaul + update-panel fixes

New

  • The dashboard now shows an Imports & Receipts card — receipts imported and analyzed, value captured from them, statements imported and rows parsed, the review-queue count, and a warning when receipts fail to analyze or their line items don't add up. Every figure links through to the tab that owns it.
  • The Cash Flow card has a Trend mode — 6 or 12 months of committed, ad-hoc and special spending as stacked bars with income and net lines over the top, projected months shown faded.

Improved

  • The This Week card now shows a Monday-to-Sunday bar sparkline with today marked and last week ghosted behind each day, so you can see which day the spending landed on instead of only the weekly total.
  • Receipt Coverage now also shows how many of your receipts are actually linked to a transaction (1 of 285), not just how many of this month's expenses have an attachment.
  • The three stacked spending donuts are now one card with All / Ad-hoc / Recurring tabs and a ranked bar list — categories are easy to compare, the labels no longer overlap into mush, every row shows its change vs last month instead of hiding it in a tooltip, and the card takes about a third of the height.
  • The update panel is now a proper floating card in the app's own colours, wide enough to actually read the release notes, and it stays fully on screen even from the collapsed sidebar rail.
  • Release notes in the update panel show up to 20 lines and say how many more there are, instead of silently stopping at 12.

Fixed

  • The committed-spending sparkline no longer logs a sizing warning on every dashboard load, and its hover highlight is visible instead of white-on-white.
  • The Balance Timeline's YTD Income and YTD Expenses lines no longer share their colours with the Alex Income and Chelsea Income bars — the legend was labelling the same colour two different things. Income is now a solid green line, expenses a dashed neutral one, and the Today marker and deficit shading follow the active theme instead of a fixed red.
  • The dashboard Cash Flow chart now draws a deficit — the Net bar was clamped to zero, so a deficit month picked the red colour and then rendered nothing while the real figure showed only in the tooltip. Bars are also sized sensibly instead of stretching wider than the chart is tall, every category keeps its slot at zero, and scheduled-but-not-yet-received income appears as a ghosted extension of the Income bar rather than an empty gap.
  • Charts across the app now follow the Alex/Chelsea/Duo theme — axis labels, grid lines, tooltips and bar-hover highlights referenced colour variables that did not exist, so they silently fell back to generic grey; progress-bar tracks on the dashboard, vendors and installments cards were invisible for the same reason.
  • The update panel behind the sidebar's UPDATE badge is no longer see-through and no longer slips behind page content — it was inheriting the version line's 60% opacity and was trapped inside its stacking context.
  • Typographic characters in release notes — dashes, arrows, curly quotes — now render properly instead of showing raw codes like `–`.
  • The desktop app no longer ambushes you with a duplicate native "Restart now" dialog hours after the UPDATE badge is already on screen.
  • The gold accent buttons in the event and refund pickers now use a readable dark label instead of inheriting body text colour.
  • Entries on the Bug Fixes tab are no longer dated four hours ahead — often landing on the wrong day — when a release is cut.

v2.9.0

Fix wave: audit bugs, transfer chains + income review

New

  • The Review Queue now surfaces income too — low-confidence income classifications, missed paydays, and paystubs awaiting analysis, each linking to where it gets fixed.
  • The transfer engine detects multi-account transfer chains (chequing → savings → investment), labels each hop, and nullifies the chain as a unit — same-day three-account transfers no longer strand the originating outflow as real spending.
  • Configurations gains a transfer-pair amount tolerance setting (default $0.01, today's behaviour) so FX-rounded transfer legs a few cents apart can pair when loosened.

Improved

  • "Role ended…" on a missed payday prefills the role's last confirmed payday, and the Pause action now reads as what it is, pointing single-payday skips at Dismiss.
  • Statement records remember which workbook sheet was parsed, honoured on re-analyze with the heuristic as fallback.
  • The Features page's planned section now reflects the real queue — 22 stale v1.9x targets retargeted to the v2.x roadmap and 16 missing planned items added (from the audit's never-tracked list, including the Plaid build, year-end tax export, and confidence calibration).

Fixed

  • The dashboard cash-flow waterfall no longer double-subtracts special (event-tagged) expenses — the Ad-hoc bar previously under-reported by the full special amount and could go negative; the bars now tie to the Disposable headline in both toggle states.
  • Income page paydays on the 1st of a month now group under the correct month and year instead of slipping into the previous one, and evening use no longer prefills tomorrow's date — the add form, role date inputs and the earned/forecast split all use local Toronto time.
  • The Income page's Upcoming pill now counts only the forecast paydays the current filters actually show.
  • Changing an income role's owner, employer, kind, or category now updates its already-generated forecast paydays, so deposits keep matching their expected slots instead of silently going unreconciled forever.
  • Pasting tab-delimited rows from Google Sheets no longer mis-parses when a line contains a comma — the importer sniffs the delimiter by consistent column count, preferring tabs on ties; comma-CSV parsing unchanged.
  • Transfer-pair matching uses the single canonical Toronto-day helper everywhere, so dates can no longer shift a day on non-Toronto machines.
  • A workspace-level installer quirk that could silently break PDF rendering after any dependency install is fixed at the root (the native-build allowlist was in a file pnpm no longer reads).

v2.8.0

The classifier learns out loud

New

  • Vendor Learning card in Settings → Prompts & Cache — see everything the classifier has learned per vendor, search it, pin a preferred category (the pin outranks count and recency everywhere, including the AI's prior), edit or delete learned triples, and adopt your repeated corrections from a built-in Problem Vendors view with one click.
  • AI classification now uses a vendor's learned history as a strong prior — the classifier sees the dominant prior triple with its count and recency and only overrides it with reason; per-prompt toggle defaults on, and prior-injected runs are flagged in the accuracy log.
  • A Runway card on the dashboard shows how many months of liquid cash remain at the recent average net burn — with honest states for cash-flow-positive months, missing snapshots, and no spending history, and the calculation basis spelled out under the number.
  • A Savings Rate card shows (income − spend) ÷ income for the viewed month, rendering "n/a" instead of a nonsense percentage in zero-income months.
  • A Financial Insights card generates 3–5 AI-written observations on demand — never automatically — showing model and estimated cost like the app's other AI surfaces.
  • Spending Trends can overlay a monthly Income line (own axis, toggleable) so spending growth vs earnings growth is finally visible — reimbursements, CC-payment legs and forecast rows excluded by the shared earnings policy.
  • Mortgage page shows affordability — the payment as a share of paystub take-home, with an honest "no current income — last known take-home" state instead of a broken percentage.
  • Income page shows a YTD withholding card summing income tax, CPP, EI, benefits and RRSP from analyzed paystubs, with coverage-gap notes so totals read as floors.
  • The line-item editor gains multi-select bulk category actions and keyboard navigation — a 35-item receipt no longer means 35 dropdown trips, and hand-classified items are never bulk-overwritten unless selected.
  • Recurring-classified receipts can link to their recurring bill occurrence — clearly-labeled recurring candidates in the match picker, a distinct badge, clean unlink; auto-match deliberately stays ad-hoc-only.
  • A Runway card on the dashboard shows how many months of liquid cash (chequing + savings snapshots) remain at the recent average net burn — with honest states for cash-flow-positive months, missing snapshots, and no spending history, and the calculation basis spelled out under the number.
  • A Savings Rate card shows (income − spend) ÷ income for the viewed month using the same income definition as the Total Monthly Income headline, rendering "n/a" instead of a nonsense percentage in zero-income months.
  • A Financial Insights card on the dashboard generates 3–5 AI-written observations on demand — never automatically — showing the model and estimated cost of each run like the app's other AI surfaces.
  • The expense line-item editor gains checkbox multi-select with "apply category to selected" and "apply to all unclassified" bulk actions — a 35-item receipt split no longer means 35 separate dropdown trips, and items you classified by hand are never bulk-overwritten unless you select them.
  • A "Hash all remaining" action on the duplicate scanner fingerprints the entire receipt corpus in one run instead of 25 receipts per scan click, so exact-duplicate detection is complete.
  • Recurring-classified receipts can now link to their recurring bill occurrence — the match picker offers clearly-labeled recurring candidates, linked receipts wear a distinct "Recurring" badge with clean unlink, and auto-match deliberately stays ad-hoc-only.

Improved

  • Duplicate-import legacy category upgrades now train the vendor-history store, and the Reclassify modal and add forms no longer double-write the legacy vendor cache — vendor learning is consolidated server-side.
  • The pacing budget's automatic default is floored at your real committed obligations (recurring monthly equivalents, mortgage included) instead of a hardcoded 90% of income — so pacing stays meaningful while income is stopped.
  • Receipt scanning and Classify Only share one line-item classification engine — multi-quantity items are shown to the AI on every path, and AI spend is recorded even when a response fails to parse.
  • Deleting a receipt removes its cached thumbnail, the duplicate scan cleans up orphaned thumbnails, and a "Hash all remaining" action fingerprints the whole corpus in one run.
  • The pacing budget's automatic default is now floored at your real committed obligations (recurring templates' monthly equivalents, mortgage included) — with income stopped, pacing paces against the money that will leave regardless instead of going dark at $0.
  • The line-item editor is keyboard-navigable — Enter/Shift+Enter (and arrow keys from the description) move between rows without submitting the form.
  • Deleting a receipt now removes its cached thumbnail too, and the duplicate scan cleans up thumbnails orphaned by past deletions — never touching a live receipt's thumbnail.

Fixed

  • Income dated the 1st of a month now counts in that month on the Dashboard income cards and Balance columns — a timezone parsing bug previously slid it into the prior month.

v2.7.0

Income wiring, duplicate defense + awaiting-bank receipts

New

  • Re-uploading a statement file you already imported is caught before parsing — a notice shows what you uploaded and when, with a one-click jump to the archived entry or a deliberate re-import override.
  • The full expense form and AI Entry now warn about possible duplicates before saving, matching the Quick Add banner — warn only, never blocking.
  • Saving an expense flags server-detected look-alike charges in a toast with a jump link — a backstop that works no matter which screen created the expense.
  • Receipts waiting for their bank charge now show as "Awaiting Bank" instead of being lumped in with orphans — fresh receipts wait (14 days, adjustable), stale ones age out to Orphan, and you can pin either state by hand.
  • When the bank charge arrives — by statement import or by manual entry — waiting receipts link to it automatically, and near-miss candidates are suggested on the receipt card instead of staying silent.
  • Creating an expense from a receipt first checks whether a matching expense already exists and offers to link instead.
  • The duplicate scanner also looks for twins that slipped through before — same amount, close dates, one holding the receipt and one bank-imported — and suggests the merge, never doing it automatically.
  • Receipts whose bank charge hasn't landed yet now read as "Awaiting Bank" instead of Orphan — a new tab, badge, and summary chip; they auto-link when the charge arrives, age to Orphan after a configurable window (default 14 days), can be pinned to either label by hand, show their closest "possible match" candidate for review, and Create Expense warns first when a matching expense may already exist.
  • Backups can be restored in-app — Configurations lists every backup with its retention tier and daily snapshots, and restore uses a type-to-confirm dialog that names the file being overwritten, always saving a safety copy first.
  • The parser wizard on Configurations takes Excel workbooks directly, with automatic sheet picking and one-click sheet switching; built-in parser cards gained one-click "Duplicate as editable parser" with the takeover note and parity differences visible.
  • Paystub details can be corrected by hand — employer, pay date, gross, net and each deduction line — and a later re-read fills blanks only, never overwriting what you typed.
  • The paystub document itself is viewable — a thumbnail in the paystub detail and a View stub link beside a linked deposit, served from this machine only and never sent to any AI.
  • Paystub matching is actually wired up now — analyzing a stub auto-links it to the deposit it produced when the match is unambiguous, a Find deposit panel lists ranked candidates (with score and which factors matched) for everything else, and Unlink undoes it.
  • A linked paystub shows why the paycheque differed from the forecast — signed dollar lines for each deduction change, a green "fully explained" or amber "$X unexplained" chip, and honest notes when there is no earlier stub to compare against.
  • Income rows with a linked paystub carry a Stub chip (with the match score) that jumps to the Paystubs page.
  • The Balance page now shows expected income under future months (an "exp." figure beneath Total Earned) — the forecast the app has computed since income roles shipped but never displayed.
  • A missed payday now shows up on the Dashboard as an amber "1 missed payday" link on the income card, instead of counting nowhere and appearing nowhere.
  • Committing an import with checked rows the duplicate scorer flagged for review now asks once first — "N selected rows look like possible duplicates of existing expenses" — listing each row beside the existing entry it resembles, with jump-to-row links and Proceed/Cancel; proceeding still imports everything as selected.

Improved

  • The codebase reached zero lint errors (from 68), with hydration-sensitive patterns deliberately documented rather than blindly refactored.
  • Packaging now fails fast with a clear message if the PDF-rendering binary is missing or broken, instead of shipping an app whose receipt scanning silently fails.
  • When two different statements collide on a filename, the import page says the file was saved under a new name instead of renaming it silently.
  • Duplicate warnings recognize statement-style vendor names — "SQ *BLUE BOTTLE" now matches "Blue Bottle Coffee".
  • Committing an import warns once when selected rows look like possible duplicates of existing expenses, with jump links to review them first.
  • Run Matching now confirms up front how many analyzed receipts could be labelled Orphan, and reports matched/orphaned/examined counts afterwards — plus a clickable Orphans chip in the summary bar.
  • Linking a receipt match candidate scoring under 30% now asks for confirmation instead of linking on a single click.
  • Imported statement files now record how many rows the parser skipped, shown in the Files imported panel.
  • Bulk payment-method edits save in a single write with one backup instead of one request per row; reimbursement, income-link, and event edits now count as your own edits so automated syncs won't overwrite them.
  • The quick-add duplicate warning also catches the same charge recorded a day apart (pending vs posted dates).
  • Paystub pipeline events now appear under their own Paystub category on the Logs page.
  • The Dashboard and the Income page now tell the same story about the month: both headline money that actually arrived, with the schedule's remaining paydays on a separate labelled Expected line — computed by one shared rule so the two pages can no longer disagree.
  • Income data now arrives pre-filtered everywhere — pages ask for forecast paydays explicitly, so a page that forgets can no longer count them as money by accident.

Fixed

  • Roughly 450 places where the app's styling silently compiled to nothing now render as designed — selected states, tinted backgrounds, hover shades and divider lines across every page (the palette is now registered with the styling engine instead of hand-written around it).
  • A receipt-created expense no longer turns into a duplicate when its bank charge posts days later — the import recognizes the pair (posting delays are expected, not evidence against a match) and merges the bank details into your existing expense with the receipt still attached.
  • Re-analyzing a receipt no longer overwrites its overall category on receipts you've edited — the analyze pipeline now respects every user-ownership marker, in both full and classify-only modes.
  • Automated syncs can no longer overwrite hand-curated per-item category splits on an expense — line items are now protected the same way as the main category.
  • Custom parser profiles left on the "auto" date format now read ambiguous slash dates month-first (MM/DD), matching the built-in bank parsers — 4/3/2026 no longer silently imports as March 4 instead of April 3.
  • Transfer-pair, refund, and link-suggestion matching now count calendar days in Toronto time — evening-timestamped transactions no longer drift one day forward at the date-window edge.
  • Uploading receipts, paystubs, statements, or attachments into a brand-new data folder no longer poisons it — the budget database is seeded before any file directory is created, and a folder with prior data but a missing budget.json is refused instead of silently written into.
  • Vector-drawn PDFs no longer send a blank page image to AI extraction — scans fall back to reading the real PDF instead of fabricating data from an empty render; PDF page rendering also caps its output size.
  • Searching the receipt match picker now shows expenses already linked to another receipt as disabled rows naming that receipt, instead of silently returning nothing.
  • A statement containing only CC payments / transfer pairs no longer dead-ends on a disabled "Import 0 rows" button — it reads "Commit N linked pairs" and commits both legs with their link stamped.
  • A failed update download no longer makes the sidebar update badge vanish — it turns amber, keeps the version visible, retries once on its own, and offers one-click Retry.
  • Review fixes from the wave's adversarial pass — Rebates tab group totals no longer read $0.00, the daily balance curve honours the include-reimbursements toggle exactly like the monthly view, the Income page's cards use your real settings instead of defaults, and the Dashboard's Scheduled line no longer double-represents post-dated deposits.
  • "Projected Annual" on the Dashboard is now a real projection — it includes the schedule's remaining expected paydays (labelled), instead of quietly showing just year-to-date.
  • Income scenarios no longer double-count — the baseline was summing 12 months of real income PLUS 12 months of forecast paydays (roughly 2x on real data), and it now follows the same counting rules as every other page.
  • An event's detail page now shows the same income numbers as its CSV export and the events list — forecast paydays tagged to an event no longer leak into the page's totals or timeline.
  • Forecast paydays can no longer be force-linked, flagged as reimbursements or credit-card payments, or tagged to events — the server refuses, so a schedule projection can never be treated as money.
  • The dashboard's YTD gross figure now follows the same counting rules as the net figure beside it, and FX history only counts money that actually arrived.
  • The AI financial-insights prompt now feeds on real received income — previously its "recent income" sample was guaranteed to be future forecast paydays.
  • The Dashboard's income list no longer shows a missed payday as received money — forecast paydays are badged Expected, missed and dismissed ones stay out of the list.
  • With expenses but no recorded income, "Expense % of Income" now says so instead of displaying a healthy-looking 0.0%, and the pacing card explains there is no budget to pace against instead of silently showing "of $0".
  • Event totals no longer count forecast paydays tagged to an event — the event list, event cards and profit views now agree on the same number.
  • Balance and the Dashboard now agree on which income rows count — transfer legs cancelled by a scan were still counted as earnings on the Balance page, and the daily balance curve now applies the same rules as the monthly totals.
  • Cross-import partner matching actually runs now — a response-shape mismatch had been silently feeding it empty data since it shipped.
  • A bank row for a charge you already created from its receipt now merges into that expense instead of importing a twin — the import scorer recognizes purchase-vs-posted date drift on receipt-created expenses (linear credit out to 14 days), and an exact-to-the-cent match with a similar vendor auto-links with a green "receipt" chip; the receipt stays attached and the statement's posted date, card, and source file fill in.

v2.6.0

Paystubs + in-app updates

New

  • A persistent UPDATE badge on the sidebar version line shows when a new version is waiting — click it for the version, the release notes, and a one-click Restart & Install.
  • Paystubs page under Income — upload a stub and see employer, pay period, gross, net and the full deduction breakdown (income tax, CPP, EI, benefits, RRSP).
  • Paystub PDFs with a text layer are read entirely on your machine — the page image never leaves your computer, and every badge says which path read a stub, so "nothing left this machine" is only claimed when it's true.
  • A scanned paystub with no text layer stops and asks first, spelling out that the whole page would be sent as an image including your SIN, address and bank details — nothing is sent until you agree, and the app records when you did.
  • Uploading a paystub finds the deposit it produced, ranking income rows by employer, pay period and gross — and refusing to link automatically when the match is ambiguous.
  • A matched paystub explains why a paycheque differed from the forecast ("benefits started this period", "CPP restarted for the new tax year") and states plainly how much of the gap the deductions do not account for, instead of guessing.
  • A daily snapshot captures what the old backups missed — the receipts and transactions indexes, learned vendor history, and the AI learning logs — with a manifest explaining how to restore it.
  • Keyboard shortcuts on Ad-hoc Expenses — `n` focuses Quick Add, `/` opens filters, `j`/`k` move the row cursor, `e` edits the focused row, `?` lists them all (and they stay quiet while you're typing).
  • Quick Add warns before saving when vendor, date and amount all match an expense you already have — a heads-up, never a block.
  • Bulk-edit the payment method across selected ad-hoc rows, plus "Add similar expense", CSV export of exactly what your filters show, and a running-total pill above the table.
  • `node scripts/guard-canvas-prebuild.mjs` reports a missing or broken canvas binary with the exact repair command, instead of PDF rendering failing silently.

Improved

  • Updates now download quietly in the background, so installing is instant instead of starting a download when you say yes.
  • Paystub text that does go to an AI provider has your Social Insurance Number and employee number masked completely, with no last-four digits left behind, while every dollar amount and date stays readable.
  • Re-analyzing a paystub you have already edited fills in what you left blank and never overwrites a figure you typed yourself.
  • Force-link shows the two rows you're about to pair side by side before the modal opens — labelled A and B with net, sign agreement, date gap and owner match spelled out — and says whether the pair will be excluded from your totals.

Fixed

  • Dismissing or missing the update dialog no longer leaves the app with no sign anywhere that an update exists.
  • A burst of saves no longer wipes out your backup history — backups are thinned generationally (recent, then daily, weekly, monthly and yearly copies) instead of keeping only the last 20.
  • Restoring a backup now rejects file names that point outside the backups folder.
  • Force-link tells you a row is already linked the moment you pick it, instead of waiting until you've chosen a second row.
  • Dismissing a missed payday no longer deletes it. The payday is kept, marked Dismissed, and can be restored — previously it was erased with no undo and no record beyond an unlabelled date in the role's pause list.
  • Removing the last pause window from an income role now saves instead of silently reverting.

v2.5.0

Receipts finishing + PDF statements

New

  • Bank statements can be imported as PDFs — drop one on the Import page and it is archived, parsed and deduped exactly like a CSV.
  • When a PDF's layout can't be confirmed, the column-mapping wizard opens with the reconstructed table already loaded instead of the import failing.
  • A column mapping you save for a PDF statement is reused automatically the next time a statement from that account is uploaded.
  • Any built-in bank parser can be duplicated into an editable copy you can adjust, and your copy takes over from the built-in automatically — the built-ins keep working untouched.
  • Excel statements can go through the column-mapping wizard too, including choosing which sheet to read.
  • An Imported Files panel shows which statements you've imported, with their source, date range and row counts.

Improved

  • One consistent rule now decides a receipt's overall category: all items agree, or the biggest-dollar items win, or the vendor decides — and any item the AI couldn't identify is stamped with that result and flagged, instead of quietly guessing.
  • Searching for an expense to match a receipt now searches all your expenses instead of filtering the first ten results.
  • Receipts that find nothing to link to are now marked as orphans so they stop hiding among analyzed receipts — and they stay matchable by hand.
  • Expanded receipt rows on the Ad-hoc page now show each item's tax-included cost, matching the Receipts page.
  • A scanned PDF now explains that it has no text layer and needs OCR, instead of failing with a generic unsupported-file message.
  • The six built-in bank parsers now report rows they skipped and why, instead of dropping them silently.
  • Transfer-pair matching gained an adjustable date window, correct Toronto day boundaries across daylight-saving changes, and bank-prefix-aware description matching so the two legs of a transfer find each other more reliably.

Fixed

  • Receipts with no itemized lines (or whose items disagree with the receipt as a whole) no longer file themselves under "Other" — the receipt's category now comes from the same source in every analyze mode.
  • Your manual edits to an expense now stick — the ad-hoc form records that a human made the change, so later receipt syncs and bulk AI classification can no longer overwrite categories you set yourself (a bulk classify skips your rows; explicitly reclassifying one row still works).
  • Editing an expense created from a receipt no longer erases where it came from.
  • Scanned PDF receipts and statements can be read again — a PDF-engine canvas mismatch made every image-based page fail, forcing a slower, pricier fallback.
  • Re-analyzing a statement you'd parked for later now honours your saved parser and can re-read PDFs, instead of failing with a workbook error.
  • Uploading a statement to a brand-new install no longer fails with a server error.
  • A statement whose rows all already exist can now be imported to merge in the new details, instead of leaving the Import button dead.

v2.4.0

The app learns from you

New

  • Recurring template form gains free-trial fields — mark a template as a trial with its end date and post-trial price, editable inline on the template detail panel too (the form was also silently dropping these fields on create — fixed).
  • Recurring templates can store an account number (utility, telecom, property tax), shown masked with click-to-reveal in the recurring list and detail views — and never included in anything sent to AI providers.
  • The classifier now learns your vendor categorizations — every import, manual edit, and Problem Vendors promotion builds a per-vendor history, and AI picks that disagree with it get an amber "history" chip with one-click "Use history" / "Keep AI" resolution.

Improved

  • One-time migration renames categories from the original spreadsheet era (Food, Medical, Home, …) to their modern hierarchy equivalents — 288 of 309 legacy rows map automatically, leaving only genuinely ambiguous ones for the Classify Legacy AI button.
  • AI classification now injects your written category descriptions at the subcategory and detail levels too (392 L2/L3 descriptions were sitting dormant behind off-by-default toggles) — injection stays branch-scoped, so only the chosen parent's descriptions ride along.
  • Card payments now show real brand marks — Mastercard's interlocking circles, the AMEX blue box, and the VISA wordmark — instead of a generic card glyph; cash keeps its banknote.
  • Problem Vendors "Create Override" now records your correction into the vendor history (your preferred category for future classifications) instead of a single sticky cache entry.
  • Type-safety hardening — the TypeScript error baseline dropped from 37 to 4; the remaining 4 are third-party typing issues slated for the next wave's finish.

Fixed

  • Imported transactions now keep their statement metadata — posted date, cardmember, cheque number, foreign-currency amount/rate/commission, source-file link, and the credit-card-payment flag were being silently discarded when committing an import; a new guard prevents this class of regression.

v2.3.0

Income roles

New

  • Income Roles — jobs, benefits, and other recurring income streams with salary history (a raise is the same role with a new entry effective its date), pause windows, and end dates; expected paydays generate forward automatically.
  • Paycheck reconciliation — an incoming deposit matching an expected payday confirms it with variance tracking, the forecast auto-learns your real net after two consistent deposits, and missed paydays are flagged without polluting totals.
  • Describe income in plain English ("severance from a former employer, paid in March") and confirm the AI-parsed proposal — roles, benefits, and one-offs all supported.
  • Roles section on the Income page — cards with salary timelines and auto-learn notes, per-role stats, missed-payday banner with resolve actions, and an Upcoming toggle for forecast rows with status chips and variance tooltips.
  • One-off income (severance, bonuses) can attach to a role with a reason chip — your employment history including the severance is pre-linked.
  • Dashboard warns with a dismissible banner when a reimbursement's income and expense sides reference different events.
  • Settings toggle to include reimbursements in Balance earned totals (default off keeps them in their own column only).

Improved

  • Balance forecast months show projected expected income from your roles (never mixed into earned totals).

Fixed

  • Dashboard income cards (Total Monthly Income, YTD, Projected Annual, Other Money In, cash-flow waterfall) now use the same earnings partition as the Balance page, so reimbursements and nullified legs can never inflate dashboard income.

v2.2.1

Receipts overhaul

New

  • Analyzing multiple receipts shows live progress (count, current receipt, running cost) with per-receipt error notices and a cancel button.
  • Re-uploading a file you already have is flagged as a possible duplicate the moment it lands — before any AI analysis is spent on it.

Improved

  • Receipts card view and duplicate scanner load lightweight thumbnails on demand instead of full-size originals — browsing hundreds of receipts no longer downloads hundreds of megabytes.
  • Receipt images return 304 Not Modified on revisit instead of re-downloading multi-MB originals, and the receipts list no longer scans the inbox or checks every file on disk per request.
  • Receipt actions (match, unlink, delete, edits) update just the affected receipt instead of reloading everything; saving line-item edits patches the page instantly, and unclassified rows get a one-click "copy category from above" shortcut.

Fixed

  • Balance page Total Earned no longer double-counts bonuses, severance, gifts, and other non-paystub income — the Alex/Chelsea Earned columns are now paystub-only, Other Money In carries the rest exactly once, and the columns sum to Total Earned.
  • Create Expense from a taxed multi-item receipt no longer fails silently — tax and tip are distributed proportionally across line items, and API errors now show in the error banner.
  • Editing a matched receipt's line items or totals now updates the linked expense (amounts, categories, tax fields), unless you've hand-edited the expense.
  • Re-analyze and Classify no longer wipe manual work — user-set categories, renamed descriptions, added and deleted items all survive, with a confirmation prompt before re-analyzing an edited receipt.
  • Receipt item ID and raw-description edits now actually save (they were silently discarded).
  • Collapsing a row or closing the preview with unsaved line-item edits now asks before discarding them.
  • Deleting every line item and saving no longer crashes the receipts page.
  • Receipt index writes are serialized and saved per receipt during batch analyze — concurrent edits and uploads can no longer be silently lost mid-batch, and the index keeps 5 rotating automatic backups.
  • Deleting an expense now unlinks its receipt instead of leaving it stuck "matched" to a dead expense, and re-matching a receipt to a different expense cleans the old expense's link and attachment.
  • Resolving duplicate receipts deletes the discarded file from disk and clears any linked expense (both previously leaked).
  • Auto-match no longer double-links duplicate receipts to one expense and ignores implausible OCR dates (e.g. year 2093) when scoring.
  • "Classify Only" no longer crashes with "overall is not defined" — results now save correctly.
  • Force re-analyzing a matched receipt keeps its expense link and updates the expense's attachment to the renamed file.
  • "Not duplicates" now sticks — dismissed receipt pairs stay dismissed across scans, and the duplicate scanner no longer pops up after every analysis unless a new match was actually created.
  • Scanned (image-based) PDF receipts render correctly again in thumbnails — a PDF-engine canvas mismatch made every scanned PDF fail.

v2.2.0

Multi-stage AI classify

New

  • AI Classify is now four buttons — Transfers, Destination, Category, and Detect + Classify — so each stage can be re-run on its own without clearing the other stages' results, with per-stage cost shown on each button.
  • Income rows in the import preview get a manual Reimbursement chip and inline event picker, so money-back deposits can be flagged and event-tagged before committing.
  • Promote any income row to an event reimbursement from Money back ("+ Promote income row") or an event's detail page ("+ Add reimbursement") — one shared modal, no API workflow needed.
  • Reimbursement checkbox + event picker on the Income page's add and edit forms.
  • Settings panels for ten previously hidden flags — import commit behavior (auto-flag reimbursements/CC payments, event-suggestion window, nullifying-row commits, preview page size) and manual-link aggregates (fee-reversal/rebate nullify, CC-payment inclusion, smart-suggest similarity, link-chip visibility).

Improved

  • Reimbursement-flagged income no longer inflates Balance earnings — a new Reimbursements column keeps money-back visible without counting it as earnings, and linked expense/income rows sync their event automatically.
  • The AI progress bar lights only the running stage's segments, and the post-run stage summary now displays when classification finishes.
  • Settings toggle renamed to "Hide reimbursements from Earnings page" to match the Income page's tabs.

v2.1.2

In-app updates fixed

Fixed

  • In-app updates work now — the update component was silently missing from packaged builds, so the app never offered new versions. Updates download inside the app (nothing goes to your Downloads folder) and install on restart.

v2.1.1

Resume previous uploads

New

  • Import page now lists previously uploaded statements that were never committed — preview them again (rows append to the current batch) or delete them, with automatic cleanup after 90 days for new uploads (your existing statement archive is grandfathered and never auto-deleted).

Fixed

  • Committed rows now record which statement file they came from, so imported files correctly leave the pending list.

v2.1.0

Inline merge editing

New

  • Linked import rows gain an inline "Merge edits" panel — see the existing row, override vendor, category, description/notes, and fill fields, and preview the merged result before committing; edits apply in the same single write as the merge.
  • The Features page now tracks the full roadmap — 42 planned items, the current wave in progress, and the v2.0.x revival work and its bug fixes recorded.

Fixed

  • The installed app now keeps your data in a clean `data/` folder inside Application Support instead of mixed in with the app's caches — existing installs relocate automatically on first launch after updating.

v2.0.1

Privacy hardening

New

  • Account and reference numbers are masked (last-4 kept) in transaction text before it is sent to AI providers — on by default, toggle under Settings → AI Providers.

Improved

  • The spreadsheet parser used for bank statement imports was upgraded to SheetJS 0.20.2, closing known security vulnerabilities in the old version.
  • The public download page now shows a dashboard screenshot (captured from demo data).

v2.0.0

The installable era

New

  • Websites open in your browser can no longer reach the local WholeBudget API — cross-origin and DNS-rebinding requests are blocked.
  • WholeBudget is now a real installable Mac app — signed, notarized, and auto-updating, with all data stored in Application Support where updates can never touch it.
  • Sidebar shows an amber STAGING badge with the build's git SHA when running outside the production channel.
  • Dev-only maintenance endpoints (AI logs, receipt filename migration, receipt dedupe resolution, transaction-file re-tagging) are disabled in the packaged production app.
  • API keys in the packaged desktop app save to the macOS Keychain via the desktop bridge; the dev server keeps the .env.local flow.

Improved

  • Release notes, the feature list, and bug-fix history now ship with the app itself instead of living inside the personal data file — production installs get updated release information with every app update, and the Release Notes page is generated from this changelog.
  • Fresh installs start with an empty database instead of sample data — the first-run seed no longer contains any pre-filled income, expense, or subscription entries.
  • Data storage is now crash-safe — writes are atomic, a corrupt or missing database file is preserved and reported instead of being silently replaced with a blank one, and a lock prevents two app instances from writing to the same data folder.
  • AI activity logs now mask card, account, and reference numbers instead of storing raw transaction text.

Fixed

  • The dev server now binds to 127.0.0.1 (this Mac only) instead of all network interfaces, so the app and its API are no longer reachable from other devices on the network.
  • The launcher no longer wipes a Gemini API key saved via Settings on every start.
  • PDF receipt image conversion works again — the canvas rendering module had been silently broken (missing native binary) since the pnpm workspace migration.

v1.92.37

Bugfix bundle — greedy-match de-dup + tighter sub-$5 amount scoring + Skipped income-leg L1-L3 visibility + per-page Settings UI

New

  • FEATURE (Settings UI for importPreviewRowsPerPage) — Closes the v1.92.30 Phase D.2 gap. The schema field + the IMPORT_PAGE_SIZE wiring + the soft-warning banner were plumbed in v1.92.30 but no UI toggle existed; user could only change the value via /api/settings PUT. NEW inline "Per page: [50/100/200/all]" dropdown next to the pagination controls. On change: updates local autoFlagSettings + POSTs to /api/settings (persists across reloads) + resets reviewPage to 0. Visible whenever filteredRows.length > 0 so user can switch from 100 → 50 to enable pagination on smaller sets, or 100 → all to disable it.

Improved

  • NOT-IN-ROADMAP triage — Issue 5 (paired-row chain visual on "Marked for manual link" rows) folded into Phase F.5 as a sub-item per Alex 2026-05-09 — fits the same visual-polish theme as the inline merge editing work. Issue 3b (smart filters / sort by review% / locked items in import preview) ALREADY in roadmap via Track 1.AA multi-button-classify plan; no early pull.
  • Verification baselines — guard-types 401/401 passing (397 v1.92.36 baseline + 4 new v1.92.37 assertions: scoreAmount sub-$5 relative threshold, scoreBatchAgainstPools batch-aware de-dup, Skipped fmtCat income-leg branch, per-page selector dropdown). vitest unchanged (the new scoring + batch logic should add ~5-8 cases in a follow-up). APP_VERSION 1.92.36 → 1.92.37.
  • NEXT — Phase F.5 (fully-editable inline merge panel + server overrides + paired-row chain visual). Then Track 1.X persisted uploads. Then v1.93.0 Track 1.AA multi-button-classify (which includes the smart filter/sort items).

Fixed

  • BUGFIX (greedy-match de-dup) — Per Alex 2026-05-09: a joint-savings deposit-interest row got mis-matched to an EXISTING personal-savings deposit-interest row of a different amount because the dedupe scorer treated each incoming row independently. The incoming row that SHOULD have matched was left unclaimed. Fix: scoreBatchAgainstPools now (a) scores every incoming row × every candidate via the existing scorer, (b) sorts results by score desc, (c) greedily claims candidates so each candidate id is claimed by ONLY its highest-scoring incoming row. Later incoming rows whose top match was claimed are re-scored with that candidate excluded via NEW bestCrossDestinationMatchExcluding helper. Pattern mirrors the v1.92.24 leg-reuse fix in suggestPairs.
  • BUGFIX (small-amount scoring too generous) — Per Alex 2026-05-09: two unequal sub-dollar amounts scored 91% on the amount factor because the legacy linear-decay window of $5 is too generous when both amounts are sub-$5. Fix: scoreAmount now uses a RELATIVE-difference threshold for amounts where max(incoming, candidate) < $5. 1% relative match → 1.0; 25% relative diff → 0; linear decay between. For that pair: relDiff = 0.79 → score = 0 (was 0.91). Legacy linear-decay preserved for $5+ amounts (the wide majority of real transactions) so this change has no effect on normal scoring.
  • BUGFIX (Skipped income-leg L1-L3 hidden) — Per Alex 2026-05-09: the Skipped section's per-leg categorization (added in v1.92.29) only rendered the OUTGOING (chequing-side) leg's [destination / L1 / L2]. The INCOMING (AMEX-side) leg of a CC payment showed just [skip] because the auto-flag-on-import path stamps moneyInType + isCreditCardPayment but not category/subCategory. Fix: fmtCat helper now mirrors the outgoing format on the income leg by checking incomeCategory + incomeSubCategory first, then falls back to a humanized moneyInType ("credit card payment" / "internal account transfer" / etc.) when L1/L2 are unstamped. Both legs of a Skipped pair now show their bucket at a glance.

v1.92.36

Phase F.1 + F.2 + F.3 (preview) — rename auto-skip → linked + inline visibility + grey/blue tint + merge-preview retained

New

  • FEATURE (Phase F.1 — naming sweep) — Per Alex 2026-05-09 clarifying round: full UI-only rename of duplicate / auto-skip terminology to "linked" / "will merge into existing". VERDICT_LABELS["auto-skip"].label changed from "duplicate" → "linked". Per-row chip tint switched yellow → blue to pair with the new "linked" semantics. Toggle button text "Hide/Show N auto-skipped duplicates" → "Hide/Show N linked rows (will merge into existing)". Parse status-bar messaging "N duplicates auto-skipped" → "N rows will merge into existing". Internal MatchVerdict union string-literal stays "auto-skip" for backward-compat with persisted breakdowns in data/logs/dedupe-corrections.jsonl (tens of thousands of pre-existing entries reference "auto-skip" — type-union stays as-is, only display labels rename).
  • FEATURE (Phase F.2 — inline rendering) — Linked rows now render INLINE in the main preview table by default with the new "linked" chip + grey/blue row tint, instead of being collapsed behind the expandedDuplicates toggle. Pre-Phase-F default was hidden; v1.92.36 default flips to expanded so the user sees all merge targets at a glance. Toggle is still available to hide them ("Hide N linked rows…") for users who prefer the compact view.
  • PHASE F.3 (merge-preview already wired) — The existing v1.83.6 previewEnhancement helper + "✓ merging (N fields)" inline caption already renders below linked rows showing which existing-row fields will be filled on commit. v1.92.36 keeps this surface in place; Phase F.5 (deferred to v1.92.37+) will add the FULLY-EDITABLE inline panel where the user can override the merge-fill values + edit vendor/category/description on the existing DB row before commit. Server-side mergeExpenseFromImport extension to accept user overrides is the bigger chunk and ships separately.

Improved

  • NON-DOUBLE-COUNT GUARANTEE — Already-true today via include:false + auto-skip exclusion + enhance-only commit path. Phase F.1 + F.2 + F.3 (preview) preserve all three guarantees while exposing the merge transparently. Aggregates (selectedCount, totalAmount, breakdown buckets) all consult the include flag so linked rows never inflate the preview totals.
  • PARTIAL SHIP — Phase F.5 (fully-editable inline merge panel + server overrides) deferred to a follow-up release. The clarifying-round-locked decisions (editable fields = merge-fills + vendor + category + description; apply on commit) are documented in ROADMAP for the follow-up. v1.92.36 ships the rename + inline visibility + existing merge-preview which already addresses Alex's primary ask: "the dup probability would be renamed to link and wouldnt disappear that item but instead match to the existing if linked".
  • Verification baselines — guard-types 397/397 passing (393 v1.92.35 baseline + 4 new v1.92.36 assertions: VERDICT_LABELS auto-skip displays "linked", expandedDuplicates default is true, row tint switched to blue, status-bar messaging updated). vitest unchanged. APP_VERSION 1.92.35 → 1.92.36.
  • NEXT — Phase F.5 (fully-editable inline merge panel + server-side mergeExpenseFromImport overrides parameter, ~3-4h) queued. Then Track 1.X persisted uploads. Then v1.93.0 Track 1.AA multi-button-classify.

v1.92.35

Phase E.3 bulk-action toolbar + Phase E.4 Force-link surface (subsumed) + Phase E.5 vitest coverage — closes Track 1.V Phase E

New

  • FEATURE (Phase E.3 bulk-action toolbar) — "Reject all N visible" button in the /transactions counts banner when verdictFilter === "review" AND there are rejectable (vendor, template) pairs in the visible set. Click opens a confirmation modal showing the first 20 pairs to be rejected (+ "and N more" overflow line) with Cancel / Reject N actions. Confirm fires parallel POSTs to /api/dedupe/review-band-rejection with source: "transactions-filter" + adds keys to the local rejectedKeys Set optimistically + replaces the per-row Undo chip with a single bulk-undo banner ("Rejected N (vendor, template) pairs · Undo all N"). Bulk undo fires parallel DELETEs against the most-recent matching log entries within the 60s timestamp window. 30-second auto-clear.

Improved

  • PHASE E.4 (Force-link surface) — SUBSUMED. /transactions IS the Force-link page (per the sidebar nav: "Force-link" links to /transactions). The v1.92.34 per-row red button + v1.92.35 bulk-toolbar already cover the Force-link page red-button surface. No separate page added; guard-types asserts the sidebar mapping.
  • PHASE E.5 vitest coverage — NEW src/lib/__tests__/review-band-rejections.test.ts with ~15 assertions across 6 describe blocks: normalizeVendor (case + whitespace + empty), rejectionKey (composer + uniqueness), appendReviewBandRejection + isRejectedPair (round-trip + vendor-normalization match + non-match + JSONL persistence), deleteMostRecentRejection (in-window + outside-window + missing entry), maybeCompactRejectionLog (idempotent stub when log < threshold + 90-day aggregation when > 1000 entries), bestCrossDestinationMatch + isRejectedPair option (rejected pair skipped for recurring; non-recurring not consulted).
  • CLOSES PHASE E. Track 1.V Phase E (review-band negative training + filter view) is now fully shipped. The deferred "Link all M" half of the bulk toolbar is intentionally out of scope — historical /transactions rows are already committed as new rows; "linking" them to a recurring template would require PATCH on the existing OneTimeExpense.recurringExpenseId (a different mechanism than the import-preview link CTA). Spec note added to ROADMAP for follow-up if needed.
  • Verification baselines — guard-types 393/393 passing (390 v1.92.34 baseline + 3 new v1.92.35 assertions: bulk toolbar present, Force-link surface mapping verified, vitest file exists with full describe coverage). New vitest file added (~15 cases). APP_VERSION 1.92.34 → 1.92.35.
  • NEXT — v1.92.x Phase F (rename auto-skip → linked + inline editable merge UX, ~5-7h). Then v1.92.36 Track 1.X persisted uploads. Then v1.93.0 Track 1.AA multi-button-classify.

v1.92.34

Phase E.3 UI polish — per-row red "Not this template" button + header counts + Undo chip on /transactions verdict-filter view

New

  • FEATURE — Per-row red "Not this template" button on /transactions rows. Renders only on rows whose dedupe scorer flagged "review" at commit AND carry a resolvable matchTemplateIdAtCommit AND the (vendor, template) pair hasn't already been rejected. Click optimistically adds the (vendor, templateId) key to a local Set so future imports of the same vendor stop suggesting this template + POSTs /api/dedupe/review-band-rejection with source: "transactions-filter". 30-second Undo chip restores. Mirrors the import-preview button surface from v1.92.32 Phase E.2.
  • FEATURE — Header counts row when verdict filter is active. Amber-banner with "N rows · X rejectable · Y already-rejected" surfaces how many rows in the current view are addressable. Italic note clarifies that rows committed before v1.92.33 carry no verdict snapshot and are excluded from restrictive verdict filters. Clear button resets to "All verdicts" with one click.

Improved

  • SCHEMA — NEW matchTemplateIdAtCommit?: string field on all three transaction types. Direct lookup field that resolves matchCandidateIdAtCommit (an ExpenseEntry id) up to its recurringExpenseId at commit time so /transactions can render the red button without loading the ExpenseEntry pool. Stamped by the import-page commit-pass via pools.recurring.find(matchCandidateId)?.recurringExpenseId. Threaded through /api/expenses + /api/expense-entries + /api/income POST handlers.
  • INFRASTRUCTURE — /transactions page now loads /api/recurring + /api/dedupe/review-band-rejection on mount alongside expenses + income. Recurring template lookup hydrates the per-row button title + the rejection POST payload. Rejection-keys hydration prevents the red button from rendering on already-rejected (vendor, template) pairs (avoids duplicate POST + replaces with a small "rejected" chip).
  • PARTIAL SHIP — Phase E.3 bulk-action toolbar (Link all M / Reject all K with confirmation modal + 30s undo banner) deferred to v1.92.35 (medium chunk on its own). Phase E.4 (Force-link page red-button surface) + Phase E.5 (vitest + Chrome verification of full surface) + Phase F (rename auto-skip → linked + inline editable merge UX) all queued. Auto-expand matchBreakdown panels (originally part of Phase E.3) folded into Phase F since matchBreakdown isn't persisted (would need on-demand re-derivation against the existing-row pool).
  • Verification baselines — guard-types 390/390 passing (387 v1.92.33 baseline + 3 new v1.92.34 assertions: matchTemplateIdAtCommit field declared, commit-pass resolves recurringExpenseId, /transactions page renders red button + counts + Undo). vitest unchanged. APP_VERSION 1.92.33 → 1.92.34.
  • NEXT — v1.92.35 bulk-action toolbar with confirmation modal + 30s undo banner. Then Phase E.4 (Force-link red button) + Phase E.5 (vitest + full Chrome verification). Then Phase F (linked-merge UX rename + inline editable merge). Then v1.92.36 Track 1.X persisted uploads.

v1.92.33

Phase E.3 foundation — schema persistence + commit-pass stamping + URL-bound verdict filter on /transactions

New

  • SCHEMA — Persist dedupe-scorer verdict snapshot on every committed transaction. NEW four optional fields (matchVerdictAtCommit, matchScoreAtCommit, matchCandidateIdAtCommit, matchDestinationAtCommit) declared on OneTimeExpense, ExpenseEntry, IncomeEntry. Lets the user revisit borderline duplicates later via /transactions?verdict=review (e.g., "was that 78%-match row I committed last month actually a dupe? did the rules improve since?"). matchBreakdown intentionally NOT persisted — re-derived on-demand from the existing-row pool when the user expands a panel. Keeps row-body bytes light.
  • FEATURE (Phase E.3 foundation) — /transactions page exposes URL-state-bound verdict filter. NEW dropdown ("All verdicts" / "Review band" / "Auto-skip (merged)" / "Accept") next to the existing owner + link filters. ?verdict=review URL reflects state — shareable + refresh-stable. router.replace keeps the URL clean when filter is "all" (default). Rows committed before v1.92.33 land without the field set; restrictive verdict filters hide them (acceptable since there's no recoverable historical signal pre-Phase E.3).

Improved

  • INFRASTRUCTURE — Import page commit-pass stamps the match-* snapshot on adhoc + recurring + income payload pushes (3 sites). Spread gated on r.matchVerdict presence so rows without a dedupe candidate carry no verdict and don't pollute the index. /api/expenses + /api/expense-entries + /api/income POST handlers extended to pass the four fields through to the persisted entity (the buildEntry/equivalent functions enumerate fields explicitly so they had to be threaded through one-by-one).
  • PARTIAL SHIP — v1.92.33 ships the foundation. v1.92.34 will land the auto-expand matchBreakdown panels (cap 50 visible), per-row red "Not this template" button mirroring the import-preview behavior with source: "transactions-filter", header counts row ("N rows in review band — green-link M, red-reject K, untouched (L)"), and the bulk-action toolbar (Link all M / Reject all K) with confirmation modal + 30s undo banner. Phase E.4 (Force-link page red-button) + Phase E.5 (vitest + Chrome verification of full surface) still queued.
  • Verification baselines — guard-types 387/387 passing (383 v1.92.32 baseline + 4 new v1.92.33 assertions: schema fields declared on 3 types, import-page commit-pass stamps, 3 POST handlers persist, /transactions URL-bound filter present). vitest unchanged. APP_VERSION 1.92.32 → 1.92.33.
  • NEXT — v1.92.34 Phase E.3 UI polish + Phase E.4 (Force-link red button) + Phase E.5 (vitest + Chrome verification). Then v1.92.x Phase F (rename auto-skip → linked + inline editable merge UX). Then v1.92.35 Track 1.X persisted uploads.

v1.92.32

Phase E.1 + E.2 — review-band "Not this template" button + audit log + scorer feedback (import-preview surface)

New

  • FEATURE (Phase E.1 — audit log + scorer feedback) — Per Alex 2026-05-06: review-band rows that match a recurring template show a green "Link to <template> (link + learn)" CTA. Pre-fix there was NO negative path — if the suggestion was wrong, the suggester re-fired on every future similar row. NEW data/logs/review-band-rejections.jsonl audit log captures every "Not this template" click: {timestamp, rowVendor, rowAmount, rowDate, suggestedTemplateId, suggestedTemplateName?, scoreSnapshot, breakdownSnapshot, source}. NEW src/lib/review-band-rejections.ts pure(-ish) helper module with isRejectedPair / appendReviewBandRejection / deleteMostRecentRejection / loadRejectionKeys + module-scope cache. Scorer (bestCrossDestinationMatch) extended with optional isRejectedPair callback so route handlers / client components can wire the predicate without the scorer importing fs (preserves Track 15 invariant #8 pure-module boundary).
  • FEATURE (Phase E.2 — red "Not this template" button on import-preview review-band rows) — Per Alex 2026-05-07: confirmation modal + 30-second undo banner. Two-button group renders inside the existing emerald banner: green "Link + learn" (existing) + red "Not this template" (new). Click optimistically strips the row's matchVerdict / matchScore / matchDestination / matchBreakdown / matchCandidateId / matchCategory / matchSubCategory locally + adds the (vendor, templateId) key to a Set so the scorer immediately stops suggesting the rejected template + POSTs to /api/dedupe/review-band-rejection (best-effort; client-side strip is authoritative if the POST fails). 30-second Undo chip appears below the row; click → DELETE the JSONL entry by (vendor, templateId, timestamp ±60s window) + restore the snapshotted match-* fields + remove the local key. Reject revert action per Alex 2026-05-07: strip match-* fields entirely so the row reverts to whatever the parser produced for a fresh import.

Improved

  • INFRASTRUCTURE — NEW /api/dedupe/review-band-rejection route. GET returns { keys: string[] } (Set of `${normalizedVendor}::${templateId}` for client-side scorer wiring) + idempotent 90-day compaction stub fires on each GET (cheap when log < 1000 entries). POST appends one entry. DELETE removes the most-recent matching entry within a configurable timestamp window (default 60s) for the undo flow. Vendor normalization (lowercase + trim + collapse whitespace) so "RBC FEE" / "rbc fee" / " RBC fee " all share the same lookup key.
  • INFRASTRUCTURE — Idempotent 90-day compaction stub. When the log exceeds 1000 entries, consolidate non-aggregate rejections older than 90 days into one aggregate entry per (vendor, templateId) pair (preserves the lookup-key behavior + frees per-click body bytes). Stub fires on every GET; safe to call when log is small.
  • PARTIAL SHIP — Phase E.3 (`/transactions?verdict=review` filter view + auto-expand + bulk-action toolbar with confirmation modal + 30s undo banner) + Phase E.4 (Force-link page red-button surface, NEW per Alex 2026-05-07) + Phase E.5 (vitest + Chrome verification) pending in subsequent work. v1.92.32 ships the scorer wiring + import-preview button + audit log; the UI sweeps for /transactions + Force-link queue separately so each surface ships independently and verifiably.
  • Verification baselines — guard-types 382/382 passing (378 v1.92.31 baseline + 4 new v1.92.32 assertions: review-band-rejections module helpers, scorer isRejectedPair option, /api/dedupe/review-band-rejection route GET+POST+DELETE, import page wires closure + hydration + red button). vitest unchanged (Phase E.5 will add ~6 new cases). APP_VERSION 1.92.31 → 1.92.32. New log review-band-rejections.jsonl added to Track 15 invariant #9 enumeration.
  • NEXT — Phase E.3 (transactions filter view + bulk toolbar + modal + undo) + Phase E.4 (Force-link page surface) + Phase E.5 (vitest + Chrome verification). Then v1.92.33 Track 1.X persisted uploads. Then v1.93.0 Track 1.AA multi-button-classify.

v1.92.31

HOTFIX — source-label double masked-ending suffix bug from v1.92.30 ship

Improved

  • CONSISTENCY — the 3 secondary callsites also used a simpler inline bankKey-to-label conversion (`d.bankKey.replace(/-/g,' ').replace(/\b\w/g, c => c.toUpperCase())`) which produced "Rbc Tfsa" instead of v1.92.30's acronym-aware "RBC TFSA". Unified all 3 sites with bankKeyToAccountTypeLabel so labels are consistent across the FindPair modal, the suggested-pair preview, and the primary import-row composition.
  • Verification baselines — guard-types 378/378 passing (376 v1.92.30 baseline + 2 new v1.92.31 assertions: appendLastFourIfMissing helper present, no remaining inline `${nick} ····${cardLastFour}` template at composition sites). vitest unchanged. APP_VERSION 1.92.30 → 1.92.31.
  • NEXT — v1.92.32 Phase E (review-band negative-training "Not this template" + /transactions verdict=review filter). Plus NEW Track 1.X — Persisted uploads / Resume previous statements (target v1.92.33): list inbox + analyzed-but-uncommitted TransactionFileRecords on the import page so users can re-preview pending files instead of re-uploading. Then v1.93.0 Track 1.AA multi-button-classify.

Fixed

  • BUGFIX (source label double-suffix) — the import preview's Source column could render a masked card ending twice in a row when a saved profile nickname already included it and the label composer unconditionally re-appended one. A new helper detects whether the base label already carries the bare digit run before appending, applied at all 4 places the label is composed.

v1.92.30

Phase D.1 + D.2 — Account-type-aware source labels + Settings.importPreviewRowsPerPage (50/100/200/all)

New

  • FEATURE (Phase D.1 — account-type-aware source labels) — a generic "BANK DEBIT" label used to collapse several different accounts at the same institution (chequing, personal savings, joint savings) into one indistinguishable string, making the import-preview Source column unreadable when a batch spanned more than one such account. Labels now prefer a saved nickname, then an account-type-aware name with the last four digits, then a generic fallback.
  • FEATURE (Phase D.2 — settings-driven preview pagination) — Pre-fix the import-page IMPORT_PAGE_SIZE was hard-coded to 100 (v1.90.2). For year-end consolidated reviews with a few hundred rows the user wanted to see the whole batch on one page; for everyday reviews 50 felt less overwhelming. NEW Settings.importPreviewRowsPerPage: 50 | 100 | 200 | 'all' (default 100). 'all' bypasses pagination via Math.max(1, filteredRows.length) so the entire filtered set renders in one DOM tree. Soft amber warning banner ("⚠️ N rows on one page — scrolling may be slow") renders inline beside the pagination controls when the user picks 'all' AND filteredRows.length > 500 — flags the perf risk without blocking, since the user might be reviewing a year-end consolidated import deliberately.

Improved

  • INFRASTRUCTURE — NEW bankKeyToAccountTypeLabel helper in src/lib/source-kind.ts. Pure module (no fs/env deps per Track 15 invariant #8) — imports cleanly from both server and client component contexts. institutionCap (rbc→RBC, td→TD, scotiabank→Scotiabank, tangerine→Tangerine, questrade→Questrade) + acronymCap (tfsa→TFSA, rrsp→RRSP, fhsa→FHSA, rrif→RRIF, resp→RESP, cc→CC, loc→LOC) dual-table pattern with Title-case fallback. Special cases: bankKey === 'amex' → 'AMEX'; bankKey === 'generic' → 'Generic'.
  • Verification baselines — guard-types 376/376 passing (372 v1.92.29 baseline + 4 new v1.92.30 assertions: bankKeyToAccountTypeLabel helper present, sourceLabel composition uses bankKeyToAccountTypeLabel, Settings.importPreviewRowsPerPage declared, IMPORT_PAGE_SIZE reads autoFlagSettings.importPreviewRowsPerPage). vitest unchanged. APP_VERSION 1.92.29 → 1.92.30.
  • NEXT — v1.92.31 Phase E (review-band negative-training "Not this template" button + data/logs/review-band-rejections.jsonl audit log + dedupe-scorer feedback + /transactions?verdict=review filter with auto-expanded matchBreakdown panels + bulk-action toolbar). Then v1.93.0 Track 1.AA multi-button-classify.

v1.92.29

Phase D.3 polish #2 — Outgoing/Incoming labels in Skipped section + per-leg categorization + Auto-handled bucket includes v1.90 transfer-pair legs

New

  • UX (Outgoing/Incoming labels) — Skipped section per-leg labels renamed from "L:" / "R:" to "Outgoing:" (expense leg = money LEAVING the account) / "Incoming:" (income leg = money ARRIVING at the account). Clearer money-movement semantics per Alex 2026-05-06. Inferred from leftKind/rightKind on PendingPairMark.
  • UX (per-leg categorization) — the Skipped section now shows each leg's auto-stamped destination, category and subcategory as a small bracket suffix beside it, so you can verify what's actually committing without expanding the row. Updates live as you re-classify via the Marked Pairs banner's type-override dropdown.

Improved

  • Verification baselines — guard-types 372/372 passing (370 v1.92.28 baseline + 2 new v1.92.29 assertions: isV190AutoHandled helper present, Outgoing/Incoming labels in Skipped section). vitest unchanged. APP_VERSION 1.92.28 → 1.92.29.
  • NEXT — Phase D.1+D.2 source-label readability + Settings.importPreviewRowsPerPage. Phase E review-band negative-training. Then v1.93.0 Track 1.AA multi-button-classify.

Fixed

  • BUGFIX (Auto-handled breakdown bucket undercounted) — Per Alex 2026-05-06: "the breakdown which becomes +Auto Handled should include the items which were automatically handled before I marked the suggested pairs together." v1.92.28 only counted v1.92.x nullifying-marked rows; this missed the v1.90 transfer-pair-detected rows (transferPairId set, destination='skip', include=false, transferPairType !== 'associated'). Fix: NEW isV190AutoHandled helper + unified isAutoHandled predicate (OR of v1.90 and v1.92.x). Selected count + totalAmount + skipAggr breakdown bucket now consult isAutoHandled. Net effect on Alex's 196-row import: Auto-handled count includes both flavors so the breakdown accurately reflects ALL pair-classified rows regardless of which mechanism flagged them.

v1.92.28

Phase D.3 polish #1 — Selected count + Total drop when items are auto-handled + breakdown shows Auto-handled bucket

New

  • FEATURE (Auto-handled breakdown bucket) — Per Alex 2026-05-06: "the $ amounts show everything but should also breakout and show the skipped items so we can see the actual income vs recurring vs adhoc expenses since some of this income is really internal transfers or credit card payments". NEW skipAggr 6th bucket in selectionBreakdown. Nullifying-marked rows divert into skipAggr instead of inflating their parser-default destination bucket (income / adhoc). Toolbar breakdown now reads e.g. "Unclassified $X (n) · Recurring $X (n) · Ad-hoc $X (n) · Income $X (n) · Auto-handled $X (n)" — user sees real adhoc/recurring/income separated from auto-handled.

Improved

  • Verification baselines — guard-types 370/370 passing (367 v1.92.27 baseline + 3 new v1.92.28 assertions: selectedCount excludes auto-handled, selectionBreakdown skipAggr bucket present, "+ N auto-handled" UI rendered). Updated v1.91.0 Phase 7A assertion regex to expect 6-bucket sums shape. vitest unchanged. APP_VERSION 1.92.27 → 1.92.28.
  • NEXT — v1.92.29 follow-up for Outgoing/Incoming labels + per-leg categorization + Auto-handled bucket also includes v1.90 transfer-pair legs.

Fixed

  • BUGFIX (selected count + total didn't reflect auto-handled rows) — Per Alex 2026-05-06: when 9 pairs are nullifying-marked, the user expected "185 of 196 selected" to drop to "178 of 196" (or similar) since the auto-handled rows shouldn't count toward the user's manual review queue. Fix: NEW isNullifyingMarked helper. selectedCount + totalAmount filter EXCLUDES nullifying-marked rows. Top-line shows "X of Y selected · Total $Z · + N auto-handled ($Z)" with the auto-handled note in purple to surface the otherwise-confusing drop.

v1.92.27

Phase D.3 — Skipped (auto-handled) preview section + Settings.commitNullifyingMarkedRows plumbing

New

  • FEATURE — Skipped (auto-handled) preview section. New compact section renders ABOVE the import preview table when any nullifying-marked pairs exist (CC payment / transfer / couple-transfer / fee-reversal / rebate). Each pair shows BOTH legs grouped together with score + type chip + Unmark button. Display: "L: date · vendor · account · amount" / "R: date · vendor · account · amount" — compact one-line-per-leg per Alex 2026-05-06. Type chip on the right ("CC payment · skip-aggr" / "Internal transfer · skip-aggr" / etc.) shows the manualLinkType + commit behavior. Unmark restores the pair to Suggested via the v1.92.21 rescued-pairs mechanism + clears the v1.90 transferPair fields per v1.92.20.
  • FEATURE — Nullifying-marked rows hidden from main preview table. Per Alex 2026-05-06: rows marked as CC payment / transfer / couple-transfer / fee-reversal / rebate are now FILTERED OUT of the main preview table (they live in the new Skipped section above). Associated-marked rows STAY in the main flow with their associated chip — both legs still count in aggregates so they need to commit normally. Filter applied in filteredRowsUnsorted gating on NULLIFYING_LINK_TYPES[mark.type].

Improved

  • PLUMBING — Settings.commitNullifyingMarkedRows boolean (default true). Plumbed through Settings type + import-page autoFlagSettings state + settings load. Default behavior preserves v1.92.7 design: nullifying-marked rows commit with manualLinkType + isAggregateExcluded so they don't pump aggregates but DO surface in future Track 1.Y dashboard cards. Future: when set to false, will exclude these rows from commit entirely. Setting page toggle to be added in a follow-up if Alex wants the alternate mode.
  • Verification baselines — guard-types 367/367 passing (364 v1.92.26 baseline + 3 new v1.92.27 assertions: Settings field declared, Skipped section render gated on length > 0, main-preview filter excludes nullifying-marked rows). vitest unchanged. APP_VERSION 1.92.26 → 1.92.27.
  • NEXT — Phase D.1 + D.2 (account-type-aware source labels + Settings.importPreviewRowsPerPage), Phase E (review-band negative-training + /transactions verdict=review filter). Then v1.93.0 Track 1.AA multi-button-classify.

v1.92.26

Phase D.0 — bridge-bypass detection + cross-source money-flow priority sort (the duplicate-amount CC-payment fix)

Improved

  • Verification baselines — guard-types 364/364 passing (361 v1.92.25 baseline + 3 new v1.92.26 assertions: bridge-bypass detection logic locked, TYPE_PRIORITY shape locked, vitest scenario locked). vitest gains 1 new describe block ("bridge-bypass detection (v1.92.26 Phase D.0)") with the 4-leg / 3-account regression case. APP_VERSION 1.92.25 → 1.92.26.
  • NEXT — v1.92.26 Phase D (the originally-planned Phase D, now D.1+D.2): account-type-aware source labels (RBC Chequing / RBC Personal Savings / RBC Joint Savings instead of generic "RBC DEBIT") + Settings.importPreviewRowsPerPage. v1.92.27-28 Phase E: review-band negative-training + /transactions verdict=review filter.

Fixed

  • BUGFIX (bridge-bypass) — in a three-account transfer chain, the suggester was offering a direct savings-to-card pairing as a top suggestion, when the money actually flowed through a middle chequing account first. It now detects when a third account bridges two legs and penalizes the direct pairing's score with a "bridge bypass" reason chip instead of hiding it, so you can still override.
  • BUGFIX (greedy de-dup priority sort) — a same-account pairing was stealing both legs from valid cross-account pairs because pure score-sort processed it first. Real money-flow pairs (transfers, card payments, refunds, rebates) now get first claim on legs during de-duplication, so a genuine chequing-to-card and savings-to-chequing pair both survive instead of the wrong same-account pairing winning.

v1.92.25

Phase C — transitive-nullify verification + unmark restores account labels (rescued-prefix strip) + illusory-pair audit footer

New

  • C.3 (feature) — Illusory-pair audit footer. When the suggester correctly suppresses a same-source pair via excludeLegKeys (because both legs are claimed by other pendingPairs), the user has no signal we made the call. NEW footer at the bottom of Marked Pairs: "N illusory same-source pairs hidden (legs already claimed by other marked pairs)" with a click-to-audit expander showing date · vendor · amount per suppressed pair. Computed via a useMemo that iterates same-source income+expense candidates (sourceFileId match, ±$0.01 amount, ±3-day window) and counts those with both legs in the claimed set. Footer hides at N=0 to avoid noise on every clean import.

Improved

  • Verification baselines — guard-types 361/361 passing (358 v1.92.24 baseline + 3 new v1.92.25 assertions: C.1 vitest scenario locked, C.2 unified strip helper present, C.3 hiddenIllusoryPairs + footer gated on length > 0). vitest unchanged + 1 new transitive-nullify describe block (2 cases). APP_VERSION 1.92.24 → 1.92.25.
  • NEXT — v1.92.26 Phase D: source-label readability (RBC Chequing / Personal Savings / Joint Savings instead of generic "RBC DEBIT") + Settings.importPreviewRowsPerPage. v1.92.27-28 Phase E: review-band "Not this template" rejection + /transactions verdict=review filter. v1.92.25 Phase C complete.

Fixed

  • C.1 (verify) — confirmed that once both legs of a two-hop transfer chain are marked, the leftover same-account pairing correctly disappears from Suggested instead of still being offered, since both its legs are already claimed. Locked in with a new test covering both the fully-claimed and the partially-marked states.
  • C.2 (fix) — Unmark loses account labels on rescued entries. Root cause: rescued-pair LinkSuggestions use `rescued-${kind}-${rowKey}` IDs but the Suggested Pairs render path stripped only `^pending-(?:exp|inc)-\d+-` from leftId before looking up `accountByKey`. The `rescued-` prefix wasn't in the strip regex → leftKey ended up as the full prefixed string → `accountByKey.get` returned undefined → rescued entries rendered without account labels. Fix: NEW unified `LEG_ID_PREFIX_RX` + `stripLegIdPrefix` helper that handles BOTH organic and rescued prefixes. Replaced 4 inline strip calls with the helper. Account labels now persist across mark/unmark cycles.

v1.92.24

EMERGENCY — CC payment direction rules (AMEX parity with Mastercard) + same-source same-vendor pair handling + leg-reuse de-dup

New

  • FEATURE (same-source CC pair cascade) — Per Alex 2026-05-06: when a same-source CC pair has its income leg vendor matching the CC-payment pattern (e.g. "AMEX Payment" / "MASTERCARD PAYMENT"), the TRUE partner is the cross-source deposit-out leg (chequing or savings). The same-source pair is incidental noise. New behavior: `inferLinkType` returns `'other'` for these so they don't auto-mark or score highly — the cross-source pair will outrank them. For same-source CC pairs where the income leg ISN'T a CC-payment vendor, fall back to vendor-signal cascade: REBATE/CASHBACK signal → `'rebate'`; max(amounts) ≤ $50 → `'fee-reversal'`; else → `'refund'`. Reverse-direction CC pairs (CC-out + deposit-in) and CC-to-CC pairs return `'other'` (user picks via dropdown).
  • FEATURE (suggester greedy de-dup) — `suggestPairs` now applies a greedy leg-claim pass after sorting candidates by score: each leg can be claimed by at most ONE pair (the highest-scoring). Belt-and-suspenders with the existing `excludeLegKeys` (which dedupes against pendingLinkMarks). Prevents duplicate-leg suggestions where three CC payments of the same amount could theoretically pair with three chequing-out legs but each leg has only ONE true partner.

Improved

  • Verification baselines — guard-types 354/354 still passing. APP_VERSION 1.92.23 → 1.92.24. v1.92.x re-opened from close-out for this emergency patch — Track 1.V Phase A is otherwise still complete; Phase B (auto-detect incomplete-transfer + cross-import partner-finder) remains queued for v1.93.x.
  • NEXT — re-test the AMEX import. Expected: same-source AMEX expense+income pairs (MAE HOME SOLUTIONS + AMEX Payment) NOT auto-marked as CC payment. Cross-source chequing/savings → AMEX Payment auto-marked as CC payment (canonical direction). No leg appears in two marked pairs simultaneously. Reverse-direction AMEX expense → chequing income returns 'other' so user manually classifies if intentional.

Fixed

  • BUGFIX (CC payment direction-blind classification) — the link classifier was labelling any pair touching a credit card as a "CC payment", including nonsensical ones: a card paying itself, or money flowing FROM a card INTO chequing as if that were a bill payment. It now enforces the real shape — money must flow from a non-card account into the card account, from two different sources — before calling something a CC payment.
  • BUGFIX (leg-reuse in auto-mark loop) — Auto-mark useEffect's `legAlreadyUsed` check inspected `pendingLinkMarks` (committed state) but NOT `newMarks` accumulated within the current loop iteration. Result: loop adds pair (A↔B), then immediately adds pair (A↔C) because A isn't in `pendingLinkMarks` YET — `setPendingLinkMarks` only runs after the loop. The AMEX Payment income leg ended up claimed by both a chequing-out pair (96%) AND a savings-out pair (95%) simultaneously. Fix: extend the check to include `newMarks.some(...)`.

v1.92.23#ax1sx

AMEX .xlsx upload fix (SheetJS externalisation + headerRowHint translation) + 4-account list on the built-in RBC parser card + skip-category button key warning

Fixed

  • BUGFIX (AMEX .xlsx parsed but every row was empty/$0.00) — follow-up to the SheetJS externalisation fix below. Once xlsx loaded properly, the parser still returned 108 empty rows because parseBankRecords() applied the matched ImportProfile's `headerRowHint: 11` directly against the post-filter `nonEmpty` array. The AMEX .xlsx has 4 fully-empty preamble rows (indices 3/4/5/10 — XLSX renders blank rows as `<row/>` empties; the .xls equivalent had spaces that survived filtering). After dropping those, the real header (raw idx 11) sat at filtered idx 7 — `nonEmpty[11]` pointed at a data row (vendor / cardmember / amount) used AS the header. Every column lookup returned -1 → all rows had undefined fields and $0 amount. Fix: translate `headerRowHint` from raw-records space to nonEmpty-array space — lookup `records[hint]` then `nonEmpty.indexOf(targetRaw)`. Falls back to findHeaderRow()'s auto-detect when records[hint] was a filtered-out empty row. No-op for .xls / CSV / any file without empty-row preamble. Located in src/lib/csv-import.ts → parseBankRecords() Stage 1.
  • BUGFIX (React key warning on import preview) — distinct from the v1.92.23 React.Fragment composite-key fix. Console error "Each child in a list should have a unique key prop. Check the render method of `div`. It was passed a child from TransactionsImportPage" fired on every render of the selection-breakdown summary. Root cause: the `renderCategoryButton(key, count, label)` helper at src/app/transactions/import/page.tsx:6438 returned a `<button>` without setting key={key}, and buttons were then pushed into a `parts: React.ReactNode[]` array rendered as `{parts}` children of the surrounding div. Separator spans already had `key={`sep-${i}`}` but the buttons didn't. Fix: added `key={key}` (the category id — `dup | transferPair | explicitSkip | userUnchecked`) inline on the button + comment explaining why React needs it (parts-array reconciliation invariant).
  • BUGFIX (AMEX .xlsx import failed) — uploading any workbook (.xls / .xlsx / .xlsm) to /transactions/import returned a 500 with the misleading hint "Failed to parse workbook. Run pnpm install from the workspace root to ensure the xlsx dependency is installed." Root cause: turbopack/webpack server bundle could not pack SheetJS's lazy CommonJS requires, so the lazy `require('xlsx')` inside src/app/api/transaction-files/route.ts (and the inbox-analyze sibling) resolved to a broken bundle stub instead of the real module — same class of bug as the canvas one we already fixed. Diagnosed by running SheetJS directly against the user's real AMEX .xlsx (header at row 11, 690 rows, 11 columns) — file parses cleanly outside Next, confirming the issue was the bundler. Fix: added 'xlsx' to next.config.ts → serverExternalPackages alongside 'canvas' so the require resolves at request time through Node's real module resolver. Both .xls and .xlsx now flow through the same readWorkbookToRecords() → AMEX parser path identically.
  • BUGFIX (parser error message was misleading) — readWorkbookToRecords previously returned `string[][] | null`, and on null both upload routes hard-coded the same "Run pnpm install" string regardless of the actual cause. Refactored to a discriminated union `{ ok: true, rows } | { ok: false, error: string }` so the real SheetJS error (e.g. "Cannot find module 'xlsx'", "Bad uncompressed size", "End of central directory not found") propagates into the API response. Future workbook failures will now show the actual cause in the import banner instead of one canned string.
  • BUGFIX (RBC parser card was ambiguous about which accounts it handled) — the built-in RBC parser descriptor in src/lib/parser-descriptors.ts described how the parser works but never enumerated which RBC products it handles, AND the EXAMPLE INPUT ROWS strip below SPECIAL RULES only showed 2 of the 4 (Chequing + MasterCard). Two-part fix. (1) Added a leading notes[] bullet listing the 4 RBC products (Chequing → rbc-chequing/DEBIT; Mastercard → rbc-mastercard/MASTERCARD; Personal Savings → rbc-savings/DEBIT; Joint Savings → rbc-savings-joint/DEBIT) with the bankKey each resolves to and the paymentMethod stamped per row. (2) Extended sampleRows[] from 2 → 4 — one representative row per account: Chequing E-TRANSFER SENT; Mastercard a grocery purchase; Personal Savings DEPOSIT INTEREST; Joint Savings ONLINE BANKING TRANSFER. Both savings rows carry Account Type "Savings" — joint vs personal is filename-disambiguated downstream, not row-disambiguated — and the positive-amount savings rows also exercise the v1.90.3 "kept, not dropped" branch that lands them as income-side transfer-pair candidates. Source-of-truth now visible on the Settings → Imports parser card.
  • package.json sync — APP_VERSION in src/lib/constants.ts was at 1.92.23 but package.json was still pinned at 1.92.19 (pre-existing drift across v1.92.20/21/22). Brought package.json in line with constants per AGENTS.md "Always update both constants.ts and package.json to match" rule. No version bump for this release — APP_VERSION stays 1.92.23 with BUILD_HASH set to "ax1sx" per Scenario B (bug-fix-only).
  • POST-RELEASE — clear the dev cache before reloading: stop the dev server (Ctrl+C in the pnpm dev terminal) and run `rm -rf .next && pnpm dev` from /Users/ALEX/Projects/budget-tracking. constants.ts BUILD_HASH change ("" → "ax1sx") flips the Sidebar version span from "v1.92.23" to "v1.92.23#ax1sx", and the stale .next server-rendered HTML will mismatch the client bundle on first reload (hydration error on every page until the cache is cleared).

v1.92.23

React key-warning fix + features/bugFixes data backfill + full v1.92.x close-out regression

Improved

  • DATA BACKFILL — v1.92.20 / v1.92.21 / v1.92.22 entries added to data/budget.json features[] (6 entries: per-version feature + verification baseline) and bugFixes[] (3 entries: v1.92.20 unmark→suggested broken-by-v1.90, v1.92.21 unmark-half-broken, v1.92.23 React key warning). Features tab now shows the v1.92.20-22 work; Bug Fixes tab shows the v1.92.20-22 bugs. Previously these only appeared on the Release Notes tab because the RELEASES const in features/page.tsx is independent of the data/budget.json arrays.
  • REGRESSION — Full guard-types pass: 354/354 → 354/354 (no new assertions; v1.92.23 changes are key+data-only). vitest unchanged. APP_VERSION 1.92.22 → 1.92.23. tsc held at the existing baseline (no new TypeScript errors introduced).
  • v1.92.x close-out assessment — Track 1.V Phase A (CC-payment 3-way classifier + auto-flag + smart-suggest sequencing flip + cross-import partner-confirm + audit log + chip + multi-surface integration + force-link KIND labels + BUG-A real fix + Marked Pairs UX + incomplete-transfer + per-leg redesign) is FULLY SHIPPED across v1.92.10-v1.92.23. Phase B (auto-detect incomplete-transfer + cross-import partner-finder when partner CSVs land) explicitly DEFERRED to a future minor or v1.93 cut. v1.92.x is ready to close out — recommend v1.93.0 cut next to start Track 1.AA (multi-button AI Classify) per ROADMAP.
  • NEXT — v1.93.0 cut: archive v1.92.x detail in SHIPPED.md (already current as of v1.92.22 entry), start Track 1.AA Phase A (extract per-stage entry points from runAIClassification per docs/plans/v1.92.21-multi-button-classify-2026-05-06.md). Phase B incomplete-transfer auto-detect remains queued under Track 1.V.

Fixed

  • BUGFIX (React key warning) — Console error "Each child in a list should have a unique key prop. Check the render method of div. It was passed a child from TransactionsImportPage" fired on every /transactions/import render. Root cause: line 6798 had <React.Fragment key={i}> where i = rows.indexOf(r). When a row had been removed from rows mid-render, indexOf returns -1 and multiple Fragments all got key={-1}, breaking React reconciliation. Fix: composite key key={`${rowKeyOf(r)}|v${visibleIdx}`} — stable row hash + visible-list index tiebreaker for legitimate duplicate transactions. Suspect this also caused the v1.92.21 mystery where suggestions array length was 8 but DOM rendered 7 li elements.

v1.92.22

Per-leg incomplete redesign — fully unlink at commit, two sub-dropdowns, each leg gets its own expectedPartnerType

New

  • REDESIGN — the incomplete-transfer model wrongly treated two visible legs as partners of each other, when each is actually missing its OWN true partner. Example: a three-account chain where only the middle account's statement is imported — its incoming leg's real partner is upstream, its outgoing leg's real partner is downstream. They're halves of two separate transfers that happen to coincide on one account; a single-field model couldn't express that.
  • NEW data model — PendingPairMark gains leftExpectedPartnerType + rightExpectedPartnerType (the single expectedPartnerType field is deprecated, kept for one-cycle backward compat). The Marked Pairs UI shows TWO emerald sub-dropdowns when type === 'incomplete-transfer': "L (expense) partner: [...]" and "R (income) partner: [...]". Each sub-dropdown has the same 9 options (Unknown + 8 ManualLinkType values). Default Unknown for both.
  • COMMIT SEMANTICS — at commit time, when linkType === 'incomplete-transfer', /api/transactions/link DOES NOT call linkTwo(). NO shared manualLinkPairId is stamped. Instead, each leg independently receives its own expectedPartnerType (or remains undefined for Unknown). Both legs are touched (updatedAt bumped) so the audit chain reflects the explicit incomplete classification. Aggregates still count both legs (NOT in NULLIFYING_TYPES). The legs become two independent rows in the DB; Phase B partner-finder will scan rows with expectedPartnerType set and pair them with their TRUE counterparts when fresh imports land. New API response includes mode: 'incomplete-transfer' + the per-leg hints for client-side audit.

Improved

  • Verification baselines — guard-types 354/354 passing (352 v1.92.21 baseline + 2 new v1.92.22 assertions: per-leg fields on PendingPairMark, API route's incomplete-transfer no-pair branch). vitest unchanged. APP_VERSION 1.92.21 → 1.92.22.
  • NEXT — Phase B (auto-detect incomplete-transfer + cross-import partner-finder when partner CSVs land) still planned. With per-leg expectedPartnerType now stamped at commit, partner-finder gets a strong signal for each leg independently: scan committed expectedPartnerType-flagged rows on every import and try to match new legs against them by amount + date window + (optionally) the partner-type hint. Surface matches in a "Partner Found!" banner above the import preview.

v1.92.21

Rescued-pairs state guarantees Unmark→Suggested + incomplete-transfer sub-type dropdown + Track 1.AA multi-button-classify plan integrated into ROADMAP

New

  • FEATURE — incomplete-transfer sub-type dropdown. When the user picks "Incomplete (partner missing)" as a Marked Pair's type, a SECOND emerald-colored dropdown appears next to the type picker: "partner: [Unknown / Internal transfer / CC payment / Couple transfer / Refund / Rebate / Fee reversal / Associated / Other]". Default Unknown (= undefined expectedPartnerType). Stamps expectedPartnerType onto the PendingPairMark and forwards through resolvePendingLinks → POST /api/transactions/link body → linkTwo() so the field lands on both committed legs for Phase B partner-finder consumption. Sub-dropdown renders ONLY when type === 'incomplete-transfer' (no noise on other types). Expand panel also shows the captured partner-type in the per-pair detail.

Improved

  • ENHANCEMENT — Suggester limit bumped 10 → 30 in ImportSuggestionBanner allSuggestions useMemo. Belt-and-suspenders with rescued-pairs state: if the rescued pair somehow falls out of the synthetic-injection path, the higher organic limit gives weak cross-date partners a reliable shot at surfacing. No behavioral change for the typical case (most batches produce <10 useful suggestions).
  • PLAN INTEGRATION — Track 1.AA "Multi-button AI Classify (Transfers / Destination / Category / Detect+Classify)" added to ROADMAP.md as the canonical record for the v1.91.2 Phase 7D three-button restructure that was silently dropped in the 2026-05-05 ROADMAP consolidation when the "7D" label was reused for Bank fee auto-link. Source plan v1.92.21-multi-button-classify-2026-05-06.md preserved verbatim; per-phase plan A through F + open questions + lapse-watch notes embedded in ROADMAP. Implementation deferred to v1.92.22+ per the plan's clarifying-questions queue. Stale references in src/app/features/page.tsx:388 + :401 corrected to point at v1.92.22+ Track 1.AA. Track 1.D Phase 7D got a naming-collision back-pointer note.
  • Verification baselines — guard-types 352/352 passing (346 v1.92.20 baseline + 6 new v1.92.21 assertions: rescuedPairs prop accepted, PARTNER_SUB_OPTIONS includes 9 entries with Unknown default, sub-dropdown gated on incomplete-transfer, page wires rescuedPairs state + onUpdateExpectedPartnerType + commit-time clear, suggester limit bumped 10→30, ROADMAP has Track 1.AA section). vitest unchanged. APP_VERSION 1.92.20 → 1.92.21.
  • NEXT — v1.92.22 reshapes incomplete-transfer per Alex 2026-05-06 feedback: a pair marked Incomplete should fully UNLINK at commit so each leg becomes an independent single-leg flagged row with its OWN expectedPartnerType (the chequing expense leg might be missing a CC payment partner; the chequing income leg might be missing an internal transfer partner — they're not actually each other's partners). Two sub-dropdowns instead of one. Phase B (auto-detect incomplete-transfer + cross-import partner-finder when partner CSVs land) still planned per ROADMAP Track 1.V.

Fixed

  • BUGFIX (Unmark→Suggested half-broken in v1.92.20) — when an unmarked pair's preferred partner was already taken by another marked pair, the reconstructable pair re-scored too low to resurface in Suggested. The original mark's full metadata is now captured on Unmark and re-injected as a suggestion at the top of the list, cleared on commit. Verified live: the rescued high-confidence CC payment reappeared as the first Suggested entry.

v1.92.20

Track 1.V Phase A — Marked Pairs expand panel + incomplete-transfer linkType (9th member) + unmark restores v1.90 transfer-pair fields

New

  • FEATURE — Marked Pairs expand panel. Mirrors the existing Suggested Pairs panel: chevron toggles per-pair detail showing leg detail (kind / account / vendor / date / amount), score percentage, reasons, nullify behavior, and a small blue "auto" chip on pairs auto-marked by the v1.92.11 CC-payment heuristic. NEW optional score?: number + reasons?: string[] fields on PendingPairMark, captured at mark time on BOTH manual and auto-mark paths so the expand panel can render "Why X%: amount equal · same date · cross-bucket · CC payment vendor-pattern bypass (+0.15) · temporal order ✓" the same way Suggested Pairs does. Without this capture the panel could only show leg details and the user lost the "why was this auto-marked at 95%?" context.
  • FEATURE — a new "incomplete-transfer" link type for a same-file income+expense pair whose TRUE partners weren't imported. Both legs still count in totals until the missing partner statements are imported and reclassified, at which point the legs flip to a nullified pair naturally. An optional field records what kind of partner is still missing, for a future partner-finder to match against later imports.

Improved

  • KNOWN MINOR — with multiple same-amount chequing legs across a ±2-day window, greedy auto-mark over-pairs and a single Unmark surfaces the next-best cross-date candidate at 0.65 (not the original 95%) because the perfect-date partner is "stolen" by the still-marked sibling pair. Resolves once the user unmarks both. v1.92.21 introduced rescued-pairs state that mitigates this by guaranteeing the original 95%-score pair surfaces at the top of Suggested regardless of suggester re-scoring.
  • Verification baselines — guard-types 346/346 passing (337 v1.92.19 baseline + 9 new v1.92.20 assertions: VALID_LINK_TYPES 9-member union, expectedPartnerType field on row types, expectedPartnerType API stamping, PendingPairMark score + reasons fields, Marked Pairs expand panel mirror, ALL_LINK_TYPES + FindPairTypeModal + LinkedPairChip TYPE_LABEL all have incomplete-transfer, auto-mark captures s.score+s.reasons). vitest unchanged. APP_VERSION 1.92.19 → 1.92.20.
  • NEXT — v1.92.21 ships rescued-pairs state + sub-type dropdown + Track 1.AA plan integration (see entry above). v1.92.22 reshapes incomplete-transfer to UNLINK at commit so each leg gets its own expectedPartnerType independently per Alex 2026-05-06.

Fixed

  • BUGFIX — Unmark restores v1.90 transfer-pair fields. The v1.90 transfer-pair detector flags CC-payment legs at parse time with destination='skip' + include=false + transferPairId set. When the user unmarks via the v1.92.x Marked Pairs banner, the v1.92.20 onUnmark handler now ALSO clears transferPairId / transferPairConfidence / transferPairStatus / transferPairType / transferPairIndex / transferReason and restores preTransferDestination / preTransferInclude on both legs. Without this, unmarking removed the pair from Marked but didn't return it to Suggested because the rows stayed skip-flagged and rowsToExpenses/rowsToIncome filters excluded them. Per-leg restore is gated on r.transferPairId so only v1.90-flagged rows get touched.

v1.92.19

4 critical auto-mark fixes — no double-use, Unmark sticks, temporal-order preference, account labels on Marked Pairs

New

  • FEATURE — Temporal-order preference for CC payments. Money must leave chequing before it can show as a credit on a card statement, so the scorer now rewards the chequing leg dating on-or-before the card leg, and penalizes the reverse — a backwards-dated pairing now scores lower than the correct one. Helps disambiguate when several same-amount legs on each side could otherwise match interchangeably.
  • FEATURE — Account labels on Marked Pairs section. Auto-marked pairs now resolve and show their account labels the same way manually-marked pairs already did, instead of showing bare date, vendor and amount with no account context to verify the pair against.

Improved

  • Verification baselines — guard-types 337/337 passing. vitest unchanged. tsc 27 errors held (ZERO new). APP_VERSION 1.92.18 → 1.92.19. ⚠️ MANDATORY: rm -rf .next && pnpm dev per CLAUDE.md.
  • NEXT — re-test the import preview. Expected: only 3 marked pairs total (no double-use); Unmark removes a pair AND it stays unmarked; Marked Pairs section shows account labels; the temporally-incorrect backwards-dated pairing should rank below the same-day match (or be excluded entirely if the user re-marks correctly).

Fixed

  • BUGFIX (no double-use) — the Suggested Pairs list could show a candidate whose leg was already used in another marked pair, letting one transaction appear linked twice. Suggestions and auto-marking now both exclude already-claimed legs, so each transaction can appear in at most one pair, suggested or marked.
  • BUGFIX (Unmark sticks) — Pre-v1.92.19, clicking Unmark on an auto-marked pair triggered the auto-mark useEffect to immediately re-mark the same pair (it found the candidate again and didn't know the user had rejected it). NEW userRejectedPairKeys session state. Unmark callback adds the pair fingerprint (sorted leftKey+rightKey) to the set. Auto-mark useEffect skips any pair whose fingerprint is in the set. Cleared on commit (next import cycle starts fresh). Mirrors v1.92.7 dismissed-warning sessionStorage pattern.

v1.92.18

LinkTypeChip redesign (4 inline color-coded chips with refund sub-dropdown) + auto-mark pre-classifies chequing-side leg

New

  • CHIP REDESIGN per Alex 2026-05-05 — replaced single dropdown with 4 inline chips at ¼ width each. (1) Purple "CC payment" with CreditCard icon. (2) Blue "Transfer" with ArrowRightLeft icon. (3) Amber "Associated" with Link2 icon. (4) Emerald "Refund" with RotateCcw icon + sub-dropdown for Vendor refund / Rebate / Fee reversal disambiguation. Each chip color-coded per design lock. Click any chip → enters v1.92.7 Find-pair mode pre-set with the picked type. Refund chip opens its sub-menu first. Already-linked rows render single pill with click-to-unlink (X icon).

Improved

  • Verification baselines — guard-types 337/337 passing. vitest unchanged. tsc 27 errors held (ZERO new). APP_VERSION 1.92.17 → 1.92.18. ⚠️ MANDATORY: rm -rf .next && pnpm dev per CLAUDE.md.
  • NEXT — v1.92.19 swap CcPaymentChip → new 4-chip LinkTypeChip on /expenses/ad-hoc + /income surfaces. v1.92.20+ drop isCreditCardPayment flag entirely with one-time migration. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md.

Fixed

  • AUTO-MARK PRE-CLASSIFICATION FIX — when Phase E auto-mark fires on a CC-payment pair, it now ALSO pre-classifies the CHEQUING-SIDE leg with destination='adhoc' + category='Professional & Financial' + subCategory='Credit Card Payment'. Pre-v1.92.18, Alex saw chequing-side rows show "—" "—" "—" in the import preview (empty destination/category/sub) because the v1.92.17 parser fix only handled CC-source positive-amount rows; the chequing-side outflows had no parser-level pre-classification. Now both legs of an auto-marked pair are properly classified. Phase D auto-flag-on-import then sees the subCategory match and sets isCreditCardPayment: true on commit. Fill-if-empty pattern preserves user-set values.

v1.92.17

BUG-A TRUE ROOT CAUSE FIX — CSV parser pre-classified at upload time + unified Link-Type dropdown chip

New

  • UNIFY WORK (no more deferring) — NEW src/components/transactions/LinkTypeChip.tsx unified dropdown chip with 6 link-type options (CC payment / Internal transfer / Associated / Refund / Rebate / Fee reversal). Click chip → menu opens → pick option → enters v1.92.7 Find-pair mode pre-set with the chosen type → user clicks row B → FindPairTypeModal opens with type pre-selected → confirm. Replaces the standalone CcPaymentChip on /transactions/import preview. /expenses/ad-hoc and /income still use CcPaymentChip (with plausibility from v1.92.15) until v1.92.18 swap.
  • UNIFY WORK — Already-linked rows render the chip in "✓ Linked: <type>" disabled state with click-to-unlink (removes from pendingLinkMarks). Plausibility + linkChipVisibility settings unchanged from v1.92.15. New findPairTypeOverride state holds the pre-picked type from chip click; cleared on modal Cancel/Confirm. FindPairTypeModal accepts optional initialType prop that wins over inferLinkType auto-inference.

Improved

  • Verification baselines — guard-types 337/337 passing (no new guards in v1.92.17 — primitives unchanged; LinkTypeChip is wired through existing pendingLinkMarks API). vitest expected at 583+ (581 v1.92.16 baseline + 2 updated existing csv-import-counter-legs cases). tsc 27 errors held (ZERO new). APP_VERSION 1.92.16 → 1.92.17. ⚠️ MANDATORY: rm -rf .next && pnpm dev per CLAUDE.md.
  • NEXT — v1.92.18 swap CcPaymentChip → LinkTypeChip on /expenses/ad-hoc + /income surfaces. v1.92.19+ drop isCreditCardPayment flag entirely (manual-link only as source of truth) with one-time migration of existing flagged rows. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md.

Fixed

  • BUG-A TRUE ROOT CAUSE FIX — the real bug fires at CSV upload, not at AI Classify. Two built-in parsers were hardcoding a generic transfer category on every card-payment row at parse time, before AI classification ever ran, and later fixes downstream couldn't override an already-set field. This is why the wrong category kept persisting despite several earlier attempted fixes.
  • Fix — both parser sites now seed the v1.92.10 sentinel L2 "Credit Card Payment" instead of "Internal Account Transfer". moneyInType becomes "credit_card_payment" (the v1.92.10 expanded MoneyInType value). isCreditCardPayment: true is also seeded at parse time so v1.92.11 Phase D auto-flag-on-import has the value pre-populated and just commits forward. The Scotiabank Interac E-Transfer branch (line 856) remains "Internal Account Transfer" — that one IS chequing↔chequing and the existing label is correct. ParsedRow type widened to include isCreditCardPayment?: boolean. 2 existing vitest cases updated to assert the new pre-classification.

v1.92.16

BUG-A REAL FIX — migration moved to readDB() + defensive AI override for canonical CC-payment vendor patterns

Improved

  • NO USER ACTION REQUIRED for the defensive override. It runs server-side on every classify-income API call. Re-run AI Classify in the import preview — RBC Mastercard Pay rows on RBC MASTERCARD source should now route to "Income → Transfers & Refunds → Credit Card Payment" with moneyInType: credit_card_payment. The Phase D auto-flag-on-import (which depends on moneyInType: credit_card_payment) WILL now fire — expect to see purple "CC payment · skip" chips on those rows after commit.
  • Verification baselines — guard-types 337/337 passing (no new guards in v1.92.16 — the override logic is in API routes covered by integration paths). vitest unchanged. tsc 27 errors held (ZERO new). APP_VERSION 1.92.15 → 1.92.16. ⚠️ MANDATORY: rm -rf .next && pnpm dev per CLAUDE.md.
  • NEXT — Re-test the import preview. If RBC Mastercard Pay rows STILL route to Internal Account Transfer after a fresh `pnpm dev` + AI Classify, that's a third issue (vendor cache, prompt template caching at the AI provider, or the override regex missing a vendor-string variation). v1.92.17 if needed for further hardening. Otherwise v1.92.17 = full Phase K unified Link-Type dropdown chip redesign.

Fixed

  • BUG-A REAL FIX (root cause #2) — v1.92.15 placed the settings migration in /api/settings GET handler, but the AI Classify routes (/api/ai/classify-income + /api/ai/classify-income-l1) call readDB() directly and bypass /api/settings. If the user hit AI Classify after the v1.92.15 deploy but BEFORE the page navigated through the settings GET path, the migration never fired → AI prompt was still rendered with the unmigrated hierarchy missing the Credit Card Payment L2 → AI fell back to Internal Account Transfer. STILL the same bug Alex first flagged. Fix: move the migration logic INTO readDB() in src/lib/db.ts. Every consumer of the DB now gets patched settings in-memory regardless of which entry path they used. Idempotent (checks before inject). Pure in-memory patch; the next writeDB persists.
  • BUG-A DEFENSIVE OVERRIDE (belt-and-suspenders) — Even with the L2 in the AI option list, the AI may still pick the old Internal Account Transfer habit for canonical CC-payment vendor patterns. New defensive override in /api/ai/classify-income + /api/ai/classify-income-l1: AFTER the AI returns its classification, check if (a) row.sourceKind === 'credit_card', (b) row vendor matches LINK_TYPE_VENDOR_PATTERNS.ccPayment regex, and (c) AI didn't already pick subCategory 'Credit Card Payment'. If all three, force-override category to 'Transfers & Refunds', subCategory to 'Credit Card Payment', moneyInType to 'credit_card_payment'. Logs override count to console.info. Makes the canonical CC-payment classification DETERMINISTIC regardless of AI behavior — no more relying on AI prompt-following.

v1.92.15

Track 1.V Phase K (foundation) — BUG-A settings migration + BUG-B auto-mark threshold fix + chip plausibility + Settings.linkChipVisibility

New

  • BUG-B VITEST — 2 new cases under a bank fee/rebate test case: (1) a transfer paired with a credit-card payment of the same amount on the same date hits ≥0.85 score with the boost reason; (2) when both vendor strings already match exactly, the boost is NOT applied (the suggestion already qualifies via normal vendor-similarity).
  • Phase K (foundation) — Settings.linkChipVisibility (default "plausibility") gates chip rendering across all 3 surfaces (import preview, /expenses/ad-hoc, /income). Three modes: "always" (chip on every row, max discoverability), "plausibility" (chip only on rows where at least one link-type is plausible — CC source, chequing/savings source, or vendor matching CC-payment / transfer / refund regex), "hidden" (chip suppressed; user uses v1.92.7 🔗 Find-pair button or /transactions force-link hub). Default plausibility addresses Alex's original BUG-C complaint about chip noise on irrelevant rows.
  • Phase K (foundation) — NEW src/lib/source-kind.ts exports: LINK_TYPE_VENDOR_PATTERNS (regex constants for ccPayment / transfer / rebateOrRefund — mirrors inferLinkType so chip plausibility tracks the auto-mark heuristic) + isPlausibleForAnyLinkType() helper (returns true when at least one link-type fits the row). Already-flagged rows always render the chip so users can unflag, regardless of plausibility.

Improved

  • Phase K DEFERRED to v1.92.16 — full unified Link-Type dropdown chip with 4 options (CC payment / Internal transfer / Associated / Refund-rebate-fee) + P3 hybrid pair-creation flow (auto-search HC partner ≥0.95; if unique, link silently; if zero/multiple, enter pick-partner mode pre-set with type) + drop isCreditCardPayment flag in favor of manual-link only. Locked Alex 2026-05-05 but deferred to next minor for proper design pass + migration of existing flagged rows. v1.92.15 ships the foundation (visibility settings + plausibility filter); v1.92.16 ships the redesign.
  • Verification baselines — guard-types 337/337 passing (no new guards in v1.92.15 — pure helper additions + chip render-gate logic; existing primitives covered). vitest expected at 583+ once locally run (581 v1.92.14 baseline + 2 new BUG-B cases). tsc 27 errors held (ZERO new). APP_VERSION 1.92.14 → 1.92.15. ⚠️ MANDATORY: rm -rf .next && pnpm dev per CLAUDE.md.
  • NEXT — v1.92.16 Phase K full redesign (unified Link-Type dropdown chip + P3 pair-creation flow + drop isCreditCardPayment flag with migration). v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

Fixed

  • BUG-A FIX (CRITICAL — root cause of the original complaint) — a new Credit Card Payment category was added to the default category list, but existing installs with their own saved category list never received it, so the AI classifier had nowhere to route a card-payment row and kept falling back to a generic transfer label. A migration now injects the missing categories into existing settings automatically.
  • BUG-B FIX — Phase E auto-mark threshold was 0.85 but canonical CC-payment pairs (ONLINE BANKING TRANSFER ↔ RBC Mastercard Payment) score only ~0.75 due to weak vendor bigram similarity (the two vendor strings share almost no n-grams). Auto-mark NEVER fired on the case it was specifically designed for. Fix: in src/lib/link-suggest.ts scoreCandidate(), when inferLinkType returns "credit-card-payment" AND vendor similarity is <0.30, add +0.15 score boost (a "vendor-pattern bypass" reflecting that CC-metadata signal via cardBrand/paymentMethod or vendor regex is strong enough to substitute for vendor-string match). Canonical CC-payment pairs now reach ~0.90, comfortably above auto-mark threshold. Boost does NOT apply when vendor sim is strong (no double-counting).

v1.92.14

Track 1.V Phase I full integration + Force-link KIND subtype labels + HOTFIX Phase G TDZ error

New

  • Phase I FULL INTEGRATION — CcPaymentChip wired on three surfaces. (1) /transactions/import preview rows: chip slots above the destination dropdown next to the link-mark chip. Pre-commit click-to-toggle updates local row state via updateRow; the flag flows through the Phase D auto-flag pipeline on commit. (2) /expenses/ad-hoc rows: chip slots inline next to vendor name. Click triggers optimistic local state update + PUT /api/expenses with isCreditCardPayment + updatedBy: 'user'. Phase H log fires server-side. (3) /income rows: chip slots inline next to employer name. Click triggers optimistic update + PUT /api/income. Both PUTs trigger v1.92.13 Phase H cc-payment-corrections.jsonl logging.
  • Force-link KIND column subtype labels (NEW per Alex 2026-05-05) — /transactions force-link hub KIND column previously showed just "income" or "expense". Now shows two-tone label with subtype: Income [Earning] / Income [Rebate] / Income [Reimbursement] / Income [CC payment] (uses v1.92.2 classifyIncomeRow + v1.92.10 isCreditCardPayment flag). Expense [Ad-hoc] / Expense [Recurring] / Expense [Refund] / Expense [CC payment] (infers from row shape: recurringExpenseId, refundOfExpenseId, isCreditCardPayment). New kindLabelOf() helper centralizes the logic. Title attribute exposes the full label for screen readers.

Improved

  • Phase I Option C DEFERRED again to a future minor (not v1.92.15) — auto-link unique HC partner on flag-ON requires its own clarifying round on multi-candidate UX, score thresholds, and conflict resolution with existing manualLinkPairId. Foundation already exists via v1.92.12 cross-import-pairs module — when shipped, the chip-toggle handler will call findCrossImportCandidates with score ≥0.95 and POST /api/transactions/link silently for unique matches.
  • Verification baselines — guard-types 337/337 passing (no new regression guards for v1.92.14 — pure integration work; the underlying primitives (CcPaymentChip, kindLabelOf shape) are already covered by Phase I component existence guard #336). vitest unchanged. tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.13 → 1.92.14; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.13 Phase J browser regression of v1.92.0 → v1.92.14 (in flight at time of v1.92.14 ship). v1.92.15+ = Option C auto-link + any bugs surfaced by Phase J. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

Fixed

  • HOTFIX (severity: CRITICAL; live since v1.92.12) — /transactions/import page threw "Cannot access 'detectedPerFile' before initialization" runtime error on every page load. v1.92.12 Phase G placed the lazy-load useEffect with dep array `[rows, detectedPerFile]` BEFORE detectedPerFile state declaration in the component — JavaScript TDZ caught the eval-during-render. Fixed by relocating the entire Phase G useEffect to AFTER the detectedPerFile useState. Page now loads correctly. Discovered when Alex tested the v1.92.13 ship in browser.

v1.92.13

Track 1.V Phase H + I (foundation) — cc-payment-corrections.jsonl audit log + reusable CcPaymentChip component

New

  • Phase H FEATURE — NEW src/lib/cc-payment-log.ts pure module exports appendCcPaymentCorrection() + readCcPaymentCorrectionLog(). Append-only JSONL log at data/logs/cc-payment-corrections.jsonl per Track 15 invariant #9. Captures BOTH directions (locked Alex 2026-05-05): flag_on (user added flag to row AI did not auto-flag — AI false-negative signal) AND flag_off (user removed flag from auto-flagged row — AI false-positive signal). Future v1.94.0 vendor-history-aware classifier (Track 2.B) consumes alongside classifications.jsonl to refine CC-payment vendor patterns over time.
  • Phase H WIRING — /api/expenses route.ts and /api/income route.ts PUT handlers detect isCreditCardPayment changes, call appendCcPaymentCorrection() with full log entry shape (timestamp · action · rowKind · rowId · vendor · amount · date · sourceFileId? · wasAutoFlagged · reason?). Errors swallowed by helper per invariant #9 — disk-full or perm issues cannot block the PUT flow. wasAutoFlagged heuristic uses prev.updatedBy === "import" || classifierConfidence presence to approximate "did the v1.92.10 Phase D auto-flag path stamp this row originally."
  • Phase I COMPONENT — NEW src/components/transactions/CcPaymentChip.tsx reusable inline chip. Two visual states: amber unflagged ("flag as CC payment") and purple flagged ("CC payment · skip"). Click toggles state via onToggle callback. Hover state surfaces × icon on flagged variant for unflag affordance. Compact prop for dense table rows. Disabled prop for in-flight PUTs. Component is INTENTIONALLY light — parent owns side-effect logic (local state update vs PUT vs Option C auto-link).

Improved

  • Phase I INTEGRATION DEFERRED to v1.92.14 — chip placement on import preview rows + /expenses/ad-hoc rows + /income rows is queued for the next minor bump alongside Option C auto-link logic. Reason: ship the v1.92.13 foundation (logging works server-side, chip is a built component) and use Phase J browser regression to surface ANY bugs across the v1.92.0-v1.92.13 stack BEFORE adding more UI surface area. Better risk profile than bundling everything.
  • Phase I Option C DEFERRED to v1.92.14 — auto-link unique high-confidence partner on flag-ON. Locked Alex 2026-05-05 but deferred to v1.92.14 with its own clarifying round on edge cases (multi-candidate modal UX, score threshold ≥0.95 vs ≥0.90, conflict with existing manualLinkPairId). Foundation needed for Option C is already shipped via the v1.92.12 cross-import-pairs module — v1.92.14 just wires the chip-toggle-handler to it.
  • Phase J PHASE NEXT — full browser regression test of v1.92.0 → v1.92.13. Walk through every shipped feature: force-link UI hub (v1.92.0), smart-suggest banner (v1.92.1), 3-way income split (v1.92.2), sidebar IA + auto-flag reimbursement (v1.92.3), 8-member ManualLinkType (v1.92.4), type-override + same-source disqualifier (v1.92.5), marked pairs persist + smart ordering (v1.92.6), Find-pair affordance + reversed auto-uncheck (v1.92.7), VALID_LINK_TYPES drift fix (v1.92.8), refund dual-encoding (v1.92.9), CC-payment schema + AI prompt rev + source-kind (v1.92.10), sequencing flip + auto-flag + auto-mark + aggregate nullify (v1.92.11), cross-import partner-confirm UI (v1.92.12), CC-payment audit log + chip component (v1.92.13). Capture bugs for v1.92.14 bug-fix release.
  • Track 15 invariant #9 updated — cc-payment-corrections.jsonl added to the canonical audit-log enumeration. ROADMAP source plan + Lapse-watch references updated.
  • Verification baselines — guard-types 337/337 passing (332 v1.92.12 baseline + 5 new v1.92.13: cc-payment-log.ts module exports, log path correctness, expenses-route PUT integration, income-route PUT integration, CcPaymentChip component). vitest expected at 581+ once locally run (no new vitest cases — Phase J browser-test catches the integration-level regressions). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.12 → 1.92.13; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.14 bundles: (1) Phase I full chip integration on import preview / /expenses/ad-hoc / /income surfaces (~2-3h); (2) Phase I Option C auto-link unique HC partner on flag-ON (~2-3h, with own clarifying round on multi-candidate UX); (3) any bug-fixes surfaced by v1.92.13 Phase J browser regression. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

v1.92.12

Track 1.V Phase G — cross-import partner-confirm UI for CC payments that span import sessions

New

  • Phase G FEATURE — Cross-import partner-confirm UI surfaces high-confidence CC-payment partners between current-import rows and already-committed rows from PRIOR import sessions. Use case: user imports CC statement Tuesday after chequing was imported Monday — Tuesday's CC-side row finds Monday's chequing-side leg via lazy-load match against /api/expenses + /api/income. In-import auto-mark (Phase E v1.92.11) catches pairs in the SAME import session; Phase G covers the cross-session gap.
  • Phase G NEW MODULE — src/lib/cross-import-pairs.ts (pure, no fs/no fetch) exporting findCrossImportCandidates() + CrossImportCandidate type. Conservative defaults locked Alex 2026-05-05: scoreFloor 0.9 (vs 0.85 for in-import), dateWindowDays 30 (narrow pre-filter), amountTolerance 0.01 (rounding-error only — tighter than in-import 0.05), topPerRow 3 (cap per row). Higher bar reflects that cross-session matches are more error-prone than in-import. Pure module per Track 15 invariant #8.
  • Phase G ALGORITHM — (1) Filter current rows to sourceKind === credit_card AND positive amount (locked trigger scope). (2) Pre-filter committed pools to ±30d window. (3) Score via existing suggestPairs helper, require inferLinkType returns credit-card-payment + score ≥ 0.9 + amount within ±$0.01. (4) Highest-score-wins de-dup: each committed row claimed by its strongest current-row partner. (5) Cap top 3 per row, sort by amount-exact > date proximity > vendor similarity. (6) Drop empty-candidate rows from output (auto-hide UI surface).
  • Phase G NEW COMPONENT — src/components/transactions/CrossImportPairConfirm.tsx — collapsed-by-default expander UI BELOW the Suggested Pairs banner (locked placement). Header: "🔗 N rows have possible cross-import matches". Body groups candidates by current-row index. Each candidate has Confirm / Not this one buttons. Auto-hides when zero candidates. Confirmed pairs show ✓ Confirmed disabled state.
  • Phase G LAZY-LOAD — Import-page useEffect fires when current import contains any sourceKind === credit_card positive-amount row. Fetches /api/expenses + /api/income in parallel, computes candidates, stores in state. Refs the eligibility signature so re-renders don't re-fetch. Gate prevents perf hit on chequing-only imports (which usually have both legs in the same file).
  • Phase G DEFERRED FLUSH — Cross-import marks defer to post-commit hook because the current-import row has no DB ID until commit. After /api/expenses + /api/income POSTs land, the flush resolves the current-row stable key (date|amount|vendor|owner) to its fresh DB id, then POSTs /api/transactions/link with both real IDs + linkType: 'credit-card-payment'. Status bar surfaces "N cross-import links confirmed" alongside the existing in-import flush summary.
  • Phase G END-TO-END FLOW — Day 1: a chequing import commits an outgoing card-payment row. Day 2: a credit-card statement import shows the matching incoming row in preview, and the cross-import matcher finds the Day-1 partner (score ≥ 0.9, amount within $0.01, date within 30d). The expander shows the match; confirming links and nullifies both rows in aggregates.
  • Phase G NEW VITEST — src/lib/__tests__/cross-import-pairs.test.ts — 12 cases: canonical happy-path, empty when no eligible CC rows, empty when no committed in window, ±$0.01 tolerance reject, ±$0.01 tolerance accept, top-3 cap, highest-score-wins de-dup, empty-row hide, exclude already-linked partners, custom score floor, custom date window, candidate output shape.

Improved

  • Verification baselines — guard-types 332/332 passing (324 v1.92.11 baseline + 8 new v1.92.12: cross-import-pairs.ts module exists, conservative defaults check, sourceKind+positive-amount trigger gate, highest-score-wins de-dup, CrossImportPairConfirm component, hides-empty-state check, import-page wiring + lazy-load effect, deferred post-commit flush). vitest expected at 581+ once locally run (569 v1.92.11 + 12 new Phase G cases). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.11 → 1.92.12; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.13 = Track 1.V Phase H (training-signal log to data/logs/cc-payment-corrections.jsonl when user toggles isCreditCardPayment off after auto-flag) + Phase I UI chips (inline auto-flag toggle on import preview + /expenses/ad-hoc + /income rows). v1.92.14 = Phase J end-to-end verification + edge-case polish. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

v1.92.11

Track 1.V Phase C + D + E + F — sequencing flip invariant + auto-flag on import + auto-mark CC-payment pairs + aggregate nullify wiring

New

  • Phase C INVARIANT — Smart-suggest sequencing flip is a NO-OP code-wise (the suggester already partitioned rows by parser-set r.destination, not AI-set r.incomeCategory/r.txType, so it has always run pre-AI). Phase C makes this an explicit invariant via a doc-comment in src/components/transactions/ImportSuggestionBanner.tsx + a guard-types regression guard. Future refactors that re-couple the suggester to AI output will fail the guard. Net effect: smart-suggest fires immediately on parse, auto-marked nullifying pairs feed linkedSkipIndices (v1.92.7), AI Classify never fires on those rows — saves API cost on the most predictable cases.
  • Phase D AUTO-FLAG-ON-IMPORT — When AI emits moneyInType: 'credit_card_payment' on the income-side leg (positive amount on a CC source) OR subCategory: 'Credit Card Payment' on the chequing-side leg (negative amount in chequing), the import-commit path auto-stamps isCreditCardPayment: true on the row. Gated on Settings.autoFlagCreditCardPaymentOnImport (default true). Mirrors the v1.92.3 autoFlagReimbursementOnImport pattern. NEW helpers in src/lib/income-types.ts: inferIsCreditCardPaymentFromMoneyInType + inferIsCreditCardPaymentFromSubCategory. Wired into both adhocPayload + incomePayload builders in src/app/transactions/import/page.tsx.
  • Phase E AUTO-MARK CC-PAYMENT PAIRS — NEW useEffect in src/app/transactions/import/page.tsx that runs suggestPairs over pre-AI rows, filters to inferredType === 'credit-card-payment' AND score ≥ 0.85 (Phase A locked threshold), and auto-adds matching pairs to pendingLinkMarks. Gated on Settings.autoMarkCreditCardPayments (default true). Strict 5-condition guard already enforced inside link-suggest.ts inferLinkType (vendor pattern + cross-bucket + amount equality + sourceFileId-different + isCcLeg metadata). Idempotent: pairs already in pendingLinkMarks short-circuit on the next render. Auto-marked pairs feed linkedSkipIndices → AI Classify skips both legs.
  • Phase F AGGREGATE NULLIFY — NEW exports in src/lib/links.ts: isCreditCardPaymentNullified (per-flag predicate; honors Settings.includeCreditCardPaymentsInAggregates default false → exclude) + isAggregateExcluded (composed predicate ORing isManuallyNullified + isCreditCardPaymentNullified). src/lib/calculations.ts now uses isAggregateExcluded in sumOneTimeAmounts, sumIncomeAmounts, and getMonthlyTotalIncome — so isCreditCardPayment-flagged rows drop out of /balance + /trends + /insights aggregates by default. Combinatorial: a row with both isReimbursement: true AND isCreditCardPayment: true AND manualLinkPairId nullifies exactly once (single skip via short-circuit).
  • NEW VITEST — src/lib/__tests__/links.test.ts gains 9 new cases under 2 describe blocks (isCreditCardPaymentNullified: 4 cases for setting-on/off/unset/flag-off; isAggregateExcluded: 5 cases for combined predicate behavior including idempotency, setting interactions, and short-circuit). Confirms the Phase F aggregate-exclusion math holds across all 2^3 = 8 combinations of (isReimbursement, manualLinkPairId, isCreditCardPayment).
  • END-TO-END USER FLOW — Import a CC + chequing statement together: (1) parser detects CC source via bankKey suffix; (2) smart-suggest fires immediately on parse (Phase C invariant), finds the CC payment pair via inferLinkType returning 'credit-card-payment'; (3) Phase E auto-marks the pair with manualLinkType: 'credit-card-payment'; (4) AI Classify skips both legs via linkedSkipIndices (v1.92.7); (5) on commit, Phase D auto-flag fires for any rows where AI did emit moneyInType: 'credit_card_payment' (paste-flow imports without auto-mark), stamping isCreditCardPayment: true; (6) Phase F aggregate predicates exclude flagged rows from /balance + /trends + /insights. Net effect: CC bill payments and their chequing counterparts disappear from spend/income totals as if they never happened — accurate "money I actually spent / earned" math.

Improved

  • Verification baselines — guard-types 324/324 passing (317 v1.92.10 baseline + 7 new v1.92.11: Phase C invariant + Phase D income+expense auto-flag + Phase E useEffect + Phase F two new exports + default-exclude inversion check + the existing v1.92.1 isManuallyNullified assertion widened to accept isAggregateExcluded). vitest expected at 569+ once locally run (560 v1.92.10 + 9 new Phase F cases). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.10 → 1.92.11; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.12 = Track 1.V Phase G cross-import partner-confirm UI (~3-4h). Surface candidate matches against already-committed rows when only one leg is in the current import (e.g., user imports CC statement Monday, chequing Tuesday — Tuesday's import suggests linking back to Monday's already-committed CC-side row). Lazy-load /api/expenses + /api/income gated on "any unflagged CC-payment-pattern row in current import" (perf gate). v1.92.13 = Phase H training-signal log (data/logs/cc-payment-corrections.jsonl) + Phase I UI chips for inline auto-flag toggle. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

v1.92.10

Track 1.V Phase A + B — CC Payment classifier foundation (schema additions + 3-way AI prompt rev + source-kind context)

New

  • Phase A SCHEMA — MoneyInType union expanded from 6 → 8 members. New: 'rebate' (cashback / multiproduct rebate / promotional credit) and 'credit_card_payment' (CC-side leg of a CC bill payment). Backward-compatible: legacy 6 values still valid; new values surface only when AI emits them. Closes Alex 2026-05-05 ask: "we need a separate distinguishable type that is just clearing debt".
  • Phase A SCHEMA — isCreditCardPayment?: boolean field added to BOTH OneTimeExpense (chequing-side leg) and IncomeEntry (CC-side leg). Symmetric to v1.92.2 isReimbursement pattern. CC-side leg flagged on income; chequing-side leg flagged on expense. Drives Phase F aggregate nullify (when shipped) — flagged rows excluded from /balance + /trends + /insights per Settings.includeCreditCardPaymentsInAggregates (default false).
  • Phase A SCHEMA — 3 new Settings fields: autoFlagCreditCardPaymentOnImport (default true; mirrors v1.92.3 autoFlagReimbursementOnImport — auto-stamps isCreditCardPayment on AI-classified rows at import commit), includeCreditCardPaymentsInAggregates (default false; aggregate-exclusion escape hatch), autoMarkCreditCardPayments (default true; smart-suggest auto-mark gate, strict 5-condition guard locked Alex 2026-05-05).
  • Phase A SCHEMA — sentinel L2 categories added without architectural change. ADHOC: 'Professional & Financial → Credit Card Payment' (chequing-side leg commit bucket). INCOME: 'Transfers & Refunds → Credit Card Payment' (CC-side leg commit bucket; replaces the wrong 'Internal Account Transfer' default). Per Alex: "not recurring, not an expense, just clearing debt" — the flag drives aggregates; the bucket gives the row identity in /income + /expenses/ad-hoc views.
  • Phase B AI PROMPTS — All 4 income classifier prompt variants reved for 3-way disambiguation: classify-income (one-shot), classify-income-bare, classify-income-l1 (two-step step 1), classify-income-l1-bare. Each prompt now includes (a) expanded MoneyInType list with rebate + credit_card_payment, (b) SOURCE-KIND CONTEXT block documenting the [source: <kind>] prefix, (c) 3-WAY DISAMBIGUATION FOR POSITIVE AMOUNTS ON CREDIT-CARD SOURCES block with vendor signals (MASTERCARD PAYMENT / MULTIPRODUCT REBATE / etc.), L1/L2 routing per pattern, ambiguous-case fallback ("pick 'other' with low confidence; the import-preview review flow will catch it").
  • Phase B AI PROMPTS — origin: a real screenshot showing AI mis-routing a card-payment deposit to a generic internal-transfer category. Three positive-amount-on-card patterns the AI must now disambiguate: paying the card bill from chequing, a refund matching a prior charge, and an issuer rebate or cashback credit — each now routes to its own proper category.
  • Phase B SOURCE-KIND HELPER — NEW src/lib/source-kind.ts pure module exporting deriveSourceKind, deriveSourceKindFromBankKey, deriveSourceKindFromAccountType, sourceKindLabel. Maps bank-key suffixes to canonical SourceKind union ('credit_card' | 'chequing' | 'savings' | 'investment' | 'loan' | 'other' | 'unknown'). 18 bank-key cases covered. Pure (no fs/env deps) per Track 15 invariant #8. 17 new vitest cases under src/lib/__tests__/source-kind.test.ts (institution-only, suffix-based, case-insensitive, AccountType fallback, combined-derive precedence, label rendering).
  • Phase B WIRING — /api/ai/classify-income/route.ts and /api/ai/classify-income-l1/route.ts accept sourceKind?: SourceKind on TransactionInput. Each row's txLine gets [source: <label>] prefix when sourceKind is provided (empty for 'unknown' to suppress noise). Import page (src/app/transactions/import/page.tsx) derives sourceKind per row via detectedPerFile lookup → bankKey → deriveSourceKindFromBankKey, included in txList payload to both endpoints. Backward-compatible: pre-v1.92.10 callers without sourceKind still work; the prompt 3-way block falls back gracefully (no [source:] prefix means AI can't apply 3-way disambiguation but other classification proceeds).

Improved

  • NOT-YET-IMPLEMENTED Phase C-J: smart-suggest sequencing flip (Phase C — runs BEFORE AI Classify so linkedSkipIndices suppresses API cost on auto-marked CC pairs); auto-flag-on-import wiring (Phase D — connects AI output to isCreditCardPayment auto-stamp); strict-guard auto-mark (Phase E — 5-condition false-positive gate); aggregate nullify wiring (Phase F — calculations.ts + event-calculations.ts); cross-import partner-confirm UI (Phase G — surfaces matches against already-committed rows); training-signal log (Phase H — data/logs/cc-payment-corrections.jsonl); UI chips (Phase I — inline auto-flag toggle); verification (Phase J). Each phase will be its own minor bump (v1.92.11+) per Alex 2026-05-05 convention update.
  • Verification baselines — guard-types 317/317 passing (302 v1.92.9 baseline + 8 new Phase A + 7 new Phase B = +15). vitest expected at 560+ once locally run (543 v1.92.9 + 17 new Phase B source-kind cases). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.9 → 1.92.10; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.11 = Phase C smart-suggest sequencing flip + Phase D auto-flag-on-import. Then incremental phases through v1.92.18ish (or however many minor bumps). Phase G (cross-import partner-confirm UI) and Phase F (aggregate nullify) are the highest-leverage user-visible phases. v1.93.0 cut moves all v1.92.x detail from ROADMAP to SHIPPED.md per Alex 2026-05-05 convention.

v1.92.9

Lapse 3 fix — linkTwo() co-stamps refundOfExpenseId when linkType is refund (v1.87.0 partial-refund net-display path now fires for manual-link refunds)

Improved

  • 7 new vitest cases in src/lib/__tests__/links.test.ts under "refund dual-encoding (v1.92.9 Lapse 3)" describe block: opposite-sign refund left+right both directions, same-sign date heuristic, ambiguous (same sign + same date) skip + console.warn assertion, non-refund linkTypes (rebate/fee-reversal) NOT stamping refundOfExpenseId, cross-bucket attempt no-op (defensive), backward-compat without kind hints. 2 new guard-types assertions: linkTwo() refund branch present; API rejects cross-bucket refund.
  • Discovery — flagged during 2026-05-05 ROADMAP self-audit (the same audit that surfaced v1.92.8 BUG-1). Carved out from the original v1.92.8 lapse-fix bundle (Lapses 1/3/4/5/6 + polish) per Alex 2026-05-05 lock decision: ship Lapse 3 standalone in v1.92.9 to clean the foundation BEFORE v1.92.10 Track 1.V (CC payment 3-way classifier), where any auto-mark heuristic that accidentally fires on a refund pattern would have written half-broken refund records.
  • Verification baselines — guard-types 302/302 passing (300 v1.92.8 baseline + 2 new v1.92.9 regression guards). vitest expected at 543+ once locally run (536 v1.92.8 baseline + 7 new). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.8 → 1.92.9; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.10 = Track 1.V CC Payment 3-way classifier + auto-flag + smart-suggest sequencing flip (~15-20h, full 10-phase plan in ROADMAP.md). Origin: Alex 2026-05-05 screenshot showing AI mis-routing CC-statement positive amounts to "Income → Internal Account Transfer". v1.92.11+ = remaining Track 1.W lapses (1/4/5/6 + polish) + Track 1.Y dashboard cards.

Fixed

  • BUGFIX (severity: high; live since v1.92.0) — manually linking a refund pair stamped the link but not the field the partial-refund net display actually reads, so a refund linked by hand never updated the net-of-refund figure — the chip showed linked, but the original uncorrected amount stayed on screen.
  • Fix — linkTwo() signature gains optional leftKind / rightKind hints. When type === "refund" AND both legs are expenses (kinds match), the function determines refund-leg vs original-leg via sign-first heuristic (negative = refund, positive = original) with date-based tie-break for same-sign cases (later date = refund). Sets refundOfExpenseId on the refund leg pointing at the original leg id. Same-sign + same-date cases are ambiguous → console.warn fires + manual-link fields still land but refundOfExpenseId is skipped (relationship captured, directional FK skipped).
  • Fix — /api/transactions/link POST handler now rejects cross-bucket "refund" attempts (one leg expense, one leg income) with 400 + explanatory error: "linkType refund requires both legs in the same bucket. Cross-bucket refund is incoherent — did you mean rebate or associated?" Cross-bucket refund is logically incoherent (refund is a directional same-bucket pattern). The route also passes leftLeg.kind / rightLeg.kind into linkTwo() so the dual-encoding fires.

v1.92.8

BUG-1 hotfix — /api/transactions/link VALID_LINK_TYPES expanded to all 8 ManualLinkType members (silent dropped-link regression from v1.92.4)

Improved

  • Discovery — flagged during a 2026-05-05 ROADMAP self-audit (cross-checked claimed coverage in docs/plans/ROADMAP.md against actual code). The check that caught it: search for ManualLinkType usages outside src/lib/links.ts to find places where the union might have drifted out of sync. Lapse 3 (refund dual-encoding — linkTwo doesn't co-stamp refundOfExpenseId) was confirmed broken in the same audit but is a separate, larger fix queued for the next v1.92.8 batch.
  • Verification baselines — guard-types 300/300 passing (299 v1.92.7 baseline + 1 new BUG-1 regression guard). vitest unchanged at 536/537 (no new test cases — the API route lacks a Vitest harness; guard-types regex is the equivalent gate). tsc 27 errors held (ZERO new). APP_VERSION bumped 1.92.7 → 1.92.8; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md (cache-nuke required because APP_VERSION span renders sidebar-version-derived hashed asset names).
  • NEXT — Lapse 3 patch (refund dual-encoding: extend linkTwo or import-commit path to set refundOfExpenseId when linkType === 'refund'); Lapse 1 (one-off audit script for pre-v1.92.4 'transfer' rows that retroactively gained nullify behavior); Lapse 4/5/6 (Find-pair toggle on linked rows · session-only dismiss suppression · localStorage swap/sort persistence). All locked for the v1.92.8 batch per Alex 2026-05-05; this v1.92.8 sub-release ships the BUG-1 hotfix first because of its silent-failure profile.

Fixed

  • CRITICAL BUGFIX (severity: high; live since v1.92.4) — /api/transactions/link/route.ts VALID_LINK_TYPES was a 5-member array (fee-reversal / rebate / refund / transfer / other) that pre-dated the v1.92.4 ManualLinkType union expansion to 8 members. Pre-v1.92.8 effect: when a user marked a pair in /transactions/import with linkType ∈ {credit-card-payment, couple-transfer, associated} and committed, the post-commit resolvePendingLinks flush POSTed the type to the API and got back a 400 "linkType must be one of: fee-reversal, rebate, refund, transfer, other". The flush logged this as `skippedCount` (concatenated to the import status string as "(N unresolved)") but never surfaced as a user-visible error. Rows committed with manualLinkType stamped client-side via the import-row build path BUT had no manualLinkPairId server-side because the linkTwo() call inside the API never fired. Result: half-stamped rows where partner-lookup fails, getPartner() returns null, dashboards (Track 1.Y prereq) cannot count CC payments / couple transfers / associated pairs by pairId, isManuallyNullified() still nullifies via manualLinkType but the audit chain is broken.
  • Fix — VALID_LINK_TYPES expanded to enumerate all 8 union members in the canonical order: fee-reversal · rebate · refund · transfer · credit-card-payment · couple-transfer · associated · other. Header doc-comment updated. NEW invariant comment above the const: "MUST match the ManualLinkType union in src/types/index.ts. Drift between this list and the type union is a silent-failure mode (rows commit without manualLinkPairId)." NEW guard-types assertion (#300) regex-checks all 8 values appear in canonical order in the route file — prevents the same drift from recurring. No data migration needed: v1.92.4-v1.92.7 rows that were stamped half-half can be re-paired by the user via the existing /transactions force-link hub (no rows lost, just relinked).

v1.92.7

Manual Find-pair affordance + reversed auto-uncheck (rows commit with manualLinkType for dashboard counting) + AI Classify skip + soft commit warning

New

  • Phase A FEATURE — Manual "Find pair" affordance closes the gap where the smart-suggest engine misses a pair entirely (different amounts, completely mismatched vendors, etc.). Per-row 🔗 button in the destination cell — first click sets findPairFromIdx, banner shifts to "Pick a partner row to link with [Row A]" mode, click 🔗 on Row B opens NEW FindPairTypeModal showing both rows side-by-side with account labels + AI-inferred default type (using the same inferLinkType heuristic as the suggestions banner) + radio for the 8 link types + optional note. On confirm, builds a PendingPairMark with stable date|amount|vendor|owner keys and pushes to pendingLinkMarks — same flow as suggestion-driven marks downstream. Cancel clears state. Closes Alex 2026-05-05 ask: "select any 2 rows directly".
  • Phase B FEATURE / BREAKING — reversed the earlier auto-uncheck behavior on nullifying-marked pairs. A linked pair that doesn't count toward totals (a card payment or an internal transfer) still commits and keeps its link record, just excluded from totals — so a future dashboard could still count how many such pairs occurred and for how much, instead of the link vanishing entirely.
  • Phase B FEATURE — AI Classify pipeline now skips rows that are part of a NULLIFYING manual marked pair. NEW linkedSkipIndices Set computed at the top of the AI Classify entry point from pendingLinkMarks filtered by NULLIFYING_LINK_TYPES; rows in the set are excluded from txList before any /api/ai/classify-* call fires. Saves API cost since these rows commit with manualLinkType + skip aggregates anyway. Per Alex: "associated transactions which dont get skipped should still get AI Classified" — and they do, because associated isn't in NULLIFYING_LINK_TYPES so it falls through to normal classification.
  • Phase C FEATURE — Soft warning banner before commit when high-confidence (≥80%) pair suggestions are unmarked. NEW highConfUnmarkedCount useMemo runs suggestPairs over the current rows (with relaxed scoreFloor 0.5), filters to score ≥ 0.80, then subtracts pairs already in pendingLinkMarks via approximate stable-key match. When count > 0, renders a purple chip near the Import button: "✨ N high-confidence pair(s) unaddressed · Dismiss". Dismiss button clears highConfWarningDismissed for the session; user commits anyway if they're sure. Doesn't hard-block. Closes Alex 2026-05-05 ask: "Soft warning banner before commit (Recommended)".

Improved

  • Verification baselines — guard-types 299/299 passing (294 v1.92.6 baseline + 5 new v1.92.7: FindPairTypeModal component, findPair*Idx + 🔗 button wiring, linkedSkipIndices in AI Classify, highConfUnmarked + warning UI, reversed-auto-uncheck assertion). vitest 536/537 — single pre-existing parser-interpreter.test.ts:164 failure unchanged. tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.6 → 1.92.7; BUILD_HASH cleared (was h1k2c for the v1.92.6 hotfix). ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.8+ candidates: dashboard cards that read manualLinkType frequency from /api/expenses + /api/income (Alex's stated dashboard ask); persist pair-suggester swap/sort UI state via localStorage; expand-panel scoring weights breakdown for transparency. v1.93.0 = downstream audit (Phase E from income plan).

v1.92.6#h1k2c

Hotfix — hook-order bug in ImportSuggestionBanner crashed /transactions/import

Improved

  • Verification — guard-types 294/294 still passing. vitest 536/537 (same pre-existing parser-interpreter:164 failure unchanged). tsc 27 errors held (ZERO new). APP_VERSION unchanged at 1.92.6; BUILD_HASH set to "h1k2c" per AGENTS.md bug-fix-only release semantics. Run `rm -rf .next && pnpm dev` to clear the dev cache (BUILD_HASH change rebuilds the sidebar version span).

Fixed

  • BUGFIX (severity: high) — v1.92.6 placed a useMemo for accountByKey AFTER an early return (`if (suggestions.length === 0 && pendingPairs.length === 0) return null`). React 19 enforces stable hook count across renders; on empty-state renders only 3 useMemos fired, but the moment rows arrived a 4th useMemo fired and React threw "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." Crashed /transactions/import as soon as any CSV was parsed. Fixed by moving the accountByKey useMemo BEFORE the early return so the hook count is stable across renders. No behavioral change; purely an ordering fix.

v1.92.6

Marked pairs persist + smart left/right ordering + account labels + per-row link chip + link-skipped section separation

New

  • Phase A FEATURE — Marked pairs section in the Suggested Pairs banner. Marked pairs no longer vanish from the banner after Mark; they persist in a dedicated "Marked pairs (N)" section below the suggestions list with the same line layout (left ↔ right + type chip + Unmark button). Each marked pair has a type-change dropdown so the user can switch types without unmarking + re-marking. NEW onUpdateType callback on the import page reconciles auto-uncheck/restore based on the nullifying-or-not transition: if old type was nullifying and new isn't → restore include flag on both legs; if old wasn't and new is → capture pre-mark state + uncheck. Closes Alex 2026-05-05 ask: "I want to see which ones I marked and they don't disappear from that section but move to bottom and show as Marked but I can still undo or change how they are marked".
  • Phase B FEATURE — Smart left/right ordering with per-suggestion Swap button. Default heuristic: for nullifying types (refund/rebate/fee-reversal/transfer/CC payment/couple-transfer) the OUTFLOW leg is positioned on the LEFT so the timeline reads naturally — the spend or original action happens first, then the offsetting credit/transfer-target. Same-bucket pairs with negative-on-left + positive-on-right get auto-swapped. Cross-bucket pairs (engine already produces expense ↔ income) keep their default. Each suggestion has a tiny ⇆ icon button (ArrowLeftRight) to toggle the swap; user override beats the heuristic. Closes Alex 2026-05-05 ask: "for credit card payments would have outgoing on left as that happens first... internal transfer and refund / rebate but associated sometimes might be incoming money on left side first."
  • Phase C FEATURE — Account labels now appear on suggestion lines and in the expanded breakdown panel, showing which specific account each leg is coming from or going to (e.g. its nickname or type and last four digits) instead of just a vendor and amount. Closes the ask to see an internal transfer's money leaving one account and entering the other at a glance.
  • Phase D FEATURE — Per-row link-skip chip in import preview. NEW markedRowsByKey useMemo on the import page maps stable row key (date|amount|vendor|owner) → mark info (type, partner label, pairId). Each row in the preview table that's part of a marked pair renders a small inline chip ABOVE the destination dropdown showing the link state: purple "→ rebate · skip" for nullifying types (will be excluded from commit), amber "→ associated · keep" for relationship-only types (still commits with link record). Hover tooltip shows the partner row's full label. Closes Alex 2026-05-05 ask: "I want in the preview part it to also show some indicator of why its skipped, ie. it was a Refund Skip. Can be some icon that is displayed inline."
  • Phase E FEATURE — Link-skipped rows are now grouped at the bottom of the preview table, separated from rows still needing AI classify. filteredRows partitions into [active rows ... linked rows] in that order; sort/pagination still work over the unified array but linked rows are contiguous at the end. NEW divider row "↓ Marked for manual link (N). Nullifying types auto-skip on commit; associated/refund/other still commit but with link." appears at the boundary so the user clearly sees the section change. Closes Alex 2026-05-05 ask: "transactions getting skipped after becoming linked together can be separated from the rest of the transactions which still need an AI classify so they can stay together and be clear they are being skipped".

Improved

  • Verification baselines — guard-types 294/294 passing (288 v1.92.5 baseline + 6 new v1.92.6 covering marked-pairs section, swap button, getAccountLabel callback, onUpdateType wiring, account-label resolver in import page, per-row chip + section divider). Two existing assertions adjusted: Phase 5B title regex relaxed (now matches title with link-mark fallback); v1.92.4 auto-uncheck assertion now checks for NULLIFYING_LINK_TYPES gate + setRows include:false instead of the stale "v1.92.4 — auto-uncheck" comment string. vitest 536/537 — single pre-existing parser-interpreter.test.ts:164 failure unchanged. tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.5 → 1.92.6; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.7+ candidates: dedicated "Find pair" affordance to manually pair two arbitrary unsuggested rows from the preview (currently the suggester drives it; sometimes the user knows two rows are paired but the heuristic missed them entirely); historical sort/swap state persisted to localStorage so it survives reloads; richer expand-panel diagnostics including the actual scoring weights breakdown. v1.93.0 = downstream audit (Phase E from income plan).

v1.92.5

Smart-suggest type-override dropdown + same-source-disqualifies-transfer + CC-metadata steers to credit-card-payment + banner copy fix

New

  • Phase B FEATURE — Per-suggestion type-override dropdown. The static type chip on each suggestion line is now a <select> with all 8 ManualLinkType options. The user starts with the inferred type but can pick any other type from the dropdown BEFORE clicking Mark — useful when the AI guessed wrong. Visual feedback: dropdown is purple+nullify-suffix when selected type nullifies, amber when relationship-only. An "overridden" amber chip appears next to the dropdown when the user picked something other than the suggestion. Mark uses the user-selected effective type. Closes Alex 2026-05-05 ask: "if its wrong with what its serving up then I want to be able to link them to eachother in the correct manner."
  • Phase C FEATURE — Internal-transfer eligibility refined. Per Alex 2026-05-05: "internal transfers are only eligible on items where money is coming out of an account that holds money (savings or chequing) and money moves into another account that holds money — not credit card account; money in+out of the same account is likely associated but not internal transfer." Implementation: (1) inferLinkType now reads sourceFileId on each leg; same sourceFileId between two legs disqualifies the 'transfer' branch (intra-account in+out → defaults to associated for cross-bucket). (2) NEW isCcLeg helper inspects cardBrand + paymentMethod metadata for amex/visa/mastercard/cc; CC involvement on either leg steers inference to 'credit-card-payment' regardless of vendor strings or sourceFileId. (3) link-suggest.ts forwards sourceFileId + cardBrand + paymentMethod from OneTimeExpense + IncomeEntry rows. ImportSuggestionBanner forwards the same from in-batch ClassifiedRow data via rowsToExpenses + rowsToIncome. 4 new vitest cases covering same-source/different-source/cardBrand/paymentMethod paths.

Improved

  • Verification baselines — guard-types 288/288 passing (284 v1.92.4 baseline + 4 new v1.92.5: same-source disqualifies-transfer assertion, isCcLeg + CC_BRAND_VALUES coverage, type-override dropdown + ALL_LINK_TYPES, sourceFileId/cardBrand/paymentMethod plumbing). vitest 536/537 — single pre-existing parser-interpreter.test.ts:164 failure unchanged. 4 new test cases pass (links.test.ts +4: same-sourceFileId → associated, different-sourceFileId → transfer, cardBrand=mastercard → credit-card-payment, paymentMethod=amex → credit-card-payment). tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.4 → 1.92.5; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.93.0 (deferred from v1.92.4): downstream audit (Phase E from income plan), exclude isReimbursement: true rows from /balance + /trends + /insights aggregates, propagate eventId between IncomeEntry.eventId and OneTimeExpense.eventId on link, deferred Phase C edit-surface affordances. Plus per-row inline link chip in the import preview table (currently auto-uncheck signals the skip state but a dedicated chip would be clearer). v1.94.0+ = Phase 7C/7D/4b/4c auto-detection improvements.

Fixed

  • Phase A FIX — Suggested Pairs banner copy updated to reflect all 8 link types. Pre-v1.92.5 the helper text said "Catches refund/rebate/fee-reversal cases the AI missed" — incomplete after v1.92.4 added associated, transfer-as-nullifying, credit-card-payment, couple-transfer. New copy: "Catches refund / rebate / fee reversal / internal transfer / CC payment / couple transfer / associated / other pairs the auto-detectors missed. Pick the right type from the dropdown if our suggestion is wrong, then Mark to auto-link on commit."

v1.92.4

Smart-suggest expansion — new link types (associated, CC payment, couple-transfer), nullify-on-mark + auto-uncheck + click-to-expand confidence

New

  • Phase A FEATURE — ManualLinkType union expanded from 5 → 8 members. NEW values: "credit-card-payment" (paying down a CC balance from chequing/savings — the spend was already counted when the CC was charged, so the payment is internal), "couple-transfer" (Alex ↔ Chelsea accounts — household-internal), "associated" (passthrough pattern: paid for a group, got partial money back — BOTH legs are real, no nullification). Existing: fee-reversal, rebate, refund, transfer (NOW NULLIFIES — was relationship-only in v1.92.0–.3), other. isNullifyingLink updated: fee-reversal + rebate + transfer + credit-card-payment + couple-transfer all nullify; refund stays directional via existing refundOfExpenseId; associated + other stay relationship-only. 11 new vitest assertions covering each type's nullify behavior.
  • Phase B FEATURE — link-suggest.ts inferLinkType heuristic upgraded. Vendor-pattern regex matchers identify CC payments (MASTERCARD/VISA/AMEX/CARD PAYMENT/MC PAYMENT/CC PAYMENT/CREDIT CARD), generic transfers (TRANSFER/E-TRANSFER/INTERAC/ONLINE BANKING/EFT), and rebate signals (REBATE/REFUND/REVERSAL/CASHBACK/MULTIPRODUCT/PROMOTIONAL CREDIT). Cross-owner pairs (Alex ↔ Chelsea) now ELIGIBLE for suggestion (previously rejected); flagged as couple-transfer. Cross-bucket without rebate signal defaults to "associated" instead of always "rebate" — captures the "income from family + a matching e-transfer out to someone else" scenario where both legs are real spending+receiving. Smart suggest banner now shows ALL 8 link types as candidates, not just rebate/refund/fee-reversal. 6 new link-suggest.test.ts cases covering the target patterns.
  • Phase C FEATURE — ManualLinkModal radio options updated to show all 8 types with per-type "nullifies" badge (purple chip on the 5 nullifying options). LinkedPairChip TYPE_LABEL extended for new types ("internal transfer", "CC payment", "couple transfer", "associated"). ImportSuggestionBanner upgraded with two-tier confidence UI: hover tooltip on the % chip shows reason list as a quick scan ("amount equal · same date · cross-owner (alex ↔ chelsea)"); click the chip to expand an inline breakdown panel beneath the suggestion showing left + right leg details (kind chip + vendor + date + amount), full reason list, and nullify-or-keep verdict ("→ Inferred: rebate (both legs auto-skip; excluded from aggregates)" vs "→ Inferred: associated (relationship-only; both legs still count in aggregates)"). Type chip on each suggestion line now color-coded purple (nullifying) vs amber (relationship-only) with " · skip" suffix on nullifying.
  • Phase D FEATURE — Auto-uncheck behavior when marking nullifying pairs on /transactions/import. NEW preMarkInclude state captures the original include flag for both legs at mark time; setRows flips include: false on the two legs so they're visually unchecked AND excluded from commit. Mirrors the existing transfer-pair drawer behavior — both legs preserved in the data model but skipped from commit. On unmark, the original include state restores. Associated pairs DON'T auto-uncheck — both legs commit normally + get linked post-commit via the existing resolvePendingLinks flush. Closes Alex 2026-05-05 ask: "those 2 items in the preview should show as skipped (Refund) ... if I were to mark the income + the matching e-transfer out as associated expenses, then in the preview they should show they are associated".

Improved

  • Verification baselines — guard-types 284/284 passing (279 v1.92.3 baseline + 5 new v1.92.4: ManualLinkType expanded union, isNullifyingLink coverage, inferLinkType heuristic, ImportSuggestionBanner click-expand, import-page auto-uncheck). Two existing v1.92.0 assertions (manualLinkType + LinkedPairLeg/ManualLinkType exported) updated to match the 8-member union. vitest 532/533 passing — single pre-existing parser-interpreter.test.ts:164 failure unchanged. 17 new test cases across links.test.ts (10 new) + link-suggest.test.ts (6 new). tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.3 → 1.92.4; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.93.0 = downstream audit (Phase E from income plan): exclude isReimbursement: true rows from /balance + /trends + /insights aggregates (default yes); propagate eventId between IncomeEntry.eventId and OneTimeExpense.eventId on link; relabel hideReimbursementsFromMainIncome → "Hide reimbursements from Earnings page". Plus deferred Phase C edit-surface affordances from v1.92.2 + v1.92.3 (inline isReimbursement checkbox + EventPicker on /income forms, +Add reimbursement on Event detail, +Promote button on Money-back). v1.94.0+ = transaction-link auto-detection improvements (Phase 7C/7D, 4b heuristic, 4c learning loop closing the manual→auto loop on v1.92.x links).

v1.92.3

Sidebar IA reshape (Income → Earnings + Money back) + import auto-flag for reimbursement rows

New

  • Phase A FEATURE — Sidebar Income section reshape per Alex 2026-05-05 Q3 = "Bundle to 3: A+B+C → D → E". Replaces the flat /income link with a new collapsible "Income" section containing two children: "Earnings" (the existing /income page, relabel only) and "Money back" (relocated from the Expenses section — semantically closer to income since it's about money RETURNING to the user, not new spend). Backward-compat: /income and /money-back routes are unchanged; only the sidebar IA shifted. localStorage state under sidebar.sectionCollapsed.income preserves user collapse preference.
  • Phase B FEATURE — Import auto-flag for reimbursement income rows. NEW src/lib/event-suggest.ts with suggestEventForRow (priority: enclosing date range → nearest within ±N days → status-rank tie-break: active > planned > completed) + shouldAutoFlagReimbursement (predicate honoring Settings). NEW Settings.autoFlagReimbursementOnImport (default true) + Settings.importEventSuggestWindowDays (default 14, range 0-60). On commit, AI-classified income rows with moneyInType="reimbursement" automatically get isReimbursement: true plus a pre-filled eventId from the nearest in-window event. 12 new vitest cases. The /transactions/import page hydrates events + auto-flag settings on initial load (lazily, non-blocking) so the commit pass has them ready. User can disable via Settings.autoFlagReimbursementOnImport=false; window=0 disables eventId pre-fill while keeping isReimbursement auto-flag.

Improved

  • Phase C DEFERRED — Inline isReimbursement chip on import review rows + EventPicker on income forms + +Add reimbursement on Event detail page + +Promote income row button on Money-back > Event reimbursements tab — all deferred to v1.93.0 polish round. The auto-flag mechanism (Phase B) handles the most common case (AI classifies as reimbursement → row auto-flags + auto-suggests event); the manual edit affordances are nice-to-have for edge cases that AI mis-classifies. Workaround for now: PUT /api/income via existing /income edit flow accepts isReimbursement + eventId.
  • Verification baselines — guard-types 279/279 passing (276 v1.92.2 baseline + 3 new v1.92.3: Settings auto-flag fields, event-suggest.ts contract, Sidebar Income section). vitest 519/520 — single pre-existing parser-interpreter.test.ts:164 failure unchanged. All 12 new event-suggest.test.ts cases pass. tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.2 → 1.92.3; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • v1.92.x complete. Across 4 minor releases (v1.92.0 → v1.92.3) shipped: manual force-link UI + API + schema (v1.92.0) → import-preview pair-linker + /transactions smart-suggest + aggregate-nullify-on-rebate (v1.92.1) → income foundation API fix + 3-way Earnings/Rebates/Reimbursements split (v1.92.2) → sidebar IA + import auto-flag (v1.92.3). Total 50+ vitest cases, 20 new guard-types assertions, ZERO new tsc errors across the entire run. NEXT — v1.93.0 = downstream audit (Phase E from income plan): decide whether isReimbursement: true rows are excluded from /balance + /trends + /insights aggregates (default: yes); propagate eventId between IncomeEntry.eventId and OneTimeExpense.eventId when linked; relabel hideReimbursementsFromMainIncome → "Hide reimbursements from Earnings page". Plus the deferred Phase C edit-surface affordances from v1.92.2 + v1.92.3. v1.94.0+ = transaction-link auto-detection improvements (Phase 7C/7D, 4b heuristic, 4c learning loop closing the manual→auto loop on v1.92.0 links).

v1.92.2

Income foundation API fix + 3-way income split (Earnings / Rebates / Reimbursements)

New

  • Phase A FEATURE — POST /api/income now honors `isReimbursement` and `eventId` fields. Pre-v1.92.2 these were silently dropped on POST (only PUT respected them), making the AI auto-flag flow inert: a row that AI classified as `moneyInType: 'reimbursement'` could never be persisted with `isReimbursement: true` from the import path. Server-side warning surfaced in the response when `isReimbursement: true` is set without a corresponding `eventId` (non-blocking — legacy non-event reimbursements still write successfully).
  • Phase A FEATURE — NEW classifier helpers in src/lib/income-types.ts: `classifyIncomeRow(row): 'earnings' | 'rebate' | 'reimbursement'` (precedence: rebate > reimbursement > earnings — manualLinkType=fee-reversal/rebate is more specific than the eventId tag) + IncomeBucket type union + filterIncomeByBucket helper + inferIsReimbursementFromMoneyInType (auto-flag helper for moneyInType=reimbursement). Existing v1.15.5 effectiveMoneyInType + isOtherMoneyIn helpers preserved unchanged. 8 new vitest cases.
  • Phase B FEATURE — /income page now has three tabs: Earnings / Rebates / Reimbursements. URL-synced via ?tab=. Each tab shows its own row count + dollar total in the tab label. Tab classifier feeds off classifyIncomeRow so rows automatically land in the right bucket based on manualLinkType (rebate) or isReimbursement (reimbursement) flags, with everything else falling to Earnings. Page header shows the active tab name next to the title. Existing hideReimbursementsFromMainIncome toggle is now scoped to the Earnings tab — defensively hides rows that ended up classified as earnings but are flagged as reimbursements. Closes Alex 2026-05-05 ask: "Income should distinguish Earnings vs Refund vs reimbursement-passthrough."

Improved

  • Phase C DEFERRED — Inline isReimbursement checkbox + EventPicker on /income forms (add + edit) + "+Promote income row" button on /money-back > Event reimbursements tab — pushed to v1.92.3 to keep release size manageable. The 3-way tab classification + API fix + helper module deliver the user-visible bucket separation; v1.92.3 will polish the edit affordances. Workaround for now: PUT /api/income via existing edit flow respects isReimbursement + eventId.
  • Verification baselines — guard-types 276/276 passing (273 v1.92.1 baseline + 3 new v1.92.2: income-types.ts contract, POST /api/income honors isReimbursement, /income page renders 3-way tabs). vitest 507/508 — single pre-existing parser-interpreter.test.ts:164 failure unchanged. All 8 new income-types.test.ts cases pass. tsc 27 errors held (24 v1.82.2 + 3 pre-existing; ZERO new). APP_VERSION bumped 1.92.1 → 1.92.2; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` per CLAUDE.md.
  • NEXT — v1.92.3 = Sidebar IA reshape (collapsible Income → Earnings + Money back children) + import auto-flag (Settings.autoFlagReimbursementOnImport + event-suggest helper that looks for an Event within ±14d of the row date) + remaining edit surfaces (+Add reimbursement on Event detail page, isReimbursement chip + EventPicker on import review row).

v1.92.1

Smart-suggest pair-linker on /transactions + import preview banner + aggregate nullify for fee-reversal/rebate links

New

  • Phase A FEATURE — a fee charged and then reversed, or a rebate that cancels itself out, can now be marked as a linked pair that's excluded from balance, trend and dashboard totals — since money that goes out and comes right back was never really spent or earned. A plain refund still counts as a directional correction rather than being excluded outright.
  • Phase B FEATURE — Import-preview suggested-pairs banner. NEW src/components/transactions/ImportSuggestionBanner.tsx mounted above the review table on /transactions/import. Runs the smart-suggest engine over in-batch ClassifiedRow data (mapped to OneTimeExpense-like / IncomeEntry-like via destination) and surfaces the top 10 candidate pairs with score chip + auto-detected linkType + "Mark" button. Marked pairs persist in the import page state. After commit succeeds, post-commit hook re-fetches /api/expenses + /api/income, calls resolvePendingLinks() to map marked pairs to freshly-committed row IDs (via stable date+amount+vendor+owner key), and POSTs /api/transactions/link for each — appended to status as "+ N pairs auto-linked" (or "(M unresolved)" for keys that couldn't be located). Failures are non-fatal — they don't hide the import success message. Restores the inline-pair-linker scope that was deferred from v1.92.0 per Alex 2026-05-05.
  • Phase C FEATURE — /transactions force-link hub gains smart-suggest. (1) "Suggested pairs (N)" banner above the table runs suggestPairs() over loaded data — top 10 surfaced with score% chip + reason chip + one-click "Link" opens ManualLinkModal pre-filled with both rows + the inferred linkType. (2) New "Sort: pair score" option in filter bar floats rows with strong unlinked candidates to top. (3) Per-row amber "possible pair" chip on rows that have a high-confidence unlinked candidate elsewhere — clicking the chip opens ManualLinkModal pre-filled with the candidate. NEW src/lib/link-suggest.ts pure helper exporting suggestPairs (composite scoring: 0.45 amount equality + 0.20 date proximity + 0.25 relaxed vendor similarity + 0.10 cross-bucket bonus) + rowHasSuggestion. Catches the four Jan 2026 target cases: bank fee↔fee-reversal, vendor-mismatch refund, cross-bank rebate, generic. 11 new vitest cases.
  • Phase D FEATURE — Settings.linkSuggestRelaxedSimilarity (default 0.30) controls the smart-suggest vendor-similarity floor; below this no vendor signal contributes to score (amount + date + cross-bucket suffice). Settings.nullifyManualFeeReversalRebate (default true) gates the aggregate nullify behavior — set false to treat manual links as relationship-only (legacy). Both fields are on Settings; UI controls deferred to v1.92.x polish — sensible defaults work for Alex's use case.

Improved

  • Verification baselines — guard-types 273/273 passing (267 v1.92.0 baseline + 6 new v1.92.1: Settings.nullifyManualFeeReversalRebate + Settings.linkSuggestRelaxedSimilarity declared, src/lib/links.ts exports isNullifyingLink + isManuallyNullified, src/lib/link-suggest.ts exports suggestPairs + rowHasSuggestion, ImportSuggestionBanner component, sumIncomeAmounts + isManuallyNullified usage in calculations.ts). vitest 499/500 (single pre-existing failure in parser-interpreter.test.ts:164 unchanged from v1.92.0 — cell-extraction test, unrelated). All 22 new tests across links.test.ts (11 new for nullify) + link-suggest.test.ts (11 new) pass. tsc 27 errors (24 v1.82.2 baseline + 3 pre-existing, ZERO new). APP_VERSION bumped 1.92.0 → 1.92.1; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` to clear the dev cache before reload.
  • NEXT — v1.92.2 = Income foundation API fix (POST /api/income accepts isReimbursement + eventId; currently dropped) + 3-way income split on /income (Earnings / Rebates / Reimbursements tabs mirroring /money-back hub) + income-types.ts classifier. v1.92.3 = Sidebar IA reshape (collapsible Income → Earnings + Money back) + import auto-flag (Settings.autoFlagReimbursementOnImport + event-suggest helper) + remaining edit surfaces (+Add reimbursement on Event detail, isReimbursement chip + EventPicker on import review).

v1.92.0

Manual transaction-link UI — force-pair escape hatch for refund/rebate→fee + cross-bank rebates

New

  • Phase A FEATURE — NEW manualLinkPairId / manualLinkType / manualLinkNote / manualLinkedAt schema fields on OneTimeExpense + IncomeEntry. Third orthogonal pair mechanism alongside the existing nullifiedPairId (transfers, deterministic Stage 0) and refundOfExpenseId (vendor-similarity refunds). Captures user-asserted pairings the auto-detectors miss: bank fee↔fee-reversal where vendor strings differ ("NSF FEE" vs "PROMOTIONAL CREDIT"), vendor refunds with mismatched strings, cross-bank rebates (expense leg paired with income leg on different account), arbitrary cases the matcher's vendor-similarity ≥ 0.65 threshold rejects. Shared UUID written to BOTH legs atomically; mirrors nullifiedPairId's shape, distinct from refundOfExpenseId's directional FK. NEW LinkedPairLeg discriminated union + ManualLinkType export.
  • Phase B FEATURE — NEW /transactions force-link hub page. Combined date-desc view of OneTimeExpense + IncomeEntry rows with multi-select (max 2 — adding a 3rd clears selection), filter bar (vendor / description / amount search · owner toggle · link-status filter all/linked/unlinked), "Link selected as pair" button when exactly 2 rows selected, paginated 250 rows initially with "Show 250 more" infinite-scroll. Each linked row renders the new LinkedPairChip inline (purple) with click-to-expand showing partner row vendor + amount + date + note + Unlink button (DELETE /api/transactions/link). New Sidebar entry under Transactions section ("Force-link" with Link2 icon). NEW src/lib/links.ts pure client-safe helpers: linkTwo (atomic mutation, generates UUID, stamps both legs with Toronto-TZ ISO-8601 timestamp), unlinkPair (clears all rows sharing a pairId — defensively handles N-way states), getPartner (resolves partner leg given a row id from either expense or income arrays), inferLinkType (heuristic default for the modal: cross-bucket → rebate, same-bucket opposite-signs ≤ $50 → fee-reversal, larger → refund, else → other), hasManualLink (predicate). 21 new vitest cases.
  • Phase C FEATURE — Header-level discoverability link from /transactions/import to /transactions. Visible at all times (not just post-commit) so users hit it BEFORE giving up on a missed pair. Full inline pair-linker on the import preview (orphan-refund picker + force-link before commit) deferred to v1.92.1 because restructuring the 5800-line import page's in-memory preview state for pre-commit linking is risky; the /transactions hub solves the underlying problem on committed rows. Plan acknowledges Phase C is a UX improvement on top of the core force-link capability.
  • Phase D FEATURE — NEW POST + DELETE /api/transactions/link endpoint. POST takes {left: {id, kind: 'expense'|'income'}, right: {id, kind}, linkType, note?} → returns {pairId, linkedAt}. Validation: rejects self-pair, rejects rows that already carry manualLinkPairId (must DELETE first per atomic contract), rejects unknown linkType. DELETE takes {pairId} and clears all legs. Atomic read-modify-write on data/budget.json oneTimeExpenses[] AND income[] in a single writeDB call. updatedAt bookkeeping touches both legs.
  • ManualLinkModal component (NEW) — overlay dialog showing side-by-side preview of both legs (vendor / amount / date / kind chip / owner), linkType radio with auto-suggested default highlighted (· suggested badge), optional note input (200-char max), Confirm/Cancel buttons, sign-mismatch amber warning when fee-reversal/refund chosen with same-sign amounts. Posts to /api/transactions/link itself; calls onLinked callback with {pairId, linkedAt} on success.
  • LinkedPairChip component (NEW) — compact purple chip rendered on rows with manualLinkPairId. Two variants: "inline" (table-cell click-to-expand) + "block" (vertical-real-estate panel for expand-rows). Shows partner vendor + linkType + note. Unlink button calls DELETE endpoint with pairId. Defensive UI for orphaned link state (partner row not found → amber warning + Unlink still works).

Improved

  • Roadmap reconciliation (docs/plans/enhancement-roadmap-2026-04.md) — appended new "Status update — 2026-05-04 — Parser-architecture pivot + v1.92.0 manual force-link" section preserving all prior content. Documents v1.91.3-v1.91.8 reality (parser-architecture pivot — visualizer + interpreter + wizard + diagnostics + custom bank-keys + parser editing) which superseded the originally-planned 7C/7D/8B/8C/9 transaction-link sequence at v1.91.x. v1.92.0 swap from AI Stage 5 refund (now v1.97.0) to manual force-link justified. Forward queue: Income/Money-back track (v1.92.1 → v1.93.0 phases A→E), Parser-polish queue (v1.93.x patches J + K + L_chips + XLSX upload), Transaction-link / pair-detection track (v1.94.0 → v1.96.0+ preserving every 2026-05-02 plan commitment verbatim). Cross-references to source plans for future implementing chats.
  • Verification baselines — guard-types 267/267 passing (259 v1.91.8 baseline + 8 new v1.92.0 assertions: 3 schema field checks across both interfaces, LinkedPairLeg + ManualLinkType export check, src/lib/links.ts exports check, POST + DELETE route check, ManualLinkModal + LinkedPairChip component checks). vitest 477/478 (single pre-existing failure in parser-interpreter.test.ts:164 unrelated to v1.92.0 work — cell-extraction test for extractedMetadata.cardLastFour). All 21 new links.test.ts cases pass. tsc 27 errors (24 v1.82.2 baseline + 3 pre-existing pre-v1.92 errors held; ZERO new from this release). APP_VERSION bumped 1.91.8 → 1.92.0; BUILD_HASH cleared. ⚠️ MANDATORY: run `rm -rf .next && pnpm dev` to clear the dev cache before the new version's sidebar HTML renders without hydration mismatch (per CLAUDE.md post-version-bump rule).
  • NEXT — v1.92.1 = Income / Money-back foundation + edit surfaces (Phase A + B from income-section plan): POST /api/income fix to accept isReimbursement + eventId, plus 4 edit surfaces (Income page · Event detail · Transactions Import review · Money-back hub). Phase C (auto-flag with autoFlagReimbursementOnImport setting + event-suggest helper) → v1.92.2. Phase D (sidebar IA: collapsible Income → Earnings + Money back) → v1.92.3. Phase E (downstream audit) → v1.93.0. Parser polish (J built-in SkippedRow retrofit + K parser-diagnostics-history + L_chips cell-extraction click-to-edit) queued as v1.93.x patches. Transaction-link auto-detection improvements (Phase 7C/7D + 4b heuristic + Phase 4c learning loop closing the manual→learn loop on v1.92.0's links) queued as v1.94.x+.

v1.91.8

Imports tab UX overhaul — collapse Add Profile + sort+inline Bank Keys + bidirectional links + parser edit/delete

New

  • Phase W FEATURE — Closes UAT bug: the Add Profile owner dropdown was missing Shared and Household options, blocking joint accounts from being tagged correctly; both are now present alongside the two personal owners. The Imports tab is also reordered, and Add Profile is now collapsed by default to a one-line hint, saving vertical space when you're not actively adding a profile.
  • Phase V FEATURE — Bank keys table now sorts by file count descending by default (most-used first) per Alex's sort-by-usage choice 2026-05-04, with a clickable Usage header that toggles to alphabetical name sort. Tie-breakers: file count desc → profile count desc → name asc. Click-popover RETIRED in favor of inline file-list drill-down: clicking the usage chip expands a sub-row beneath each key showing all referencing profiles/parsers (inline chips) AND files (each with linked-profile chip "↳ {nickname}" emerald OR amber "unlinked" badge). Capped at 10 files with overflow. Profile-linkage is computed from TransactionFileRecord.matchedProfileId stamped at parse time.
  • Phase S FEATURE — Bidirectional link visualization between profiles ↔ parsers per Alex's "Both — chip on collapsed view, full section when expanded" choice 2026-05-04. Parser visualizer cards (collapsed) gain a purple "→ N profile(s)" chip when ImportProfile.parserConfigIds[] references the parser. Parser cards (expanded) render a "Linked import profiles (N)" section listing each profile with regex/cardLastFour/owner/bankKey. Known file sources rows (collapsed) show a purple "↳ parser: {nickname}" chip when parserConfigIds is non-empty. Profile edit panel renders a "Linked parsers (M)" section listing each linked parser with bankKey + first 4 headerMatch headers. New "How these work together" explainer block at the top of Parsing logic explains the structural relationship terse: profiles match files by filename regex + stamp owner/last-four; parsers convert rows via column mapping; linked = profile.parserConfigIds[] references parser.
  • Phase T FEATURE — Per Alex 2026-05-04 "Both — inline + Edit in wizard" choice: each user (custom) parser visualizer card's expanded body now renders an "Edit parser" affordance panel with three buttons. (1) "Quick edit" → inline form for nickname / signConvention / dateFormat + Move-to-profile dropdown (per Alex's additional escape-hatch ask 2026-05-04 — lets you re-link a parser to a different ImportProfile or detach). (2) "Edit in wizard" → re-opens the 8-step ParserWizard pre-filled with all state (column mapping, cell extractions, sign/date, skip rules, etc.); jumps to Step 3 (column mapping); save replaces in-place via new replaceParserConfig() helper preserving id+createdAt. (3) "Delete" → window.confirm() lists profiles that reference the parser; on confirm, removes from Settings.parserConfigs[] AND scrubs references from every ImportProfile.parserConfigIds[]. New helpers: deleteParserConfig, replaceParserConfig, moveParserToProfile.

Improved

  • Verification baselines — guard-types 259/259 (250 v1.91.7 baseline + 9 new v1.91.8: 2 Phase W + 2 Phase V + 2 Phase S + 3 Phase T). Existing Phase E "ParserWizard wired" assertion relaxed (onSave moved from a direct ref to an inline router fn that branches between addParserConfig and replaceParserConfig based on wizardEditConfig). vitest unchanged (UI-only). tsc baseline 24 (sandbox can't run pnpm).
  • NEXT — Long-deferred from v1.91.5: built-in parser SkippedRow[] retrofit + parser-diagnostics-history.json (last-import line on each visualizer card) + cell-extraction re-edit in wizard chip click. Plus possibly a v1.92.x XLSX wizard upload to handle AMEX-style preamble files. No firm timeline; user-flagged items take precedence.

v1.91.7

Custom bank-keys + aliases + cascading rename + Re-tag + Set bank-key on parsers

New

  • Phase N FEATURE — Bank-key type relaxed from a literal-union of 18 strings to plain `string` per Alex 2026-05-04 decision. New helper module `src/lib/bank-keys.ts` exports BUILT_IN_BANK_KEYS (the 18 historical literals, kept for autocomplete + documentation), allBankKeys() (built-in ∪ Settings.customBankKeys), resolveBankKey() (alias-chain walker with cycle detection at MAX_ALIAS_DEPTH=10), keysEqualResolved() (alias-aware equality), validateCustomBankKey() (lowercase/dash/no-collision), validateAlias() (cycle detection on save). New Settings fields: `customBankKeys?: string[]` and `bankKeyAliases?: Record<string, string>`. Existing data on disk is unchanged — type safety is the only thing that relaxed. The 4 hardcoded bankKey dropdowns (Settings Add Profile + Settings inline edit + import-page inline edit + ParserWizard Step 8) all now render via allBankKeys() so custom keys appear automatically.
  • Phase O FEATURE — New "Bank keys" collapsible panel between Known file sources and Parsing logic on /configurations?tab=imports. Two sub-sections: (1) "Keys" lists BUILT_IN_BANK_KEYS read-only with a Built-in badge + Settings.customBankKeys with Custom badge + per-key usage chip ("1 profile · 29 files") + click-to-drill-down popover listing the actual references (profile nicknames, parser nicknames, filenames). Custom keys gain Rename + Delete buttons (delete blocked when refs > 0). (2) "Aliases" lists Settings.bankKeyAliases with Add/Delete + cycle detection via validateAlias() at save time. New POST /api/bank-keys/rename endpoint with two-phase flow: dry-run returns row-count summary {profilesUpdated, parserConfigsUpdated, filesUpdated, aliasesRetargeted}; user confirms; second POST applies the cascade ATOMICALLY across Settings (importProfiles + parserConfigs + customBankKeys + bankKeyAliases) AND transactions-index.json. Refreshes txFiles after server cascade.
  • Phase P FEATURE — "Re-tag" button on every Recent Archived Files row in /configurations?tab=imports. Click → inline sub-row form: bankKey dropdown (built-in + custom via allBankKeys), opt-in "Re-tag all N files currently tagged X" checkbox shown when sameBankKeyCount > 1, Apply button gates bulk via window.confirm() with row count. New POST /api/transaction-files/retag endpoint accepts {ids: string[], newKey: string} — single-file (1 id) and bulk (N ids) use the same endpoint. Validates newKey is built-in OR in Settings.customBankKeys; validates all ids exist; updates bankKey on each matched record; writes transactions-index.json atomically. Pairs with Phase O so users can rename a key AND re-tag historical files that should have used the new key all along.
  • Phase R FEATURE — Closes the immediate Tangerine UAT gap (parser created before v1.91.6 had no bankKey field). Custom (user-saved) parser visualizer cards now render an inline "Bank key (metadata)" affordance at the top of the expanded body. When unset: amber "Not set" indicator + "Set bank-key" button. When set: bankKey chip + "Change" button. Click → inline dropdown via allBankKeys + auto-suggest from linked import profile (when ImportProfile.parserConfigIds[] references this parser, default the dropdown to that profile's bankKey). New `updateParserConfig(id, patch)` helper mirrors updateImportProfile (Phase I). Plus: ParserWizard Step 8's "link to existing import profile" select now auto-suggests the picked profile's bankKey when the parser's bankKey field is empty (per Alex 2026-05-04 escape-hatch addition). User can override before saving.

Improved

  • Verification baselines — guard-types 250/250 (240 v1.91.6 + 10 new v1.91.7: 3 Phase N + 3 Phase O + 2 Phase P + 2 Phase R). Existing Phase E "all 18 BankKey enum options" assertion relaxed (the literal-options check moved to a structural `allBankKeys(settings).map` check; original semantics preserved — the dropdown still has all 18+ options at runtime). vitest unchanged (this is mostly UI + schema work). tsc baseline 24 (sandbox can't run pnpm).
  • NEXT — v1.91.8 = Phase S (bidirectional link visualization between profiles ↔ parsers, both as collapsed-view chip + expanded-view section, with a "How these work together" explainer block) + Phase T (full parser-config edit flow — re-opens the wizard pre-filled with all 8 steps from an existing ParserConfig, save replaces in-place + delete with confirmation that scrubs from any ImportProfile.parserConfigIds[] references). ~3h scope. Built-in parser SkippedRow[] retrofit + parser-diagnostics-history.json + cell-extraction re-edit are still deferred from v1.91.5.

v1.91.6

Custom-parser bank-key + edit pencil for Known file sources (closes UAT gaps from v1.91.5)

New

  • Phase H FEATURE — ParserConfig gains an optional `bankKey` field. Wizard Step 8 exposes a Bank-key dropdown bound to the same 18 options used elsewhere. Pre-fill from a built-in descriptor now also seeds bankKey via FORMAT_TO_DEFAULT_BANKKEY (per Alex 2026-05-04 clarifying-round: scotiabank-ultimate → scotiabank-chequing). Per Alex's decision: profile.bankKey wins for row stamping; ParserConfig.bankKey is metadata-only (drives visualizer chip + file-history filter). Closes the UAT gap where a Tangerine custom parser showed `generic` in the visualizer instead of `tangerine-savings`.
  • Phase H FEATURE — TransactionFileRecord gains `format` and `parserConfigId` fields, stamped at parse time (both /api/transaction-files POST and the inbox-analyze route). The visualizer's per-parser file-history filter now: (1) for built-ins, prefers exact format-match (so the Scotia legacy + Ultimate cards split correctly — pre-Phase-H both pulled the same chequing files since both formats resolve to bankKey scotiabank-chequing); (2) for user configs, matches by parserConfigId first, then by parser.bankKey === record.bankKey. Legacy records without `format` stamped fall back to the bankKey-prefix heuristic. Visualizer card header now renders TWO distinct chips: a "format" chip ("file's header shape") and a "bank-key" chip ("canonical bank+product identity"), each with a hover tooltip explaining the distinction.
  • Phase I FEATURE — Pencil-icon edit affordance on every Known file sources row in /configurations?tab=imports. Click → row expands inline below itself with a Save / Cancel form pre-filled with the profile's current values: nickname, filename regex, bank key, card brand, card last four, default owner. Multi-expand allowed via Set<string> so users can edit multiple rows at once. Reuses the v1.91.3 Phase B pattern; adds a new `updateImportProfile(id, patch)` helper to /configurations?tab=imports (mirrors the import-page helper). Closes Alex's UAT gap: "want to edit the Known file source in the settings import tab with a pencil icon to modify the regex, etc."

Improved

  • Plan reference — v1.91.7 is queued and will deliver the custom-bank-keys system (Settings.customBankKeys[] for autocomplete + Settings.bankKeyAliases for rename-with-backward-compat per Alex 2026-05-04 yes-to-aliases) + a "Manage bank keys" panel + Re-tag historical files button. Plan: docs/plans/v1.91.6-v1.91.7-bank-key-and-edit-2026-05-04.md.
  • Verification baselines — guard-types 240/240 (233 v1.91.5 baseline + 7 new v1.91.6: 5 Phase H + 2 Phase I). vitest unchanged. tsc baseline 24 (sandbox can't run pnpm). Chrome UAT: visualizer chips render correctly with tooltips, Tangerine Savings custom parser shows tangerine-savings (not generic), edit pencil opens inline form on every Known file sources row. Phases J/K/L (built-in SkippedRow retrofit, parser-diagnostics-history.json, cell-extraction re-edit) are deferred — not user-flagged and not blocking the v1.91.7 work.
  • NEXT — v1.91.7 = Phase N (custom-bank-keys schema relax + aliases) + Phase O (Manage bank keys panel) + Phase P (Re-tag historical file button). Phase N is a one-way door (loses literal-union TS safety on bankKey, gains string flexibility); existing data on disk is unchanged.

v1.91.5

User-configurable parsers — Phases D+E+F (runtime interpreter · CSV upload wizard · parse diagnostics)

New

  • Phase D FEATURE — Runtime parser interpreter `rowsFromConfig()` in new src/lib/parser-interpreter.ts. Takes a ParserConfig + headers + data rows + raw all-rows, returns `{rows: ParsedRow[], skipped: SkippedRow[], extractedMetadata}`. Implements: column-mapping (case-insensitive), `amount.debit` + `amount.credit` coalesce for split-column banks, `paymentMethod.column` lookup via `paymentMethodMap` with `paymentMethodDefault` fallback, `descriptionMerge` join, sign convention (4 variants: negative-is-debit / positive-is-debit / split-columns / absolute), date format parsing (auto / ISO / DD-MM / MM-DD / etc.), `cellExtractions` with optional regex group capture, `skipRules` with explicit reasons. Wired into `parseBankRecords()` in csv-import.ts as a pre-flight dispatch: when matched ImportProfile carries `parserConfigIds`, score each linked config via `countMatchingHeaders()` (best-match wins per Alex 2026-05-04 clarifying-round) and run rowsFromConfig(); on throw OR 0 rows from non-empty input, silently fall through to the existing BankFormat dispatch — "we can have the current method as a backup if parsing fails." Existing callers without parserConfigs see zero behavior change. New BankParseResult fields: `skippedRows?: SkippedRow[]`, `parserConfigId?: string`. New SkippedRow type {rowIndex, reason, rawRow}.
  • Phase E FEATURE — CSV upload + 8-step wizard in new src/components/imports/ParserWizard.tsx, launched from a "+ Add custom parser" button next to the Parsing logic header. Steps: (1) upload CSV — client-side FileReader, never hits the server; (2) header row picker — show first 50 raw rows, click to mark; (3) column mapping — per-header dropdown with 14 ParserTargetField options + 25-row data preview + auto-suggest based on header literal ("Date" → date, "Amount" → amount, etc.); (4) sign convention + date format + description merge; (5) cell-level extractions — click any cell to extract into cardLastFour / accountHint / dateRangeStart / etc. with optional regex group capture; (6) amount-distribution preview — count, min/max, positive% vs negative%, date range, with sign-convention misconfig warning; (7) skip rules — defaults pre-populated (blank-date + blank-amount); (8) save with nickname + optional pre-link to ImportProfile + "Test parser" button (Alex 2026-05-04 clarifying-round request) that runs rowsFromConfig() against the uploaded sample and shows the resulting ParsedRow[] preview before persisting. Built-in detection: if the uploaded file's headers match an existing BUILT_IN_PARSER_DESCRIPTORS entry exactly, surface a "Pre-fill from {format}" button (Alex 2026-05-04 yes recommendation). New `addParserConfig(cfg, linkToProfileId?)` handler in /configurations?tab=imports that PUTs /api/settings with the new parserConfigs[] + optionally appends to a profile's parserConfigIds[]. Visualizer extended to render Settings.parserConfigs alongside built-ins with an emerald "Custom" badge.
  • Phase F FEATURE — Parse diagnostics surface on /transactions/import. detectedPerFile state extended with `skippedCount`, `skippedReasons` (rowIndex+reason pairs), and `parserConfigId`. Per-file detection summary strip now renders an amber "{N} skipped" chip when a user-config dropped rows + an emerald "custom parser" chip when a config dispatched (vs. legacy BankFormat). Expanded per-file panel surfaces a "Parse diagnostics — X rows produced, Y skipped" amber-card table iterating skippedReasons.slice(0,50) with row# + reason columns. Catches sign-convention misconfigs, header-row misalignment, blank-row noise. Built-in parser retrofit (rowsFromAmex / rowsFromRbc / etc. populating SkippedRow[] instead of silent .filter() drops) is deferred to v1.91.6 — visualizer-side per-parser history (parser-diagnostics-history.json) also v1.91.6.

Improved

  • Schema reminder (already shipped in v1.91.4) — ParserConfig / ParserTargetField / CellExtractionRule / RowSkipRule / SkippedRow types, Settings.parserConfigs?, ImportProfile.parserConfigIds?. The visualizer ships read-only built-ins in v1.91.4; the wizard + interpreter activate them as runtime parsers in v1.91.5. Plan: docs/plans/v1.91.4-parser-visualizer-and-config-2026-05-04.md.
  • Verification baselines — tsc 24 (held since v1.82.2 — sandbox can't run pnpm so untested locally; guard-types regex covers the v1.91.5 surface). vitest 432 v1.91.4 baseline + 14 new tests in parser-interpreter.test.ts (column mapping, split-columns coalesce, absolute sign, descriptionMerge, cell extraction with regex group, scotia-ultimate dateRange mining, skipRules with reasons, blank-date predicate, paymentMethod.column with case variants, positive-is-debit, countMatchingHeaders mode=all + mode=regex, best-match dispatch ordering, zero-amount skip). guard-types 233/233 (224 v1.91.4 baseline + 9 new v1.91.5: 3 Phase D + 4 Phase E + 2 Phase F).
  • NEXT — v1.91.6 = built-in parser retrofit (rowsFromAmex / rowsFromRbc / rowsFromScotiabank / rowsFromScotiabankUltimate / rowsFromTD return SkippedRow[] alongside their ParsedRow[]) + parser-diagnostics-history.json (last 5 imports per parser, surfaced in visualizer card) + cell-extraction re-edit via chip click in the wizard. v1.92.x = XLSX upload support in the wizard (the runtime interpreter already supports headerRowIndex>0 for AMEX-style preamble; just needs the wizard upload path).

v1.91.4

Parsing Logic visualizer + ParserConfig schema foundation (v1.91.4 = Phases A+B+C of the parser-architecture roadmap)

New

  • Phase A FEATURE — Schema for user-configurable parsers. New types in src/types/index.ts: `ParserConfig` (drives column mapping, sign convention, date format, cell extractions, skip rules), `ParserTargetField` (date/amount/vendor/description/paymentMethod.column/etc.), `CellExtractionRule` (for one-off metadata in non-data cells like AMEX preamble or Scotia Filter row-0 date range), `RowSkipRule` (explicit skip-with-reason policy). New optional fields: `Settings.parserConfigs?: ParserConfig[]` and `ImportProfile.parserConfigIds?: string[]` (multi-format-per-account support — one profile can reference multiple configs, runtime auto-picks by header match). Per Alex 2026-05-04: legacy BankFormat dispatch stays as backup if config-based parsing fails or no config matches.
  • Phase B FEATURE — Built-in parser descriptors. New `src/lib/parser-descriptors.ts` exports `BUILT_IN_PARSER_DESCRIPTORS` (5 entries: amex / rbc / scotiabank / scotiabank-ultimate / td) — each is a hand-authored ParserConfig that mirrors the corresponding `rowsFromX()` function in csv-import.ts plus `format`, `sampleHeader`, `sampleRows`, `notes` fields used by the visualizer. Drift guard: new vitest file `parser-descriptors.test.ts` programmatically asserts every column literal the live parser reads via `idx('foo')` is also present in the descriptor's columnMap (catches the failure mode where a parser change adds a column read but the docs aren't updated).
  • Phase C FEATURE — Parsing Logic visualizer in /configurations?tab=imports. New "Parsing logic" card below the existing Imports-tab cards. Each parser renders as an expandable card showing: header expectations (column literals as code chips), column → field mapping (arrow-style two-column), cell-level metadata extractions (when present — e.g. Scotia Ultimate's Filter row-0 → dateRangeStart/dateRangeEnd via regex), sign convention + date format strips, description merge rules (when present), per-row paymentMethod map (when present, e.g. RBC's Account Type column → DEBIT/MASTERCARD), row skip rules with reasons, "Special rules" bullet list (descriptor.notes), and a sample input rows table. Per-parser "Files imported with this parser" sub-section pulls from txFiles state — last 5 by default (filename / imported date / # rows), chevron expand for full breakdown (bank key / card / date range / owner), "View all" link when more than 5 files match. v1.91.4 ships built-ins read-only; user-saved configs (v1.91.5) will render in the same list with edit/delete affordances.

Improved

  • Plan reference — Full v1.91.4 + v1.91.5 architecture documented in `docs/plans/v1.91.4-parser-visualizer-and-config-2026-05-04.md`. v1.91.4 = Phases A+B+C (this release, ~5h scope, visualizer-only). v1.91.5 = Phases D+E+F (runtime interpreter, CSV upload wizard with column-mapping UI + cell-extraction picker + amount-distribution preview, parse diagnostics surface). v1.92.x = XLSX wizard support. PDF parsing deferred indefinitely.
  • Verification baselines — tsc 24 (held since v1.82.2; sandbox can't run pnpm so untested locally — guard-types regex covers the v1.91.4 surface). vitest 432 baseline + 11 new tests in parser-descriptors.test.ts (drift guard, sample-header coverage, getBuiltInDescriptor helper) — sandbox can't run pnpm; tests are authored to TypeScript compile-time correctness via the static imports. guard-types 224/224 passing (213 v1.91.3 baseline + 11 new v1.91.4 assertions: 4 Phase A type checks, 1 Phase B descriptors export check, 6 Phase C visualizer markers).
  • NEXT — v1.91.5 = Phases D+E+F per the v1.91.4 plan doc. Phase D = runtime `rowsFromConfig()` interpreter wired into csv-import.ts:detectSource() with BankFormat fallback. Phase E = CSV upload wizard (8 steps: upload → header detect → column mapping → sign/date → cell extractions → amount-distribution preview → skip rules → save). Phase F = parse diagnostics surface (SkippedRow[] populated by every parser; surfaced in /transactions/import banner + per-parser visualizer card). Open question for then: pre-fill new wizard from a built-in descriptor as starting point — Alex confirmed yes.

v1.91.3

Import-profile UX completion — Phase E Bank Key dropdown in Add Profile form · Phase B edit-profile-inline on matched-profile banner

New

  • Phase E FEATURE — Add Profile form on /configurations?tab=imports now exposes a `Bank key` dropdown alongside Card brand / Card last four / Default owner. Pre-v1.91.3 user-created profiles always had bankKey=undefined while seeded profiles all had it populated — manually-added profiles were second-class. Now bankKey can be set on creation: 9 options (amex / rbc-chequing / rbc-mastercard / rbc-savings / scotiabank-chequing / scotiabank-savings / td-chequing / td-credit / generic). bankKey overrides format-detection ambiguity (e.g. RBC's CSV format covers both chequing AND mastercard; bankKey decides which).
  • Phase B FEATURE — The matched-profile pill on /transactions/import (e.g. "Scotiabank Ultimate (chequing)") is now clickable. Click expands an inline edit panel below the banner with nickname / cardLastFour / defaultOwner / bankKey form fields + Save / Cancel / Delete buttons. Save calls NEW `updateImportProfile(id, patch)` helper that PUTs /api/settings with the new array. Delete asks for confirmation then calls NEW `deleteImportProfile(id)`. updateImportProfile auto-refreshes the banner's nickname label if changed. Pre-v1.91.3 you had to navigate to Settings → Imports to fix small profile errors; now you can edit + save without leaving the import page.

Improved

  • FOLD-IN (Phase E expansion, no version bump) — Bank Key dropdown extended from 9 → 18 options to cover more real-world account types so future manually-added profiles can stamp the right key. Added: rbc-investing · scotiabank-visa (Scene+ Visa) · scotiabank-mortgage · questrade-tfsa · questrade-rrsp · questrade-margin · tangerine-chequing · tangerine-savings · tangerine-tfsa. Dropdown reordered to group by bank (all RBC keys together, all Scotia together, etc.). Both surfaces updated in parallel — Configurations Add Profile form AND import-page inline edit panel — plus the ImportProfile.bankKey type union (src/types/index.ts). New keys do NOT auto-detect: deriveBankKey() in csv-import.ts is unchanged because Questrade/Tangerine CSVs would need their own parsers (deferred). New keys stamp only via manually-created profiles, which is the intended path. guard-types.mjs error message updated 9 → 18 (text only; assertion logic unchanged, 213/213 still passing). tsc/vitest delta: 0.
  • FOLD-IN (Phase B per-file fan-out, no version bump) — Phase B's edit-profile-inline form was wired to a single `detectedSource` (the LAST parsed file), so a multi-file batch on /transactions/import only ever surfaced the matched-profile pill + edit form for one file. The existing v1.90.4 "Uploaded files (N)" strip rows are now click-to-expand: clicking the matched-profile pill (or the row chevron) opens an inline panel beneath that specific file with the Phase B nickname / cardLastFour / defaultOwner / bankKey form fields + Save / Cancel / Delete, the v1.90.3 owner-undetected amber warning + Set-Alex/Chelsea/Shared quick buttons, the v1.89.0 account-override picker, and the v1.90.5 5C "Save profile now" + "Save on commit" affordances — all scoped per file. Multi-expand allowed (multiple panels open simultaneously) so users can line up edits across several files at once. State refactor: editProfileExpanded → expandedFiles: Set<fileRecordId> · saveProfileOnCommit → saveProfileOnCommitFor: Set · sharedOwnerAck → sharedOwnerAckFor: Set · profileEditDraft → profileEditDrafts: Record<fileRecordId, draft>. updateImportProfile / deleteImportProfile / saveAsProfile now fan changes out across every per-file entry that shares the same profile id, so a nickname edit on file 1 immediately reflects on file 3 if they share the profile. CRITICAL commit-time fix: the adhoc / recurring / income / enhance payload builders now resolve owner / cardBrand / cardLastFour / cardmemberCardMap / cardmemberOwnerMap per-row via `detectedFor(r.sourceFileId)` against a per-file detected map, instead of the global detectedSource — pre-fix every row in a multi-file batch was stamped from the LAST file, silently mis-assigning owner when files had different owners (load-bearing for transfer-pair detection's same-owner rule). Post-commit `lastUsedAt` bumping iterates every matched profile in the batch (not just the last file's), and per-file save-on-commit toggles each fire saveAsProfile sequentially. The single big "Detected:" banner is dropped for upload flow (per-file panels cover its functionality); kept simplified as a paste-flow fallback (paste produces no per-file entries). Verified in Chrome with a 3-file batch (RBC Chequing / RBC Mastercard / Scotia Chequing): all three pills clickable, all three panels openable simultaneously, each pre-filled from its own profile.
  • Plan update (Alex 2026-05-04 escape hatch) — Phase 4a manual-link UX scope clarified in ~/.claude/plans/sunny-wibbling-stream.md. The drawer's manual-link affordances MUST cover the full 4-pair-type × cardinality matrix: Transfer / CC Payment / Refund / Associated × 1:1 / 1:N / N:1 / M:N. Each manual click logs to manual-associations.jsonl with full context for the future v1.94.0+ Phase 4c AI training loop. Captures Alex's principle: "always want AI learnings though from any manual input to make it smarter and more accurate."
  • Verification baselines — tsc 24 (held since v1.82.2) · vitest 432/432 (no new tests; both phases are UI-state changes asserted via guard-types regex) · guard-types 213/213 (209 v1.91.2 baseline + 4 new v1.91.3 assertions: Bank Key dropdown render, addImportProfile stamps bankKey, matched-profile pill clickable + edit panel, updateImportProfile + deleteImportProfile helpers).
  • NEXT (v1.91.3 history note) — Original v1.92.0 plan = Phase 7C (flowDirection field on ParsedRow + matcher restructure + internal-transfer guard inline) + Phase 7D (3-button UI restructure: [Detect connections] [Classify the rest] [Detect + Classify]). Phase 7C shipped in v1.92.0 as scoped. Phase 7D (multi-button UI) was carved out in 2026-05-06 review and re-planned as ROADMAP Track 1.AA (target v1.92.22+, source plan docs/plans/v1.92.21-multi-button-classify-2026-05-06.md) — now reshaped to 4 buttons [Transfers] [Destination] [Category] [Detect+Classify]. Then v1.92.1 = Phase 8B + Phase 8C + Phase 9. v1.93.0 = Phase 5 + REBATE refund-pattern signal + Phase 4a + Phase 4b. v1.93.1 = Phase 1 + Phase 2.

v1.91.2

Phase 8A pulled forward — fair dedupe scorer + Link-to-template CTA on review-band recurring matches

New

  • FEATURE (Phase 8A UI) — The "Why this was flagged" breakdown panel now shows null-score factors as strikethrough rows labeled "(no data on existing template)" with their original weight tagged "excluded". Populated factors show effective renormalized weight (e.g. "Amount · ×44% (was 31%)"). One-line summary at the top: "X of Y factors compared · Z excluded (no data on existing template; remaining weights renormalized)".
  • FEATURE (dup→link UX) — NEW emerald "Link to <template> (link + learn)" CTA on the breakdown panel when matchVerdict='review' AND matchDestination='recurring' AND the row isn't already linked. CTA shows the proposed template's L1/L2/L3 breadcrumb so you can confirm at-a-glance. Click routes through the existing v1.84.10 handleAliasYes logic — links the row + adds its vendor to template.vendorAliases (with conflict-check + soft-cap warnings). Closes Alex's "is dup the button I'm supposed to select?" gap. Future imports of the same vendor auto-link silently via the alias matcher.

Improved

  • Verification baselines — tsc 24 (held since v1.82.2) · vitest 432/432 across 24 test files (427 v1.91.1 baseline + 5 new in transaction-match-fairness.test.ts: missing-field lift, symmetric null-on-either-side, all-factors-null score=0, missing-data lifts vs penalizes, backward-compat with all-numeric legacy breakdowns) · guard-types 209/209 (205 v1.91.1 baseline + 4 new v1.91.2 assertions: nullable factors typing, composite reweight logic, breakdown panel UI changes, Link-to-template CTA condition).
  • NEXT (v1.91.2 history note) — Original v1.91.3 plan = Phase E + Phase B (both shipped). v1.92.0 plan was Phase 7C + 7D (3-button UI). Phase 7C shipped in v1.92.0; Phase 7D (multi-button UI) carved out 2026-05-06 and re-planned as ROADMAP Track 1.AA, target v1.92.22+ — now 4 buttons [Transfers] [Destination] [Category] [Detect+Classify]. Sequence updated in ~/.claude/plans/sunny-wibbling-stream.md ACTIVE PLAN.

Fixed

  • BUGFIX (Phase 8A) — Dedupe scorer no longer penalizes the incoming row when the existing template has missing fields. Pre-v1.91.2 the scorers returned a hardcoded 0.3 / 0.0 partial penalty whenever EITHER side had no data, dragging composite scores down unfairly. Worked example from Alex's UAT: a gym-membership row scored 59% with Vendor 0% / Payment Method 30% / Card last 4 30% / Description 0% — all dragged down because the existing template was incomplete, not because the row was a poor match. Post-fix: `TransactionMatchBreakdown` factor fields are now `number | null`; scorers return null on missing data; `composite()` filters null factors and renormalizes the remaining weights to sum to 1.0. That case lifts from 59% → ~78%. Pulled forward from v1.92.1 because Alex flagged it during v1.91.1 UAT.

v1.91.1

Alias learn-loop completion — §12.9 Case 3 full-clobber fix · Yes-click auto-fills L1/L2/L3 · 3-way skip rescue · borderline banner shows proposed categories

New

  • FEATURE (Issue A) — Skip drilldown's click-to-expand rescue UI now offers 3 destination buttons (Adhoc / Recurring / Income) under "Override →" instead of the single v1.90.8 8G "Override → Ad-hoc" button. Each button sets destination + include=true + classificationSource='manual' so AI Classify won't overwrite. For Recurring picks the user picks a template via the inline dropdown after; alias-match may auto-link if a template alias matches.
  • FEATURE (Issue C) — Borderline alias-match amber banner now shows the proposed template's L1/L2/L3 breadcrumb alongside the name + confidence. Pre-v1.91.1 the banner only said "Looks like your hydro provider? (84% confidence — exact alias)"; now it also shows "→ Recurring · Utilities · Electricity / Hydro · Heating" so the user sees what would be set if they click Yes. Pairs with Issue D's category-copy: clicking Yes immediately materializes the categories shown in the preview.

Improved

  • Verification baselines — tsc 24 (held since v1.82.2) · vitest 427/427 across 23 test files (424 v1.91.0 baseline + 3 new in auto-link-reconciler.test.ts for Case 3 full-state-clobber + FK-pointing-at-different-template + transfer-pair precedence) · guard-types 205/205 (201 v1.91.0 baseline + 4 new v1.91.1 assertions for Case 3 helper logic, Issue D Yes-click categories, Issue A 3-way rescue, Issue C breadcrumb).
  • NEXT — v1.91.5 = Phase E (Bank Key dropdown in Configurations Imports tab) + Phase B (edit-profile-inline on import-page banner) per ~/.claude/plans/sunny-wibbling-stream.md ACTIVE PLAN. Then v1.92.0 (Phase 7C + 7D — flowDirection matcher restructure + 3-button UI). Transfer Pairs UI contrast pass stays in v1.92.1 Phase 8C.

Fixed

  • BUGFIX (§12.9 Case 3) — v1.91.0's reconciler only caught the FK-survives drift shape (`dest undefined + FK matches`). Real-world UAT showed the FULL-clobber shape: green "Auto-linked to Cell Phone" banner visible but dropdown empty AND template empty AND categories empty. Helper now unifies Case 1 + Case 3 under `dest === undefined && (fkMatches || fkAbsent)` and restores destination='recurring' + recurringExpenseId + categories from the template + classificationSource='manual'. Skips when FK points at a DIFFERENT template (user manually re-routed). 3 new vitest cases lock in the behavior.
  • BUGFIX (Issue D) — Borderline alias-match Yes-click now auto-fills L1/L2/L3 from the template. Pre-v1.91.1 clicking "Yes (link + learn)" stamped only destination + recurringExpenseId + classificationSource, leaving categories empty until AI Classification ran. handleAliasYes now mirrors the silent auto-link path's `r.X || template.X || r.X` triple-fallback for category/subCategory/subCategory2. Closes Alex's "we know the categories so UI should show that and no AI is needed" gap.

v1.91.0

Phase 7A + 7B — TxType `'unclassified'` extension · template-matcher lift · §12.9 destination-clobber bugfix

New

  • Phase 7A FEATURE — Extended TxType union to include `'unclassified'` as the explicit pre-classification state. Pre-v1.91.0 every parse-time row defaulted to `'adhoc'` via the renderer's `?? 'adhoc'` fallback, which caused 48 of 79 rows to LIE about being Ad-hoc when their underlying classification was actually undecided. New 5th value flows through the dropdown (greyed-italic "— pick —" placeholder option), TX_TYPE_LABELS palette, and downstream UI surfaces. Matches Alex's principle: "Ad-hoc only appears when classification has actively decided that — never as a silent default."
  • Phase 7A FEATURE — Toolbar selection-breakdown line now surfaces an Unclassified bucket alongside Recurring / Ad-hoc / Income / Skip totals. Rendered FIRST in amber to draw attention since it represents the work the user still owes the importer. Disappears once Classify the rest is run or each row is set manually.
  • Phase 7A FEATURE — NEW hard-block commit guard refuses commits while any included row is still Unclassified. NEW state `commitBlockReason` holds the inline amber banner string beside the Import button: "N rows still Unclassified — click \"Classify the rest\" to run AI Classify or set them manually before committing." Forces classification before save; prevents accidental commits of undecided data into budget.json.

Improved

  • Phase 7B REFACTOR — Extracted the alias-auto-link reconciler logic to a NEW pure helper `src/lib/auto-link-reconciler.ts → computeAutoLinkRowFixes(rows, autoLinkedRows, recurringTemplates)`. Logic that was inline in the import/page.tsx useEffect is now unit-testable in isolation (14 new vitest cases). The useEffect becomes a thin call-helper-and-apply-patches wrapper. Lifts template-matcher behavior to its own discoverable pre-AI step per the Phase 7 vision.
  • Verification baselines — tsc 24 (held since v1.82.2) · vitest 424/424 across 23 test files (410 v1.90.9 baseline + 14 new in src/lib/__tests__/auto-link-reconciler.test.ts covering empty inputs, §12.9 destination-clobber drift, original FK-cleared drift, user-override path, transfer-pair precedence, multi-row determinism) · guard-types 201/201 (195 v1.90.9 baseline + 6 new v1.91.0 assertions; 1 v1.90.5 5D legacy assertion updated to point at the new helper call).
  • NEXT — v1.91.5 = Phase E (Bank Key dropdown in Configurations Imports tab) + Phase B (edit-profile-inline on import-page banner) — small UX cleanups before architectural Phase 7C/7D in v1.92.0. See ~/.claude/plans/sunny-wibbling-stream.md ACTIVE PLAN section for the full v1.91.0 → v1.95.0 sequence.

Fixed

  • BUGFIX (§12.9) — Template auto-link banner fires but row destination stays Ad-hoc / categories empty. Two screenshot rows (a gas bill and a cell-phone bill) showed the green "Auto-linked to <template>" banner BELOW each row but the dropdown still showed Ad-hoc with empty categories. A downstream writeback was clobbering destination back to undefined while leaving the FK in place; the existing reconciler bailed when destination drifted away from 'recurring'. The new computeAutoLinkRowFixes helper now emits TWO fix kinds: `restore-fk` (the original v1.90.5 5D case) AND `restore-destination` (NEW — when destination + txType are both undefined but the FK still matches autoLinkedRows.get(i).templateId, restore destination='recurring' + categories from the template + classificationSource='manual'). The helper SKIPS rows with transferPairId set (pair logic wins) AND respects user-override (setDestination clears the FK on non-recurring picks; helper detects that and bails). Severity: HIGH — bug caused legitimate recurring rows to commit as Ad-hoc with empty categories.

v1.90.9

Import-correctness surgical bundle — RC-4 classificationSource stamp · Save-profile-now button · regex extension auto-strip · quick-set-owner inline buttons

New

  • Phase A FEATURE — NEW "Save profile now" button on /transactions/import next to the existing v1.90.5 5C "Save as profile on commit" checkbox. Calls saveAsProfile() directly so you can persist the regex+account mapping as a reusable ImportProfile WITHOUT first committing the import. Useful for verifying the import preview (e.g. transfer-pair detection accuracy) before deciding whether to commit. Banner flips to the green matched-profile pill on save, hiding both the button and the checkbox.
  • Phase F FEATURE — ImportProfile filename regexes now auto-strip canonical importable extensions (.csv / .tsv / .xls / .xlsx / .pdf, case-insensitive) before testing. NEW exported `SUPPORTED_EXT_RE` constant centralises the list. Authors can write `^.*Ultimate.*Package.*1234.*$` instead of `^.*Ultimate.*Package.*1234.*\.csv$`. Backward-compat preserved via dual-test pattern (`re.test(nameForRegex) || re.test(name)`) — every existing seeded extension-aware regex (`^RBC.*Chequing.*\.csv$`, `^AMEX.*Statement\.xlsx?$`) still matches without migration. Same dual-test applied to parseBankRecords() headerRowHint resolution. Closes Alex's Scotia regex-fail bug.
  • Phase D FEATURE — Quick-set-owner inline buttons in the v1.90.3 owner-undetected warning banner. Three buttons: [Set as Alex] [Set as Chelsea] [Set as Shared]. Alex/Chelsea stamp `defaultOwner` on detectedSource AND auto-arm the v1.90.5 5C "Save as profile on commit" toggle so future imports of matching files auto-detect. Shared dismisses the warning via a local UX-only `sharedOwnerAck` state flag (since ImportProfile.defaultOwner is the narrow Owner = 'alex' | 'chelsea' type and persisting "shared" there would need an architectural widening, deferred). Removes dropdown-hunt step for the common owner-labeling case.

Improved

  • Phase F UI ENHANCEMENT — Add Profile form (Settings → Imports) regex placeholder updated to drop the trailing extension; new helper text below the input lists the auto-stripped extensions inline; the "Test against filename" preview now surfaces 3 distinct states — green ✓ Matches (extension stripped), green ✓ Matches (regex includes extension — both styles supported), or red ✗ no-match with the stripped form displayed in monospace so authors can see exactly what their regex needs to target.
  • Verification baselines — tsc 24 (held since v1.82.2) · vitest 410/410 passing across 22 test files (400 v1.90.8 baseline + 10 new tests in src/lib/__tests__/csv-import-detect-source.test.ts covering the SUPPORTED_EXT_RE export, dual-test matching for Alex's exact Scotia regex, backward-compat for seeded extension-aware regexes, no-extension files, unsupported-extension files like .json, and the legacy-form Scotia regex with `\.csv$`) · guard-types 195/195 (189 v1.90.8 baseline + 6 new v1.90.9 assertions covering Phase 0 stamp + Phase A button + Phase F constant + dual-test pattern + Phase F UI import + Phase D 3-button widget + sharedOwnerAck state).
  • NEXT — Continue v1.91.x sequence per the active plan: v1.91.0 = Phase 7A + 7B (TxType extension + template-matcher lift) → v1.91.1 = Phase 7C (flowDirection + matcher restructure + internal-transfer guard) → v1.91.2 Phase 7D (3-button UI restructure) → v1.91.3 Phase 5 (inline pair indicator + REBATE refund-pattern enhancement) → v1.91.4a/b/c (manual N-way pair UX + heuristic auto-detect → FUTURE embedding/learning loop) → v1.91.5 Phase 1 → v1.91.6 Phase 2 (optional) → v1.91.8 Phase E + B (+ Phase C optional). v1.92.0 inherits original v1.91.0 scope (AI Stage 5 + shared-owner widening + "Not mine" flag) once Q1 2026 imports cleanly.

Fixed

  • Phase 0 BUGFIX (RC-4) — Stage-1 AI-classify-type writeback now stamps `classificationSource: 'ai' as const`. Pre-v1.90.9 the field was omitted, so deriveSkipReason() fell through to "Parser hint: not a typical expense or income row" for every AI-skipped row instead of correctly attributing them to "AI Classify flagged as skip". Three-line fix at page.tsx:2493-2501; unblocks Phase 5 inline-chip rendering coming in v1.91.3 and corrects the existing Skip-reason tooltip. See docs/transfer-pair-detection-diagnostic-2026-05-02.md §12.6 for the canonical RC-4 trace.

v1.90.8

Pair-type fixes — refund classifier tightened · NEW Associated 4th type for passthrough flows · diagnostics panel for near-miss pairs · skip-reason drill-down with quick override

New

  • 8B FEATURE — NEW 'associated' 4th pair type. PairType union extended from 3 to 4 members. Associated covers the passthrough pattern Alex described: same-account same-amount opposite-sign with no vendor signal — money came IN to chequing (e-transfer from family / refund deposit / savings move) AND went OUT same day to fund something else. Both legs are REAL transactions and the pair link is contextual only.
  • 8C FEATURE — Associated pairs commit normally — no destination/include flip. Critical correctness: same-batch matcher branches on type so transfer / cc_payment / refund pairs flip both legs to skip (existing behavior) but associated pairs stamp transferPairId / reason / type for context only and PRESERVE the original destination + include state. Cross-batch flow mirrors: associated pairs from /api/transfer-pairs/candidates skip auto-nullify AND skip the destination flip.
  • 8D FEATURE — Drawer renders amber Associated chip + "both commit" status badge alongside the existing Transfer / CC Payment / Refund chips. Type-breakdown header chip line includes an Associated count when present. Same treatment in cross-batch pair cards.
  • 8F FEATURE — NEW "Diagnostics — N near-miss candidates" panel in the Transfer Pairs drawer footer, listing pair candidates that almost matched with the specific rejection reason (amount just outside tolerance, a card outflow blocked, or a missing payment method). Helps you see why an expected CC payment, transfer or refund didn't auto-pair — a common real-world case this surfaces.
  • 8G FEATURE — Skip-reason summary drill-down with quick override. Each excluded-reason category in the toolbar breakdown line (`N duplicate`, `N transfer-pair leg`, `N classified skip`, `N manually unchecked`) is now click-to-expand, surfacing the actual rows in a max-h-48 scrollable list with quick "Override → Ad-hoc" buttons that flip the row out of skip in one click. List capped at 25 rows.

Improved

  • 8E ENHANCEMENT — /api/transfer-pairs/nullify rejects type='associated' (HTTP 400). Server-side defense-in-depth: even if a buggy client mistakenly sends the call, the server refuses since associated legs link real transactions. Client threads the type field through to /nullify so the guard can fire on any future codepath.
  • Verification baselines: tsc 24 (unchanged) · vitest 400/400 passing across 21 test files (398 v1.90.7 baseline + 2 new transfer-pairs-types cases for prefix-match refund signal + Alex-flagged false-positive associated routing; the seasonal date-drift flake from v1.90.5/7 also resolved itself this run) · guard-types 189/189 (182 v1.90.7 baseline + 7 new v1.90.8 assertions; one v1.90.6 assertion retired in favor of v1.90.8 form).
  • NEXT — End-to-end manual verification of v1.90.5–v1.90.8 on real data: re-test the drawer with an import (expect Associated bucket on those three passthrough cases instead of Refund), open the Diagnostics panel to see why MC payments didn't pair, click the breakdown drill-down to rescue any false-positive skips. Once verification looks clean, commit the data — then v1.91.0 AI Stage 5 + shared-owner widening + "Not mine" flag begins.

Fixed

  • 8A BUGFIX — The refund classifier's same-day fallback was over-firing on generic vendor strings on the same statement, wrongly labelling ordinary passthrough transactions as refunds. It now also requires a real name match — either strong vendor similarity or a shared prefix/suffix — before calling something a refund.

v1.90.7

Recurring detection — per-row Mark-as-recurring modal + batch-level candidate banner · auto-alias-match other batch rows after template create

New

  • 7A FEATURE — NEW src/components/shared/QuickRecurringForm.tsx renders a modal popover that creates a RecurringExpense template from a single import-page row in 1-2 clicks. Pre-fills name (vendor) / amount / category L1·L2·L3 / owner / paymentMethod from the row, with editable frequency (default monthly) + type (default subscription). Submits to POST /api/recurring with vendorAliases: [row.vendor] seeded so future imports auto-link. On success, the source row updates to destination=recurring + recurringExpenseId, and the local recurringTemplates pool refreshes so subsequent batch rows can alias-match. New per-row Repeat icon button on the destination cell triggers the modal — only renders when destination is not already recurring/skip and no template is linked yet.
  • 7B FEATURE — NEW batch-level recurring candidate banner above the review table. Detects within-batch patterns by grouping unmatched non-skip rows by (normalized vendor + amount bucketed to nearest $2 + owner). Buckets with 2+ rows render as click-to-open chips (vendor · $X · 3×) that seed the QuickRecurringForm with the bucket's first row. After template creation, the OTHER rows in the bucket alias-match silently via v1.84.10 alias-matcher (which re-fires on recurringTemplates change). Caps at 8 chips with overflow text directing to the per-row button.

Improved

  • 7C VERIFICATION — confirmed v1.89.5 vendor-alias-growth on manual template pick still works end-to-end after v1.90.5/v1.90.6 edits. When the user picks a template from the inline dropdown, the row's vendor is dedup-checked against existing aliases and PUT to /api/recurring if new. Added a guard-types regression assertion.
  • Verification baselines: tsc 24 (unchanged) · vitest 397/398 passing (unchanged from v1.90.6; 1 pre-existing seasonal flake unrelated; v1.90.7 is UI-only + small route extension, no new test cases needed) · guard-types 182/182 (177 v1.90.6 baseline + 5 new v1.90.7 assertions).
  • NEXT — All v1.90.x correctness work is complete (parser counter-leg fix → per-file detection → import quick wins → unified pair architecture → recurring detection improvements). Time for end-to-end manual verification on real Q1 2026 data before the v1.91.0 AI Stage 5 + shared-owner widening + "Not mine" flag scope kicks off.

Fixed

  • POST /api/recurring now preserves vendorAliases (and provider) from the request body. Without this, QuickRecurringForm's vendorAliases seed would be dropped and a follow-up PUT would be needed.

v1.90.6

Unified pair architecture — Transfer / CC Payment / Refund types with paymentMethod direction rules · closes the store-charge ↔ e-transfer false positive · same-source refund-pair codepath

New

  • 6A FEATURE — NEW PairType discriminator (`transfer | cc_payment | refund`) on every pair the matcher returns. Drawer + cross-batch API + jsonl audit log all carry the type so each pair is rendered + categorized accordingly. Legacy nullifiedReason strings stay parseable (drawer falls back to "transfer" when type is missing).
  • 6C FEATURE — Refund-pair codepath: same-file legs are now allowed to pair when they share a payment method and either a similar vendor name or an exact same-day amount match. Catches a same-day fee-and-rebate pair, a store charge and refund on the same statement, and any vendor-initiated refund on the same instrument. Without that signal, a same-amount opposite-sign pair on one file is treated as coincidental.
  • 6F FEATURE — NEW src/lib/__tests__/transfer-pairs-types.test.ts with 13 vitest cases locking the architecture: false-positive blocked, refund pairs match (vendor + same-day variants), CC payments work, missing paymentMethod handled conservatively, legacy DEBIT↔DEBIT pairs still work.

Improved

  • 6D ENHANCEMENT — Transfer Pairs drawer now shows a per-card type chip (purple "Transfer" / blue "CC Payment" / emerald "Refund") + a breakdown summary line in the drawer header counting pairs by type. Both same-batch (v1.90.0) and cross-batch (v1.90.1) cards respect the chip rendering. Full subsection-with-headers refactor deferred to a polish patch if needed.
  • 6E ENHANCEMENT — Cross-batch scanner POST /api/transfer-pairs/candidates extended: accepts paymentMethod in BatchRowProjection, projects DB-side cardBrand into PairCandidate.paymentMethod, threads type through to the response. Same-batch matcher in /transactions/import rewritten with the unified architecture inline (paymentMethod direction + refund codepath + transferPairType stamping on matched rows).
  • Verification baselines: tsc 24 (unchanged) · vitest 397/398 passing (384 v1.90.5 baseline + 13 new pair-type cases; 1 pre-existing seasonal flake unrelated) · guard-types 177/177 (170 v1.90.5 baseline + 7 new v1.90.6 assertions).
  • NEXT (v1.90.7) — Recurring detection improvements: per-row "Mark as recurring" inline action + batch-level candidate banner running the v1.84.1 detector against the import batch. Then v1.91.0 → AI Stage 5 + shared-owner widening + "Not mine" flag.

Fixed

  • 6B BUGFIX — closes a false-positive: a store card charge was pairing with an unrelated same-amount e-transfer deposit because the matcher only checked owner, amount, date and opposite sign. It now also checks payment method — a card outflow is real spending and is never treated as a transfer; only a debit or cash outflow can pair as one.

v1.90.5

Import quick wins — sortable column headers · skip-reason visibility · auto-link template + banner sync fix · pre-commit Save-as-profile toggle on account override

New

  • 5A FEATURE — Sortable column headers in the import review table. Click any header (Date / Amount / Vendor / Source / Destination / Category / Sub) to set primary sort ascending; click again to flip dir; click a third time to clear. Shift+click sets a secondary sort key with a small "1"/"2" ordinal badge surfaced when both keys are active. Default insertion order (Source then Date by file drop) preserved when sortKeys is empty.
  • 5B FEATURE — Skip-reason visibility on the import review table. Closes the "why are 28 of 194 rows excluded?" transparency gap. New deriveSkipReason helper returns a human-readable string per row (transfer-pair stamp > duplicate verdict > AI/parser hint > manual exclusion). Three surfaces: title attribute on each `<tr>` (hover anywhere on the row to see the reason), toolbar "Show/Hide N skipped rows" filter chip, and the existing per-row destination dropdown for one-click override.
  • 5C FEATURE — Pre-commit "Save as profile on commit" toggle on the account-override picker. v1.89.0 picker existed but didn't persist; now when user picks an account on a file with no matched profile, a checkbox auto-flips ON inline. On commit success, saveAsProfile() runs immediately, persisting an ImportProfile with format/cardBrand/cardLastFour/defaultOwner so the next quarter's upload of a matching filename auto-stamps owner without re-asking.

Improved

  • 5E ENHANCEMENT — Auto-link banner now shows the category breadcrumb alongside the template name. Alex flagged: "BILL PAYMENT TELU" banner said "Auto-linked to Internet, TV & Security" while the L1 column showed "Telecom, TV & Security" — confusing because banner shows the TEMPLATE NAME (FK target) and category column shows L1 (rollup destination). Format now: "Auto-linked to **Home Internet** → Telecom · Home Internet (94% · exact alias)". Disambiguates the relationship.
  • Verification baselines: tsc 24 (unchanged) · vitest 384/385 passing (1 pre-existing system-date-dependent flake in seasonal.test.ts > deriveProfileFromHistory unrelated to v1.90.5; logged for separate cleanup) · guard-types 170/170 (165 v1.90.4 baseline + 5 new assertions covering SortableTh, auto-link reconciler, banner breadcrumb, skip-reason filter, save-on-commit toggle).
  • NEXT (v1.90.6) — Unified pair architecture (transfer / cc_payment / refund types with paymentMethod direction rules). Closes the false-positive Alex hit (a store MC charge ↔ an e-transfer that paired wrongly). Then v1.90.7 → recurring detection improvements; then v1.91.0 → AI Stage 5 + shared-owner widening + "Not mine" flag.

Fixed

  • 5D BUGFIX — Auto-link template sync. Alex flagged: an internet-provider bill-payment row showed destination=Recurring + green banner "Auto-linked to <the gas template> (85% confidence · exact alias)" but the inline "— link template —" dropdown stayed empty. Two-part fix: (a) display fall-through — dropdown reads `r.recurringExpenseId || autoLinkedRows.get(i)?.templateId || ""` so banner + dropdown always show the same template; (b) reconciler useEffect writes the auto-linked templateId back to the row when destination=recurring and recurringExpenseId is missing, so commit serializes the FK correctly.

v1.90.4

Per-file detection summary strip on Transaction Import — every file in a multi-file batch now shows its filename · format · owner · profile match · row count

New

  • FEATURE — NEW compact per-file summary card above the existing single detection banner on /transactions/import. When uploading multiple CSVs, each file now shows: filename · institution (RBC / Scotiabank / AMEX / TD) · ····last4 · detected owner (Alex / Chelsea / amber "owner undetected") · ImportProfile match (green pill with profile nickname OR grey "no profile") · row count.

Improved

  • Implementation: new `detectedPerFile` state array populated in a local accumulator during the upload loop and committed in a single setState call at the end (avoids N re-renders). Cleared at start of new batches + after commit so the strip shows only the current batch's files. Existing account-override picker stays as-is (operates on the "active" / last file's detectedSource — can be split per-file in a future iteration if needed).
  • Verification baselines: tsc 24 (unchanged) · vitest 385/385 (unchanged — UI-only change, no new cases needed) · guard-types 165/165 (164 v1.90.3 baseline + 1 new v1.90.4 assertion locking the `detectedPerFile` / "Uploaded files" render pattern).

Fixed

  • BUGFIX — Prior to v1.90.4, a 2-file batch overwrote the single `detectedSource` state with only the LAST file's detection (`setDetectedSource(lastSource)` at end of upload loop). Users saw "Parsed 151 rows from 2 files" but only ONE file's Detected + Owner banners in the UI — the other file's format / profile / owner stamp was invisible. The per-file accumulator closes that visibility gap.

v1.90.3

Parser counter-leg fix — AMEX / RBC / Scotia legacy / TD no longer drop transfer counter-legs at parse time · owner-undetected warning banner on import · unblocks chequing↔card + cross-account transfer-pair detection

New

  • P0a FEATURE — AMEX parser: `include: true` always. Negative-amount rows (payments-to-card) stamp `suggestedDestination: 'income'`, `transferCandidate: true`, `transferCandidateSourceHint: 'Chequing (unconfirmed)'`, `incomeCategory: 'Transfers & Refunds'`, `incomeSubCategory: 'Internal Account Transfer'`, `moneyInType: 'other'`. Vendor becomes 'AMEX Payment' for identifiability in the review table before the pair matcher nullifies the pair.
  • P0b FEATURE — RBC parser: `include: true` always. Positive-amount rows stamp `suggestedDestination: 'income'` + `transferCandidate: true`. RBC MC positive rows (payments received from chequing) additionally get `transferCandidateSourceHint: 'Chequing outflow (unconfirmed)'` + pre-classified as Internal Account Transfer. Chequing inflows (deposits, paychecks, e-transfers received, vendor refunds to chequing) left uncategorized so AI classify-income picks paycheck vs transfer vs refund from context.
  • P0c FEATURE — Scotia legacy parser (Date/Description/Debit/Credit/Balance format): `include: amount !== 0` accepts both debits and credits. Credit-only rows stamp `suggestedDestination: 'income'` + `transferCandidate: true`. Income category left unset (legacy format doesn't separate verb from counter-party); AI classify-income resolves from description context. Achieves parity with the v1.88.0 Scotia Ultimate parser which already included deposits.
  • P0d FEATURE — TD parser: `include: amount !== 0` accepts both withdrawals and deposits. Deposit-only rows stamp `suggestedDestination: 'income'` + `transferCandidate: true`. Income category left unset; AI classify-income decides from description context. Zero-amount rows still dropped (protects against malformed source data).
  • P0e FEATURE — NEW src/lib/__tests__/csv-import-counter-legs.test.ts with 12 vitest cases across the 4 fixed parsers. Asserts `include: true` on counter-leg rows + correct stamps + primary-leg rows remain untouched (no accidental income stamping on outflows). Brings vitest suite from 373 → 385 passing.
  • P1 FEATURE — Owner-undetected warning banner on /transactions/import. Amber banner renders when `detectedSource` resolves a format but `defaultOwner` is undefined. Without this banner, rows silently imported with `owner: undefined` and the v1.90.x transfer-pair matcher's same-owner rule skipped them (silent-correctness failure class bug). Banner pairs cleanly with the v1.89.0 account-override picker already on the page — directs the user to pick an account to stamp an owner before committing.

Improved

  • P1b AUDIT — `Settings.importProfiles[]` audit found an empty array in the persisted database. Scotia Ultimate profile seed mentioned in v1.88.0 release notes was not persisted to data/budget.json (may have been in-memory seed that never reached disk, or was cleared between chats). No remediation needed now since there are no profiles to repair. The v1.90.3 P1 warning banner handles the forward-looking case.
  • SCOPE — This release is intentionally narrow. v1.91.0 will run its own clarifying round covering AI Stage 5 semantic refund detection + shared-owner rule widening for a joint mortgage account + "Not mine" flag schema (owedBy + owedByAmount + preset contacts). v1.92.0+ covers a second household profile + more account records + PDF parsing + archive re-import tool.
  • Verification baselines: tsc 24 pre-existing (0 new since v1.82.2) · vitest 385/385 passing across 20 test files (373 v1.90.2 baseline + 12 new counter-leg cases) · guard-types 164/164 passing (159 v1.87.0 baseline + 5 new v1.90.3 assertions covering the 4 parser fixes + owner banner).

Fixed

  • ROOT-CAUSE BUGFIX — The four bank-CSV parsers were silently dropping the counter-leg of every transfer at parse time (AMEX `include: amount > 0`, RBC `include: amount < 0`, Scotia legacy `include: debit > 0`, TD `include: withdrawal > 0`). The v1.90.x transfer-pair matcher wasn't broken — it was receiving ghost input. Most real-world user-expected pairs (chequing → AMEX payment, chequing → RBC Mastercard payment, chequing → Scotia legacy deposit, chequing → TD deposit) silently failed to fire because one leg was never even a row in the review table. This release flips the parsers so ALL counter-legs land in the review table as income-side candidates with `suggestedDestination: 'income'` + `transferCandidate: true`.

v1.90.2

Import review table paginates at 100 rows per page · AI Stage 5 (semantic refund detection) deferred to v1.90.3 with its own clarifying round

New

  • FEATURE — Pagination in the Import review table. When the filtered row set exceeds 100 rows, Prev / Page X of Y / Next controls appear in the toolbar and the table slices its display to the current 100-row window. Addresses the user ask "to make it easier to manually review without being overwhelmed" for large multi-file imports. AI Classify, Commit, and same-batch + cross-batch transfer-pair detection all still operate on the FULL row set — pagination is purely a display slice. Page resets to 0 when a new batch arrives.

Improved

  • DEFERRED — AI Stage 5 (semantic refund detection) was originally scoped for v1.90.2 but is deferred to v1.90.3 pending its own clarifying round. The v1.87.0 refund-matcher + POST /api/refunds/scan already handle the deterministic case well (fuzzy vendor + amount tolerance + 30-day window, auto-running post-commit). The semantic LLM layer has enough design choices (pre-commit vs post-commit, auto-apply threshold, UI surfacing of proposals) that it deserves the same upfront clarification treatment v1.90.0/1 got. Pagination is the concrete win for v1.90.2; Stage 5 will follow once scope is nailed down.
  • TESTS — 373/373 vitest ✓ · tsc baseline unchanged at 24 pre-existing errors.

v1.90.1

Cross-batch transfer-pair detection — drawer now pairs new imports against previously-imported DB rows · Settings-tunable date window · three new server endpoints (candidates / nullify / unnullify)

New

  • FEATURE — Cross-batch transfer-pair detection at Import review time. The matcher now checks new rows against already-committed ones within a date window for a same-owner, same-amount counter-leg. High-confidence matches auto-apply and nullify both legs; lower-confidence ones land as "pending" for a Confirm click. Solves the reported case of two accounts' matching transfer legs landing on different import batches and never linking.
  • FEATURE — Two new server endpoints for committed-row pair mutation. POST /api/transfer-pairs/nullify stamps nullified=true + nullifiedPairId + nullifiedReason + nullificationConfidence on a oneTimeExpense or incomeEntry. POST /api/transfer-pairs/unnullify reverts those fields (called when the user clicks Unlink in the drawer). Both create a budget.json backup before mutating. Idempotent, 400 on bad kind/id, 404 when row is missing.
  • FEATURE — Settings → Dedupe → Cross-batch match date window (days). Default 3, range 1–30. Applies only to the review-time candidate lookup; the retroactive /transfer-pairs/scan endpoint stays at the built-in ±3 day rule so bulk scans remain consistent across runs. Lets the user widen the window for banks that post inter-bank transfers slowly.
  • FEATURE — Drawer extended with cross-file pair cards. Same dual-view layout as v1.90.0 (money-out left / money-in right) but with a distinct blue "cross-file" badge on the card header and a "DB" badge on the committed leg so you can see at a glance which side is mutating persisted data. Header summary now shows 'N detected · X auto-linked · Y pending confirm · Z unlinked · W cross-file'. The pending-confirm state has an amber border for immediate visibility.

Improved

  • DEFERRED — v1.90.2 will add 100-row pagination for large manual-review batches + a new AI Stage 5 for semantic refund/reimbursement pair detection (additive to the existing v1.87.0 refund-matcher). v1.90.1 completes the transfer-pair side of the redesign.
  • TESTS — 373/373 vitest ✓ · tsc baseline unchanged at 24 pre-existing errors.

v1.90.0

Transfer Pairs review drawer — right-side slide-in with dual-view cards · parse-time pair detection · Unlink reverts both legs · reopen chip in toolbar

New

  • FEATURE — New Transfer Pairs review drawer on the Import page. Slides in from the right (fixed 42rem, ~40% of viewport), shows every detected pair as a dual-view card with money-out on the left (red) and money-in on the right (emerald). Each card shows amount, date, vendor/description, source file label (e.g. "RBC DEBIT" / "Scotiabank DEBIT"), confidence chip (green ≥ 95%, amber ≥ 75%, slate else), auto-linked/unlinked status badge, and an Unlink (or Confirm) button. Auto-opens the first time pairs are detected in a batch; click-outside backdrop or the ✕ button closes it without interrupting table work. A purple "Transfer pairs (N)" chip in the toolbar reopens it.
  • FEATURE — Pair detection moved to a parse-time useEffect. v1.89.5 ran the matcher as Stage 0 of AI Classify; v1.90.0 fires it whenever newly-parsed rows land. Pairs surface in the drawer BEFORE the user clicks AI Classify, so review-and-unlink happens up front instead of buried in the AI pipeline. Deterministic rules unchanged: same-owner, amount ±$0.01, dates ±3 days, different sourceFileId. Idempotent — the effect's guard prevents re-firing on re-renders after all eligible rows are paired.
  • FEATURE — Unlink reverts cleanly. Each row captures preTransferDestination + preTransferInclude when first paired. Clicking Unlink on a pair card flips both legs back to those preserved values (e.g. "income" or "recurring") and sets the pair status to 'unlinked'. Re-clicking Confirm flips them back to Skip. Supports the user's direct ask: "I can confirm but can still unlink anything".
  • SCHEMA — ClassifiedRow gained transferPairId, transferPairConfidence, transferPairStatus, preTransferDestination, preTransferInclude. The pre-* fields preserve original row state so Unlink can revert without losing context; the pair id is shared between both legs so the drawer can group them. Legacy transferPairIndex + transferReason fields preserved for back-compat.

Improved

  • DEFERRED — Per the three-phase plan: v1.90.1 adds CROSS-BATCH pair detection (the drawer will show candidates from previously-imported DB rows, not just same-batch). v1.90.2 adds 100-row pagination for manual review on large batches + a new AI Stage 5 for semantic refund/reimbursement pair detection additive to the existing v1.87.0 refund-matcher. v1.90.0 ships the UI + parse-time detection foundation these later phases build on.
  • TESTS — 373/373 vitest ✓ · tsc baseline unchanged at 24 pre-existing errors.

v1.89.5

Import Stage 0 → deterministic transfer-pair matcher (no more AI pair hallucinations) · manual template pick auto-fills category + learns vendor alias · selection breakdown shows per-destination totals + exclusion reasons

New

  • FEATURE — Stage 0 of AI Classify is now a deterministic, rule-based transfer-pair matcher instead of an AI call — same owner, matching amount, dates within 3 days, different source file. Replaces a model call that both hallucinated pairs (unrelated amounts) and missed real ones (an e-transfer deposit and its matching outgoing on the same day). Same-batch pairs flip to skip instantly at zero AI cost; cross-file pairs are still handled after commit.
  • FEATURE — Manual template pick now auto-fills category + auto-learns a vendor alias. When you pick a recurring template from the per-row "— link template —" dropdown, three things happen: (1) template.category / subCategory / subCategory2 copy onto the row (fill-if-empty, won't overwrite user-set values); (2) classificationSource="manual" so AI Classify leaves the row alone; (3) the row's normalized vendor is silently added to template.vendorAliases[] via PUT /api/recurring so the NEXT import auto-matches the same vendor without any click. Same alias-learning mechanism used by the borderline "Yes" banner since v1.84.10, extended to the manual-pick path.
  • FEATURE — Selection breakdown on the Import toolbar. Below the "N of M selected · Total $X" line, two compact sub-lines show (a) the per-destination split of the selected total (Recurring $X · Ad-hoc $Y · Income $Z · Skip $W with counts) and (b) exclusion reasons for the unselected rows (N duplicates, N transfer-pair legs, N classified skip, N manually unchecked). Instant answer to "why N of M" and "how does the total split across buckets".
  • SCHEMA — ClassifiedRow.owner stamped at parse time from detectedSource.defaultOwner. Supports the deterministic transfer-pair matcher's same-owner rule (prevents auto-pairing Alex's outgoing with a Chelsea deposit when both spouses' files are in one batch). Locked since v1.83.2; extended to the client-side batch matcher in v1.89.5.
  • FEATURE — Per-row Source column in the Import review table, shown only when the batch contains rows from MORE than one distinct source file (single-file imports stay uncluttered). Label format: institution + card brand (e.g. "RBC DEBIT", "Scotiabank DEBIT", "AMEX"). Helps diagnose cross-file transfer pairs at a glance once multi-bank drops become common.

Improved

  • TESTS — 373/373 vitest ✓ · tsc baseline unchanged at 24 pre-existing errors.

Fixed

  • BUGFIX — Manually picking a recurring template from the dropdown left Category / Sub / Detail blank. v1.89.2 fixed this for alias-match auto-link and dup-recurring auto-link but missed the manual dropdown-pick path. Resolved by the manual-pick feature above.
  • BUGFIX — AI Stage 0 simultaneously over-paired (hallucinated pairs with mismatched amounts) and under-paired (missed real pairs with different descriptions). Resolved by the deterministic replacement above.

v1.89.4

Import UX polish — Amount color-coded by money direction · Scotia deposit vendor surfaces Interac E-Transfer · Detail column for income reverts to read-only "—"

New

  • FEATURE — Amount column in the Import review table is now color-coded by destination: income (money in) = green, skip (not counted) = muted, adhoc/recurring (money out) = red. User request: chequing/savings deposits should read as green, credit-card charges stay red, credit-card refunds read as green. After parser normalization the destination field carries the money-direction semantic for every bank type, so a single rule styles all rows correctly — no per-bank-type branching in the UI.
  • FEATURE — Scotia Ultimate deposit vendor now surfaces as 'Interac E-Transfer'. User revised the earlier v1.88.0 "no synthetic vendor" preference: "the Scotia csv import needs to show something in the Vendor for the purposes of AI classification even if we dont have a real vendor for that money in (even still showing an etransfer helps)." The vendor string is a normalized version of the Scotia Sub-description column (literally "Free Interac E-Transfer" on every deposit row — not synthetic, just source data). Benefits: AI has a concrete vendor signal if the row is unlocked; review table shows an identifiable label; transfer-pair matching post-commit still replaces the vendor with the confirmed counter-party.

Improved

  • TESTS — Scotia Ultimate vitest asserts the new vendor="Interac E-Transfer" value on every deposit. 373/373 green · tsc baseline unchanged at 24 pre-existing errors.
  • DEFERRED — When the user eventually adds L3 values to their income hierarchy at /configurations, we can wire the Detail dropdown to incomeHierarchy[L1][L2] the same way adhoc/recurring L3 works. Today all income L2s ship with empty L3 arrays so the read-only placeholder is always the right render.

Fixed

  • BUGFIX — Detail column for income rows is now a read-only "—" placeholder. v1.89.3 shipped a moneyInType dropdown (paycheck / bonus / reimbursement / gift / refund / other) that didn't align with the user's actual income category hierarchy. User feedback: "Bonus is an L2 off of Employment, same with Salary. paycheck is nowhere to be found. typically it should only show options that are off of the relevant L2 selection as per /configurations?tab=categories." The Detail column is for L3 values only; income has no configured L3 today so it renders "—". moneyInType still stored internally for AI + IncomeEntry schema back-compat but hidden from the table UI. Cascading clear on L1/L2 onChange still defensively clears the stale moneyInType so nothing leaks to commit.

v1.89.3

Income row UX — Scotia deposits pre-classify as Transfers & Refunds / Internal Account Transfer · Detail column is now an editable moneyInType dropdown · stale paycheck values cleared on reclassify

New

  • FEATURE — Scotia Ultimate deposits pre-classify as Transfers & Refunds / Internal Account Transfer / other at parse time. User reported that AI classify-income was defaulting deposits with empty vendor to Employment/Salary/paycheck because it had no other signal. The parser now provides the signal directly: every deposit flagged transferCandidate=true also gets incomeCategory/incomeSubCategory/moneyInType stamped. v1.89.1 fill-if-empty gate means AI classify-income leaves those fields alone. Users can still reclassify via the dropdowns if a deposit is actually real income (e.g. gift from family).
  • FEATURE — Detail column for income rows is an editable <select> over the six moneyInType enum values plus blank. Pre-v1.89.3 it was a read-only <span>, so when the user corrected a row from Employment → Transfers & Refunds the AI-assigned moneyInType=paycheck stayed stuck with no UI control to clear it.
  • FEATURE — 'Internal Account Transfer' added as a canonical L2 under 'Transfers & Refunds' in the default INCOME_CATEGORY_HIERARCHY. Fresh-install users and users without custom hierarchies now see the option automatically. The Scotia parser pre-classification above depends on this value existing.
  • SCHEMA — ParsedRow extended with optional incomeCategory / incomeSubCategory / moneyInType so parsers can supply income-side hints the same way suggestedDestination (v1.88.0) works. Only the Scotiabank Ultimate parser populates them today; legacy parsers leave them undefined.

Improved

  • TESTS — Scotia Ultimate vitest asserts the new income-default stamping on every deposit row. 373/373 green · tsc baseline unchanged at 24 pre-existing errors.

Fixed

  • BUGFIX — Stale moneyInType no longer survives L1/L2 reclassify. Two parts: onChange handlers for income L1 and L2 now both clear moneyInType in addition to clearing children; and the Detail column is editable so users can also clear it directly. classificationSource='manual' is stamped so the v1.89.1 AI lock prevents AI Classify from rewriting the cleared state.

v1.89.2

Import hardening — auto-linked rows inherit template category/sub/detail · exact-alias bypasses amount gate for seasonal templates · AI Stage 0 amount-match validation · locked rows route to correct AI branch

New

  • FEATURE — Auto-link effects inherit the template's full category hierarchy. When alias-match auto-links a row to a recurring template (e.g. a hydro bill → Utilities > Electricity/Hydro > Hydro Bill) OR when v1.89.1's dup-recurring fallback fires (mortgage CSV row → Housing > Mortgage Payment > Payment), the row now also stamps category + subCategory + subCategory2 from the template. Result: the review table renders populated L1/L2/L3 columns immediately after parse, before AI Classify even runs. User's ask: "If my recurring settings has the categorization then when we attach the auto identify its recurring to a recurring expense we should also be assigning its corresponding categories too."

Improved

  • DESIGN NOTE — User asked whether AI Stage 0 should consider previously-imported rows (cross-file pairing at review time) to link e-transfer deposits to RBC outgoings imported last week. Deferred to v1.89.3+ as a larger change — would require hydrating Stage 0's input with DB-side candidate counter-legs. For now the architecture splits cleanly: AI Stage 0 same-batch only (catches pairs in the file you're importing right now); deterministic transfer-pairs.ts post-commit (catches cross-file pairs at commit time, already existed since v1.83.2). The user can still manually mark cross-file pairs via Settings > Transfer pairs if they need to surface before commit.
  • TESTS — template-alias-match.test.ts updated to cover the exact-alias bypass + the preserved name-similarity amount gate. Net: 373/373 vitest (was 371 + 2 new). tsc baseline unchanged at 24 pre-existing errors.

Fixed

  • BUGFIX — Locked recurring rows rendered blank Category dropdown despite showing high AI confidence chip (94% / 86%). Root cause: v1.89.1's lock suppressed Stage 1 type writes, so typeByIndex stayed empty for locked rows, which made them flow to the ADHOC L1 classifier instead of the RECURRING L1 classifier. AI returned adhoc-hierarchy categories (e.g. "Miscellaneous") that didn't match any <option> in the recurring dropdown. Fix: new routedType(txIndex) helper seeds Stage 2/1.5/income classifier branching from the row's own destination when set, so locked recurring rows flow to classify-recurring-l1 and locked income rows flow to classify-income.
  • BUGFIX — User-added vendor aliases now WIN over amount tolerance. Real-world scenario: user added their hydro utility's name to the Hydro template's vendorAliases but imports at the winter-peak or spring amount weren't auto-linking because they sat outside the template anchor's default 10% tolerance gate. Pre-v1.89.2 template-alias-match.ts gated every match type (including exact-alias) on amountWithinTolerance. v1.89.2: exact-alias matches BYPASS the amount gate — when the user explicitly aliased a vendor, we trust their intent regardless of seasonal amount swings. Non-alias matches (name/provider/alias-similarity) still enforce the gate. 2 new vitest cases cover the bypass.
  • BUGFIX — AI Stage 0 (detect-transfers) no longer pairs unrelated deposits with outgoings. Every deposit and every mortgage payment was being flipped to skip as if they were transfer legs, even with different amounts from unrelated accounts. Fix: the AI prompt and a post-AI filter now both require a same-to-the-penny amount and dates within 3 days before treating anything as a transfer pair; failing pairs are dropped and logged.

v1.89.1

AI Classify lock refined — fill-if-empty for L1/L2/L3 on locked rows · Stage 0 transfer detection always applies · per-row lock/unlock icon · row unlock flag

New

  • FEATURE — Per-row AI lock/unlock icon in the Import review table. User feedback: "there should be a locked icon on import to the items which shouldnt be overwritten by AI that I can unlock those import transactions which I dont think should be locked." NEW UI: Small lock glyph (Lock / Unlock from lucide-react) renders in the Destination cell for any row intrinsically locked (parser hint, alias-matched recurring template, or destination==='income'). Click toggles a new ClassifiedRow.manuallyUnlocked flag; when true, isDestinationLocked() returns false for that row so the next AI Classify run treats it as fully writable (destination + categories all reclassify). Unlocked rows render the Unlock glyph with an amber tint. Tooltip explains current state + consequence. Icon does NOT appear on rows that were never locked — keeps the table uncluttered.

Improved

  • REFACTOR — Split the single isRowLocked() predicate from v1.89.0 into two tiers: isDestinationLocked(r) honored ONLY by Stage 1 (type/destination), shouldSkipFill(r, currentValue) honored by Stages 1.5/2/3/4 that fills empty fields even on locked rows. User feedback after v1.89.0: "Items that were manually assigned their destination dont auto have their category, Subcategory and Detail filled out so Im thinking the AI lock should be updated that in a locked state it still can do the ai parts on empty values." Net result: Scotiabank Mortgage / Toronto Hydro / Enercare rows (locked via alias match) now get L1/L2/L3 populated by AI Classify for reporting visibility, while their locked destination is preserved.
  • CLARIFIED DESIGN in response to user question: "the Transfer stage (Is that identifying if there is a pair in the import too or already existing in previous imports?)" — AI Stage 0 detect-transfers only sees the CURRENT import batch. Cross-file pair matching (e.g. Scotia deposit in today's import + RBC outgoing imported last week) is handled by the deterministic transfer-pairs.ts engine which runs POST-COMMIT after every import. Both mechanisms compose: AI catches same-batch pairs at review time, deterministic catches cross-file pairs at commit time. Neither is changed by v1.89.1 — only the lock-respect behavior is.
  • DOC — Verified during implementation that ExpenseEntry schema (recurring occurrences) doesn't carry category/subCategory fields — categories are inherited from the parent RecurringExpense template at read time. So for recurring rows, AI-filling L1/L2/L3 in the review UI is COSMETIC (visible during review, not persisted at commit). For adhoc + income rows, L1/L2/L3 DO persist. Either way the fill-if-empty UX is correct: the user sees AI's best guess during review, the committed row keeps the deterministic category source (template for recurring, AI for adhoc/income).

Fixed

  • FIX — Stage 0 (AI transfer detection) now writes to ALL rows regardless of lock. User feedback: "even if its locked the Transfer stage should still occur on locked files to identify if that income has an associate money-out and they are really a transfer pair." Pre-v1.89.1 the Stage 0 write-site guarded on !isRowLocked, which meant a Scotia deposit locked to income + its matching outgoing in the same file were left unpaired; now same-batch transfer pairs flip both legs to skip correctly. NOTE: AI Stage 0 sees only the CURRENT import batch — cross-file transfer pairing (deposit in file 1 + RBC outgoing in file 2) is still handled by the deterministic post-commit transfer-pairs.ts engine introduced in v1.83.2.

v1.89.0

Import UX hardening — parser-hint honored at row-init · mortgage CSV rows auto-link to recurring template (no more silent skip) · AI Classify lock on deterministic matches · manual account override picker · Account.lastFour field

New

  • FEATURE — Manual account override picker in the Import detection banner. A dropdown lets you pick which account a statement belongs to when detection can't resolve it from the filename (e.g. a household member's statement before her card's last four digits are on file, or any non-standard filename). Options are filtered to the detected institution's own accounts, and each shows its name, masked ending and owner. Selecting one updates the parse and marks it as a manual override.
  • SCHEMA — Account.lastFour?: string. Optional field on the Account interface, canonical for display + import routing. Previously last-four was only inferable from account.id or account.notes via regex, which made dropdowns + display logic brittle. Seeded the chequing and mortgage accounts with their last-four values. Existing accounts continue to work since the field is optional.

Improved

  • DESIGN RATIONALE — chose the "send-to-AI-but-discard-response-for-locked-rows" pattern over "filter-out-of-AI-txList" per user direction. Trade-off: AI still sees locked rows for batch-level reasoning (helps calibrate confidence on the UNlocked siblings in the same batch) but spends ~the same tokens. Simpler client-side filter (filter at txList construction) would save tokens but remove that batch-context signal — can swap later if token cost becomes a concern.
  • DELIBERATELY DEFERRED to v1.89.1+ — "Save as ImportProfile" prompt after a manual account-override pick (would reduce repetition if the same-named file is uploaded repeatedly, but adds a second UI prompt); tightening the deterministic template matcher's thresholds so hydro / water-heater-rental amounts below current tolerance still auto-link (needs tuning + Settings UI work beyond this release's scope); widening transfer-pairs.ts same-owner rule for shared accounts (still tracked from v1.88.0).

Fixed

  • FIX — Deposit rows default to "Income", not "Ad-hoc". scoreOneRow at import/page.tsx now reads ParsedRow.suggestedDestination (v1.88.0 field from per-bank parsers) and stamps destination + txType + classificationSource='manual' on the ClassifiedRow. Scotiabank Ultimate deposits land as Income; mortgage/bill payments land as Recurring. Pre-v1.89.0 every row defaulted to undefined destination → the AI Classify Stage 1 had to label everything from scratch and commonly got transfer-candidate deposits wrong.
  • FIX — Mortgage CSV rows no longer vanish silently. A mortgage payment row that matched a pre-entered recurring schedule entry was being flagged as a duplicate and skipped entirely, dropping the real transaction proof at commit. It's now attached to the existing recurring entry instead of being discarded, so the schedule entry stays and the imported row becomes its proof-of-payment.
  • FIX — AI Classify no longer overwrites deterministic matches. BUG: Every stage (transfers/type/income/L1/L2/L3) wrote verdicts back without checking whether the row already carried a deterministic classification. Result per user: hydro rows matched to the Hydro recurring template became Ad-hoc > Miscellaneous > Other, mortgage rows became Ad-hoc + Skip, the water-heater rental was mis-categorized under Home & Living > Repairs > HVAC instead of Utilities > Water Heater Rental. FIX: New isRowLocked() predicate at the top of runAIClassification returns true when classificationSource==='manual' || recurringExpenseId || destination==='income'. All 11 setRows write-sites (transfers + type + income one-shot + income two-step L1 + income two-step L2 + adhoc L1 + recurring L1 + adhoc L2 + recurring L2 + adhoc L3 + recurring L3) now guard with !isRowLocked(next[c.index]) before applying the AI verdict. Per user directive the txList still includes locked rows (so AI has full batch context for confidence calibration) — AI sees them, its verdicts just don't apply.

v1.88.0

Scotiabank "Ultimate Package" CSV import — new parser variant · Scotia chequing + shared mortgage accounts seeded · ParsedRow destination hints + transfer-candidate counter-party hint · filename last-four regex fix

New

  • FEATURE — NEW BankFormat 'scotiabank-ultimate' + rowsFromScotiabankUltimate() parser for Scotiabank's online-banking "Ultimate Package" CSV export. Distinct column shape from the legacy Debit/Credit chequing CSV: Filter, Date, Description, Sub-description, Type of Transaction, Amount (signed), Balance. Both variants coexist — detectFormat() picks via column signature, both resolve to bankKey='scotiabank-chequing'. Sign contract normalized to the household convention (amount = Math.abs(signed source), direction carried by suggestedDestination). The Filter column's first-row metadata blob ("Custom filters …, From date=…, To date=…") is preserved on raw so callers can mine it for date-range provenance.
  • FEATURE — Row-class handling tuned for real transaction types on this format: a deposit is marked as a likely transfer counter-leg (matched to its source once that outgoing leg lands); a mortgage payment files as a stable recurring vendor so the existing template matches; a bill or miscellaneous payment files as recurring using the statement's own description. Deposit vendor is left blank rather than synthesized — the real counter-party lands via pair-match.
  • FEATURE — ParsedRow schema extended with four optional fields: subDescription (preserves the Scotia Sub-description column for audit + classifier context), transferCandidate (boolean flag for rows that are likely a leg of a cross-account transfer), transferCandidateSourceHint (human-readable counter-party guess, e.g. "RBC Chequing (unconfirmed)"), and suggestedDestination ('adhoc' | 'recurring' | 'income' | 'skip' — parser-time hint for the commit loop + AI classifier). All four fields are opt-in; legacy parsers (AMEX, RBC, TD, generic) leave them undefined and continue to rely on the downstream classifier alone.
  • FEATURE — Seeded the first import profile for this format under its owner and product type, first entry in an empty profile list. A second household member's equivalent profile is deferred until her card's last four digits are known.
  • FEATURE — Seeded the accounts list (previously empty) with two real accounts from the user's own online banking: a chequing account under one owner, and a loan account under shared ownership, each with its identifying fields, term, maturity, amortization and payment schedule recorded, the loan paid from the chequing account.
  • TESTS — NEW src/lib/__tests__/csv-import-scotiabank-ultimate.test.ts with 9 cases covering detectFormat branching (Ultimate vs legacy vs generic near-match), parseBankRecords dispatch + metadata (bankKey, cardBrand, cardLastFour, dateRangeStart/End), per-row-class handling (deposit / mortgage / bill / misc), Filter-column preservation on raw, and filename-aware ImportProfile matching. vitest suite 371/371 passing (362 v1.87.0 baseline + 9 new). tsc baseline unchanged.

Improved

  • DESIGN CHOICE (clarified during planning) — Transfer pairs deliberately use the existing v1.83.2 nullified flag rather than a new 'transfer' destination type. Scotia deposit → income bucket at import, RBC outgoing → adhoc bucket at import; after commit, transfer-pairs.ts stamps nullified=true + nullifiedPairId on BOTH legs and rollups filter nullified rows. Rows stay in their natural buckets for audit while being excluded from spend/income totals. Legacy 'skip' destination (which DROPPED rows at commit) is being phased out per the comment at transfer-pairs.ts:12.
  • KNOWN LIMITATION documented — the transfer-pair matcher is locked to same-owner matching, so a shared-owner mortgage payment won't auto-pair with a personal chequing debit once mortgage statements are imported. Not a regression — the rule predates this release. Logged as a follow-up: the pairing rule needs to widen for shared and household accounts.

Fixed

  • BUGFIX — extractLastFourFromFilename() picked digits from a 6-digit date suffix as a card last-four. Pre-existing since v1.78.0: the regex /\d{4}/g greedily matched the first 4 characters of any ≥4-digit run, so Scotia's "Ultimate_Package_1234_042126.csv" filename (where 042126 is MMDDYY) yielded "0421" instead of the real account suffix "1234". Latent until v1.88.0 because RBC / AMEX / TD don't emit the XXXX_YYMMDDY pattern. Fix: tighten the regex to /(?<!\d)\d{4}(?!\d)/g — requires non-digit boundaries on both sides. Year-skip heuristic (1900–2199) preserved.

v1.87.0

Phase 12 Refund & Reimbursement Tracking — NEW /money-back hub (3 tabs) · refund matcher + picker · tiered auto-link/prompt gate · dashboard RefundsCard · /reimbursements retired (307) · refund-aware rollup toggle · refunds.jsonl audit · 17 new guard-types · 42 new vitest

New

  • 12A SCHEMA — NEW `refundOfExpenseId?: string` + `refundReason?: string` on OneTimeExpense. Negative-amount refund rows FK-link to their original positive-amount charge. Single source of truth — refundedAmount / refundedAt on originals are computed on-the-fly via backRef lookups (no denormalization drift). Locked in the 2026-04-21 clarifying round: negative-amount storage is the chosen model (CSV credit rows land naturally, NET math trivial, distinct audit row per partial refund).
  • 12B FEATURE — NEW src/lib/refund-utils.ts. Client-safe (no fs, no crypto). Exports REFUND_REASON_PRESETS (8 canonical reasons — Returned / Price match / Defective / Cancelled / Duplicate / Partial / Shipping / Other), isRefundRow / isOrphanRefundRow predicates, getRefundsForExpense / computeRefundedAmountFor / getLatestRefundDateFor / getRefundCompleteness back-ref helpers, getRefundedTotalYTD / getTopRefundedVendors rollup helpers, and resolveRefundGates Settings fall-through. 23 vitest cases.
  • 12C FEATURE — NEW src/lib/refund-matcher.ts. Composite scoring (vendor 40% via bigram Dice similarity + amount-ratio 35% linear decay to tolerancePct floor + date-proximity 25% linear decay to window edge). Hard criteria: same owner, positive + negative signs, refund date ≥ original date, within 30d window, |refund| within [tolerancePct × original, 101% × original]. `findRefundCandidates` returns autoLinkPairs (≥ matchThreshold — silent) vs promptPairs (≥ promptFloor — user review). Multi-refund stacking allowed up to cumulative 101%. `rankCandidatesForRefund` powers the RefundPicker top-N list. 19 vitest cases cover eligibility guards + scoring + bucket split + stacking.
  • 12D FEATURE — NEW POST /api/refunds/scan endpoint. Idempotent — rows with refundOfExpenseId already set are skipped. Runs findRefundCandidates over every OneTimeExpense. Pairs ≥ refundMatchThreshold stamp FK + refundReason='Auto-matched refund' + updatedBy='import'. Borderline pairs returned in promptPairs[] for UI rendering (not stamped). Creates budget.json backup before mutating. Query param ?source=import-auto|retro-scan tags log entries.
  • 12E FEATURE — NEW POST /api/refunds/link + POST /api/refunds/unlink endpoints. /link body `{ refundId, originalExpenseId, reason? }` stamps FK + updates updatedBy='user'. Guards: same owner, sign validation, stacking cap ≤ 101% of original. /unlink clears FK + reason (does NOT restore pendingNullification). Both write to refunds.jsonl. Used by RefundPicker + /money-back tab actions.
  • 12F FEATURE — NEW src/components/shared/RefundPicker.tsx. Mirrors EventPicker pattern: trigger button OR always-open inline mode, search input with case-normalized vendor + amount + date match, top-5 ranked candidates with confidence chips (green ≥ 0.9 / amber ≥ 0.75 / slate else), broader text-search results mixed in, preset-reason dropdown + custom reason input for 'Other'. POSTs to /api/refunds/link. Consumed by /expenses/ad-hoc (row action on orphan refunds) + /money-back Refunds tab.
  • 12G FEATURE — Transaction Import auto-runs refund scan post-commit (mirrors v1.83.2 transfer-pair post-commit scan). Status bar appended with "N refunds auto-linked" + "M borderline refunds on Money Back". No inline banner in v1.87.0 (would require invasive review-row refactor) — borderline pairs surface on /money-back Refunds tab for user review instead.
  • 12H + E1 FEATURE — /expenses/ad-hoc gains (1) a "Refunds (N)" filter chip after the event chips narrowing to negative-amount rows; (2) an inline Link2 action button on unlinked negative rows that opens RefundPicker in-place; (3) an expand-row sub-row showing "Refund of [vendor] $X on [date] · [reason]" (amber) for linked refunds, or "Refunded $X of $Y (Z%) · latest on [date] · N refunds" (emerald) on originals with back-refs. Expense local type extended with refundOfExpenseId + refundReason.
  • 12I FEATURE — Cascade: when a refund FK is stamped on an original expense (via scan, link endpoint, or import auto-link), if the original has pendingNullification: true it auto-clears. Writes one data/logs/refunds.jsonl entry with source='auto-clear-pending-nullification'. Resolves the v1.83.4 "soon-to-be-nullified" flag automatically when the refund lands instead of requiring manual unflag.
  • 12K FEATURE — NEW sumOneTimeAmounts(rows, opts) helper in src/lib/calculations.ts. When opts.excludeRefunds === true, filters rows with refundOfExpenseId set before summing (GROSS mode — "what I charged before refunds"). Default: NET (negative amounts subtract naturally — existing dashboard rollups already get this for free). Toggle exposed via Settings.excludeRefundsFromGrossSpend.
  • 12L FEATURE — NEW /money-back tabbed hub with 3 tabs: Refunds (vendor-initiated — linked + orphan with inline RefundPicker + unlink action + completeness %), Reimbursements (lifts v1.15.6 /reimbursements employer-payback logic — status toggles + income-link dropdown), Event Reimbursements (IncomeEntry rows with isReimbursement:true, cross-linked to /events/[id]). URL-state synced via ?tab=. Header shows 3-concept YTD summary. /reimbursements 307-redirects. Sidebar renamed "Reimbursements" → "Money back" with HandCoins icon. CommandPalette entry updated. PendingReimbursementsCard href updated.

Improved

  • E2 ENHANCEMENT — NEW src/components/dashboard/RefundsCard.tsx. YTD refunded total (big emerald number) + top-3 refunded vendors by cumulative |amount|. Click-through to /money-back?tab=refunds. HandCoins icon mirrors sidebar. Mounted on dashboard next to PendingReimbursementsCard.
  • E3 ENHANCEMENT — NEW "Refund matching" card in Settings > Dedupe. Inline number inputs for refundMatchThreshold (default 0.90), refundPromptFloor (default 0.75), refundAmountTolerancePct (default 0.10 — min |refund|/original), refundDateWindowDays (default 30). Inline checkbox for excludeRefundsFromGrossSpend (default off = NET; on = GROSS). All PUT through /api/settings on change. Matches the v1.84.10 alias-match Settings pattern.
  • E4 ENHANCEMENT — NEW src/lib/refund-log.ts — server-only append-only jsonl logger mirroring transfer-pairs-log.ts. Every refund decision (import-auto / retro-scan / manual-link / manual-unlink / auto-clear-pending-nullification) appends one flat-JSON line with timestamp + source + ids + amounts + confidence + reason. Easy to grep; easy to notebook-import. Errors swallowed to avoid crashing caller paths.
  • PUSH-BACK locked during clarifying round — 12J no longer ships a full 4-way disambiguation modal (would invade the already-complex Transaction Import review UI). Instead: credit rows that match auto-link silently at commit, borderline matches surface on /money-back Refunds tab for user review in context with RefundPicker. Simpler, ships now, re-evaluate in v1.87.x if import-time friction is high.
  • Verification baselines: tsc 24 pre-existing (0 new since v1.82.2) · vitest 362/362 passing across 18 files (320 v1.86.1 baseline + 42 new — 23 refund-utils + 19 refund-matcher) · guard-types 159/159 passing (142 v1.86.1 baseline + 17 new — refund schema + Settings fields + lib modules + API routes + RefundPicker + /money-back + RefundsCard + next.config redirect + sumOneTimeAmounts helper).

v1.86.1

Phase 11 polish — 11I reimbursement carve-out + 11J enhanced active-event dashboard card + 11G event filter chips on ad-hoc & income

New

  • 11I FEATURE — NEW Settings.hideReimbursementsFromMainIncome toggle (default TRUE). When on, IncomeEntry rows flagged isReimbursement: true are hidden from /income so employer-payback / trip-partner-split rows don't inflate main income. Event dashboards always show reimbursements regardless. Settings UI: new "Events & reimbursements" section on the General tab with explanatory helper text.
  • 11I FEATURE — /income page gained "N reimbursements hidden" pill when the setting is on + reimbursements exist. Pill has "Show here" button that temporarily turns the filter off for the session (doesn't persist) + link to the Settings toggle. IncomeEntry local interface extended with eventId + isReimbursement fields (previously declared on the shared type since v1.14.0 / v1.82.3, only surfaced to the page now).
  • 11G FEATURE — Event filter chips on /income page. "All / Untagged / <top-10 non-cancelled events by recent startDate>" with event color dots. No URL persistence (session-only for v1.86.1; can upgrade if flow becomes common).
  • 11G FEATURE — Event filter chips on /expenses/ad-hoc page, inline with the existing receipt-filter row. "Event: All / Untagged / <top-5 events>" + color dots. Shows only when at least one event exists. Filter composes AND with all existing quick filters (category / vendor / dates / amounts / methods / legacy).
  • 11J ENHANCED — ActiveEventsCard on dashboard gained type-colored left border strip, days-remaining chip (green → amber ≤ 3d → orange when overdue), Gross / Reimb / Net line when any reimbursements exist for the event. Receives income slice from dashboard for per-event reimbursement totals. Prior v1.14.0 behavior unchanged when no reimbursements exist.

Improved

  • DEFERRED from v1.86.1 to v1.86.2+ — 11F per-row EventPicker on receipts page (3316-line surface, invasive) + type-specific metadata editor (complex per-type forms) + 11K work-reimbursement auto-match via transfer-pair engine (semantic stretch, revisit when real work-reimbursement events exist). Ad-hoc event tagging remains on the legacy "Move to event…" multi-select from v1.14.0 — still works, just not upgraded to EventPicker yet. Income event tagging handled via /events/[id] tag-more picker for now.
  • guard-types +1 — Settings.hideReimbursementsFromMainIncome declared. Total 142/142. tsc baseline unchanged at 24; vitest unchanged at 320/320.

v1.86.0

Phase 11 Events Extended — EventPicker component · GET /api/events/[id] eager-load · enhanced /events list + detail · side-gig profit math · budget-ceiling warnings · timeline strip · CSV export · status auto-advance · type-color palette

New

  • 11B FEATURE (closes deficiency #2C) — NEW src/components/shared/EventPicker.tsx. Dropdown/combobox for tagging rows to an existing event or creating a new one inline. Sort order: active → planned → completed → cancelled via inferEventStatus. Inline quick-create form (name + type) POSTs to /api/events + selects the new event. Reused by /events/[id] tag-more picker (v1.86.0) and the v1.86.1-polish row-view wiring on ad-hoc/receipts/income
  • 11C FEATURE — NEW GET /api/events/[id] route returns { event, expenses, entries, income, receipts, rollup } in one HTTP call. Pre-v1.86.0 the /events/[id] detail page fetched /api/events + /api/expenses + /api/expense-entries + /api/income (the entire household ledger) + filtered client-side. New endpoint joins by eventId on the server so only tagged rows ship to the client. rollup is precomputed via rollupEvent()
  • 11D FEATURE — /events list page extended with type filter chips (All / Vacation / Renovation / Work Reimb. / Side Gig / Legacy) + status filter chips (All / Active / Planned / Completed / Cancelled). URL-state-synced via window.location so bookmarks + back-button work. New "Gross / Reimb. / Net" row on each card when reimbursements > 0; falls back to the classic 4-card layout for clean events. Participant + location rendered below the type line. Type-colored left border strip on each card using resolveEventColor. Extended create form now covers all v1.82.3 fields: Type picker (Phase 11 + Legacy optgroups), Budget Ceiling, End Date, Location, Participants (comma-separated with on-blur parse)
  • 11E FEATURE — /events/[id] detail dashboard rebuilt. Uses the new eager-load endpoint (one fetch). Extended header with type + dates + participants + location + funding source. Gross/Reimbursements/Net 4-card summary when reimbursements > 0, classic 4-card otherwise. Revenue/regular income split into its own section + Reimbursements split into theirs. Tagged Transactions table gains a "Recurring charges" sub-header when ExpenseEntry rows are present
  • 11H FEATURE — NEW src/lib/event-calculations.ts exporting eventProfit() + eventCeilingStatus() + collectEventRowsForExport() + rowsToCsv(). eventProfit buckets tagged OneTimeExpense rows by expenseKind (cost / labour / other), reads revenue from non-reimbursement income, rolls recurring ExpenseEntry into cost. Returns { revenue, cost, labour, other, recurringCost, totalExpenses, profit, marginPct, row counts }. Renders on the /events/[id] detail page as the "Side-Gig Profit" card (only when type === "side_gig"). 15 vitest cases cover empty-state, expenseKind bucketing, reimbursement exclusion, negative profit, zero-revenue margin, CSV escaping. tsc + vitest all green
  • 11H SCHEMA — NEW `expenseKind?: 'labour' | 'cost' | 'other'` field on OneTimeExpense. Optional; unset rows count as 'cost' in eventProfit(). Editable inline via a per-row dropdown on the /events/[id] detail page when the event is a side_gig. Recurring ExpenseEntry rows don't carry this field (always cost)
  • 11M FEATURE — Event budget-ceiling warning banner on /events/[id]. eventCeilingStatus() classifies: < 80% → ok (neutral banner, silently hidden), 80%–99.9% → amber banner, ≥ 100% → red banner. Surfaces only when Event.budgetCeiling is set + > 0. Shows utilization % + remaining (negative when over). Independent of plannedBudget — ceiling is the "hard cap" while plannedBudget is the projected target
  • NEW lib/events.ts exports: EVENT_TYPE_LABEL (covers both legacy EventCategory + v1.82.3 EventType union), EVENT_COLOR_BY_TYPE (default theme-scoped palette per type — vacation=blue, renovation=orange, work_reimbursement=emerald, side_gig=purple, legacy=neutral), resolveEventColor(event) chain (color override → type default → category default), inferEventStatus(event, today?) for status auto-advance math (planned → active → completed; terminal statuses never change)

Improved

  • ENHANCEMENT — Event status auto-advance. On /events list mount, any drifted event (planned with startDate ≤ today, or active with endDate < today) is silently PUT to its inferred status. Skips terminal statuses (completed / cancelled) + events without startDate. Reduces manual status-toggle overhead as trips begin + end
  • ENHANCEMENT — Event timeline strip on detail page. Horizontal chronological bar showing tagged transactions as dots positioned proportionally between startDate and endDate (or falling back to min/max tx date). Three dot colors: accent = ad-hoc expense, blue = recurring, green = income. Hover tooltip shows date + label + amount. Renders when timeline span > 0 days
  • ENHANCEMENT — CSV export on detail page. "Export CSV" footer button collects all tagged rows (OneTimeExpense + ExpenseEntry + IncomeEntry + ReceiptRecord) via collectEventRowsForExport(), chronological by date. rowsToCsv() handles RFC-4180-ish quoting (commas / quotes / newlines). Filename pattern: event-{slug}-{id-prefix}.csv. Useful for tax documentation, splitting bills with trip partners, feeding other tools
  • ENHANCEMENT — Type-colored identity. Detail header gets a color dot matching the event's resolved color. /events list cards get a 3px left-border strip. EventPicker row list uses the same palette so the color follows the event across surfaces
  • DEFERRED to v1.86.1 polish (separate patch) — 11F tag-to-event picker on ad-hoc / receipts / income row views · 11G event filter chips on ad-hoc + income pages · 11I hideReimbursementsFromMainIncome Settings toggle + /income carve-out · 11J active-event dashboard card · 11K work-reimbursement auto-match · type-specific metadata editor (vacation / renovation / side_gig / work_reimbursement per-type panels). v1.86.0 ships the core foundation + end-to-end events surface; v1.86.1 adds the cross-page integration + automation
  • DEFERRED to v1.86.2 (speculative — will revisit once real events exist) — 11L side-gig invoice PDF generation · 11N auto-tag suggestions on expense commit. Shipping these now would add complexity + dependency surface without validating data (events[] array is currently empty)

Fixed

  • 11A BUGFIX (closes deficiency #3) — POST /api/events now writes the v1.82.3 foundation fields (type, participants, location, metadata, budgetCeiling). Pre-v1.86.0 the handler silently dropped them on create; only PUT via object spread accepted them. Event creation from the extended /events form + from EventPicker inline-create now saves the full shape
  • 11A LAPSE FIX — POST /api/events DELETE cascade now also clears eventId references on ReceiptRecord rows (pre-v1.86.0 cascaded OneTimeExpense + ExpenseEntry + Income but left receipts with dangling eventId pointers). ReceiptIndex lives in its own file so cascade uses readReceiptIndex / writeReceiptIndex

v1.85.8

Subscription Renewals 4-state (Charged / Expected / Confirmed / Projected) + resolveNextDueDate fixes stale anchor drift (two annual templates) + data refresh + Category (L1/L2/L3) editing parity between Settings and Templates view + Tax-exempt flag for GST/HST-exempt financial services (both card annual fees)

New

  • NEW src/lib/recurring-schedule.ts export: resolveNextDueDate(template, today?). Reconciles the stored tpl.nextDueDate against the anchor-derived computeNextDueDate(). Rule: if stored is in the FUTURE, trust it (respects user intent like SuperGrok's prepaid bundle at Jun 14 when the monthly anchor would suggest May 14). If stored is in the PAST (or missing), fall through to the anchor-derived computation. If no anchors are set, computeNextDueDate itself falls back to stored — so legacy templates behave identically to pre-v1.85.8
  • DATA — refreshed stored nextDueDate for 2 clearly-stale annual templates where the stored value was pointing at a historical creation date rather than the correct anchor-derived cycle. Both were re-pointed from their creation date to the date their own anchors imply. Other templates with drift were intentionally left alone — their stored values are either anchor-consistent or deliberate overrides for a prepaid bundle
  • NEW — `taxExempt?: boolean` on RecurringExpense. When set to true, the Pricing & Discounts panel treats pre-tax as equal to total + tax as 0. Used for items that aren't subject to sales tax in Canada: credit-card annual fees (primary and supplementary — exempt because credit-card fees are classified as GST/HST-exempt financial services), banking service fees, interest charges. Default off — most templates have tax-inclusive totals. taxBreakdown() in src/lib/recurring-cost.ts short-circuits to { preTax: total, tax: 0 } when the flag is on
  • NEW — "Tax exempt — no tax applies to this charge" checkbox added to the Pricing & Discounts panel in the Settings > Recurring expand-row. Positioned above the existing expectsReceipt toggle. Helper text lists the exempt categories (credit-card annual fees, banking service fees, interest charges, other financial services). When toggled on: (1) pre-tax input disables + visually mutes (border-themed, opacity-60, cursor-not-allowed); (2) pre-tax label value locks to the total; (3) tax label gains "(exempt)" suffix + shows $0.00 in italic muted; (4) any previously-pinned amountPreTax is cleared since it becomes meaningless. Toggling off re-enables pre-tax editing (back-computes from HST_RATE or reuses the amountPreTax on re-save).

Improved

  • Rationale for the 4-state vocabulary: user articulated that Expected (past) and Forecasted (future) are semantically the same "we think this will/should have happened" state, just split by tense. A PAST item with actual proof becomes Charged. A FUTURE item with known proof (received bill, pre-entered mortgage payment, exactly-known upcoming subscription charge) becomes Confirmed — it's locked in but not yet posted. The middle states (Expected + Projected) remain ambiguity markers
  • RenewalRow rendering updated for 4-state: new green-pill "Confirmed" badge with tooltip "Upcoming bill — amount + date are known and confirmed but the charge hasn't posted yet." Caption verbs: "Charged" / "Expected on" / "Due" / "Renews." Free Trial badge still takes precedence over all status pills
  • PARITY — TemplateDetail.tsx (the expand-row on /expenses/recurring Templates view) gained L3 support (subCategory2 free-form input) alongside the existing L1 + L2 selects. Pre-v1.85.8 only L1 + L2 were editable there — a latent asymmetry with the Settings row's read-only display which already SHOWED L3. Now both surfaces can edit L1 + L2 + L3
  • PARITY — Settings > Recurring frequency column gained inline paymentsPerYear input when frequency is "custom". Mirrors the TemplateDetail behavior (custom + N ×/yr). Switching away from custom clears paymentsPerYear. Pre-v1.85.8 custom-frequency templates could only set paymentsPerYear from TemplateDetail, not Settings
  • tsc baseline unchanged at 24. vitest 305/305 unchanged. guard-types net +2 — 134/134 (added: resolveNextDueDate export exists in recurring-schedule.ts; Renewal interface uses 4-state union 'charged' | 'expected' | 'confirmed' | 'projected'; RecurringExpense.taxExempt declared + taxBreakdown short-circuits when true; retired the obsolete v1.85.6 3-state Renewal assertion — superseded by the 4-state one). Updated v1.85.6 11D + v1.85.7 projectMonth assertions to match the new `effectiveNextDue` variable used after switching to resolveNextDueDate

Fixed

  • FIX — projectRenewalDate() in src/app/dashboard/page.tsx + projectMonth() in src/components/recurring/HistoryForecastChart.tsx now both use resolveNextDueDate() instead of reading tpl.nextDueDate directly. Fixes user-flagged bug: an annual template displayed a stale "Expected on" date on the Subscription Renewals card even though its schedule anchors correctly describe a later month as the real next charge. Same fix surfaces the correct dates for the other annual templates, etc.
  • FIX — Renewal interface status field extended from 3 states (confirmed/expected/projected) to 4 states (charged/expected/confirmed/projected) to distinguish past-ledger vs future-ledger semantics. Pre-v1.85.8 labeled BOTH past-matched-ledger AND future-pre-entered-ledger rows as "confirmed" with a "Charged [date]" caption — user flagged that "a future item as Charged doesnt make sense." Now: charged = past+ledger (no badge, "Charged [date]"); expected = past+no-match (amber "Expected", "Expected on [date]"); confirmed = future+ledger (green "Confirmed", "Due [date]" — means bill received / amount + date known / not yet posted); projected = future+no-match (blue "Projected", "Renews [date]")
  • PARITY — Settings > Recurring expand-row gained an editable Category (L1 / L2 / L3) panel. Previously the Settings row displayed category as READ-ONLY text with a code comment directing users to AddTemplateDialog or the /expenses/recurring detail panel for edits — user flagged this as a gap: "my ability to add an L2 category to a recurring expense is only available in the recurring expenses > Template view and I cannot add in Settings". New panel mirrors TemplateDetail behavior: L1 is a select driven by RECURRING_CATEGORY_HIERARCHY, L2 cascades from L1, L3 is a free-form input (saves on blur). Changing L1 resets L2 + L3; changing L2 resets L3. Now you can fully configure categories from either surface
  • DATA — applied taxExempt: true to both card-membership-fee templates in budget.json — the primary card's annual fee and the supplementary card's. Before v1.85.8 both showed back-computed tax components in the Pricing & Discounts breakdown, which was incorrect — Canadian credit-card annual fees are GST/HST-exempt financial services. Now both correctly show pre-tax === total === the charged amount

v1.85.7

Recurring Expenses + Subscription Renewals fidelity — Settings edits propagate to dashboard · HistoryForecastChart respects nextDueDate + discounts · "Configure in Settings" deep-links with auto-expand from /expenses/recurring

New

  • FEATURE — /configurations > Recurring tab now supports `?template=<id>` deep-link. When the page arrives with that query param: (1) auto-switches to the Recurring tab, (2) adds the id to expandedTemplateIds so the expand-row renders, (3) scrolls the row into view with a 2-second accent ring flash, (4) consumes the query param from the URL so back/forward nav doesn't re-trigger. Target template gets the v1.84.2 expand-row opened showing notes + aliases + linked-memos + schedule + bundle children + seasonal + pricing & discounts panels
  • FEATURE — TemplateDetail.tsx (the expand-row on /expenses/recurring Templates view) gained a prominent "⚙ Configure in Settings →" link button in the header. Clicks route to /configurations?tab=recurring&template=<id> for the deep-link auto-expand. Plus an inline hint line under the header listing what's Settings-only: "schedule anchors · bundle children · seasonal multipliers · vendor aliases · pricing & discounts (all types) · hold/archive · installment schedule"
  • FEATURE — /expenses/recurring Ledger view rows gained a small Settings-icon button in the per-row action area. Same deep-link target as the Templates view button — opens /configurations?tab=recurring with the row's parent template auto-expanded. Now both Ledger + Templates surfaces can jump to Settings in one click for any row's parent configuration

Improved

  • FIX — resolveTemplateAmount now accepts a calibrated usdToCadRate parameter so USD subscription renewals display at the median-of-last-90-days FX rate (same self-calibrating rate the /configurations > Recurring tab uses) instead of the static USD_TO_CAD constant. Dashboard fetches /api/recurring-cost on mount; falls back to the constant until the rate lands
  • UX PRINCIPLE — /expenses/recurring keeps its existing capabilities (add entries, edit amounts/frequency/category, enable/disable, scan for recurring patterns, upload proof of expense, add new templates) — nothing removed. Deeper configuration is discoverable-but-one-click-away via the new Settings links, so users don't have to remember the Settings tab exists to reach advanced template settings
  • tsc baseline unchanged at 24. vitest 305/305 unchanged. guard-types +3 — 132/132 (new assertions for: resolveTemplateAmount priority is amount || defaultAmount; HistoryForecastChart respects nextDueDate gate + applies discounts; /configurations reads ?template= deep-link query param)

Fixed

  • FIX — the dashboard's Subscription Renewals card was reading a stale forecasting baseline instead of a template's live-edited amount, so changing a subscription's price in Settings never updated the dashboard figure. The live-edited amount now wins. Fixes the reported case of an annual membership's price change not reflecting in its recurring cost.
  • FIX — HistoryForecastChart.projectMonth() in src/components/recurring/HistoryForecastChart.tsx now respects the template's nextDueDate. When the projected date for a forecast month would fall BEFORE the next scheduled charge, returns 0 — suppressing forecast bars during free trials and prepaid bundles, up to the date each one actually starts charging. Mirrors the v1.85.6 11D fix on the Subscription Renewals card so /expenses/recurring + dashboard agree on what months are "expected"
  • FIX — projectMonth() also applies active v1.84.3 discounts via activeDiscountsAt() + applyDiscountsSequentially() before the frequency multiplier. Recurring percentage-off discounts (e.g. telecom multi-line discount, insurance bundling discount) now propagate into the template forecast chart instead of showing the pre-discount sticker price. Active discount test date is mid-month (day 15) for the projected period
  • FIX — projectMonth() converts USD-denominated templates to CAD via USD_TO_CAD before display. Previously a USD-denominated template showed its USD amount on the chart as if it were CAD, conflating the two. Now multiplies through so the chart stays in CAD throughout

v1.85.6

Dashboard Deficiency Patch — Theme-aware Cash Flow + Balance Timeline · Subscription Renewals status trichotomy (Confirmed / Expected / Projected) · Held templates filtered from Upcoming Bills + Show held toggle · projectRenewalDate respects future nextDueDate (trial + prepaid suppression) · SuperGrok Mar 14 bundle modeled (discount + ExpenseEntry)

New

  • NEW src/lib/chart-theme.ts — getCategoryColor(viewMode, key) + getCategoryColorMap + ChartCategoryKey type. Resolves dashboard chart category colors per viewMode (Alex greens+beiges / Chelsea pinks+purples+magentas / Duo blended) from the existing CHART_COLORS palette. Income / Committed / Ad-hoc / Special each get a theme-scoped hex. Net +/- and deficit keys keep universal success/danger CSS vars — negative direction is red regardless of theme
  • 11C — NEW "Show held" escape-hatch toggle on the Upcoming & Past Bills card header. Off by default (held memberships suppressed). When ON, held rows surface with opacity-60 + a grey "on hold" pill (bg-gray-500/15 text-gray-500) so the user can audit what's hidden. Canonical hold gate is enabled:false (not heldSince presence — a resumed template can have a stale heldSince stamp)
  • DATA — a prepaid-bundle subscription template enriched with its bundle history: a new discount record (50% off 3-month promo), a schedule-day anchor, and expanded notes showing how the base price, the unused-time refund and the promo netted out to what was actually paid, updatedBy: user. A new ExpenseEntry was added for the bundle charge, linked to that template with billPeriodStart/End set, so the Subscription Renewals card shows that month as "Confirmed" (v1.85.6 11B) with the actual net payment. From the bundle's end date onwards it projects at the full monthly price

Improved

  • guard-types (+9) — 129/129. New assertions: chart-theme.ts exports, WaterfallChart no-hardcoded-hex guard, BalanceTimeline deficit-via-helper, installments.includeHeld option, UpcomingInstallmentsCard held filter, Renewal.status field, showHeldBills toggle wiring, WaterfallChart viewMode prop on the dashboard invocation, projectRenewalDate pre-nextDueDate suppression
  • vitest (+13) — 305/305. NEW chart-theme.test.ts (10 cases: per-theme palette correctness, uniqueness, fallback, +/- viewMode-invariance, hex-format validation). installments.test.ts (+3): held excluded by default, included with includeHeld:true, stale-heldSince-but-enabled treated as active. tsc baseline unchanged at 24

Fixed

  • 11A — WaterfallChart now theme-aware: added viewMode prop (dashboard passes it from useTheme), all 5 bar fills resolve via getCategoryColor. Pre-v1.85.6 used hardcoded #f97316 (ad-hoc) + #a855f7 (special) which ignored the active viewMode. Also corrected: Committed bar no longer uses var(--color-danger) — Committed is a category, not a +/- indicator, so it gets its own theme-scoped color and danger-red is reserved for the Net bar when negative
  • 11A — BalanceTimeline deficit gradient now resolves via getCategoryColor(owner, 'deficit') instead of hardcoded #ef4444 at both linearGradient stops. The existing owner prop (already declared on Props) was added to the function destructure — a latent bug where it was passed from the dashboard but never consumed. Today reference line stays red — it's a +/- position marker, not a category
  • 11B — Renewal interface gains status: 'confirmed' | 'expected' | 'projected'. Derivation: ledger row match → confirmed (no badge); projected date < today + no match → expected (amber pill, "Expected on [date]" caption, tooltip "Expected on this date but no matching charge has been imported yet"); projected date today/future → projected (existing blue pill). Fixes the current-month bug where templates with expected charges that hadn't been imported yet got misbadged as "Projected" — which was wrong since a past date can't be projected
  • 11C — Held template filtering across all dashboard forecast surfaces. getUpcomingInstallments in src/lib/installments.ts gained includeHeld?: boolean option (default false) that skips templates where enabled === false. UpcomingInstallmentsCard.tsx perTemplate filter extended with t.enabled !== false (belt-and-suspenders). Dashboard Upcoming & Past Bills card: nonArchivedEntries filter now also skips entries whose parent template has enabled === false unless the new Show held toggle is on
  • Fixes user-flagged regression: a held gym-membership template still appeared on the Upcoming Bills forecast even though the membership was paused. The template had a pre-entered ExpenseEntry that rendered regardless of hold state
  • 11D — projectRenewalDate() now suppresses renewal rows whose projected date would fall before the template's nextDueDate. Previously the function blindly projected the monthly anchor day into the target month regardless of whether the subscription was actually due. Fixes user-flagged bug: a template on a free trial was showing a monthly "Projected" row for months before the trial ended, and a prepaid-bundle template was showing a projection despite no charge being due. Under v1.85.6 both suppress correctly until their actual next-due month arrives. Applies universally — any template with a future nextDueDate (trial, prepaid, delayed-start) now renders only from its due month onwards

v1.85.5

AI Natural-Language Installment Adder — Describe-mode free-text → structured template with confidence scoring + preset suggestion + multi-line + AI usage logged

New

  • NEW `extract-annual-expense` prompt template, user-editable under Settings > Prompts & Cache's new "Annual / Installments" subgroup. Prompts the model to return structured JSON — name, annual-total amount, frequency, schedule months, category, provider, notes, a suggested preset key, year, missing fields and per-field confidence. An amount with just a month defaults to an annual lump sum; a non-yearly description returns an error instead of guessing.
  • NEW `POST /api/ai/extract-annual-expense` endpoint — mirrors v1.61.0 extract-adhoc-expense structure but narrower (extraction only, no classification chain). Normalizes AI output: coerces amount/paymentsPerYear to numbers, filters frequency against valid enum, clamps paymentScheduleMonths to 0-11 + dedup + sort, validates category against recurring hierarchy, filters suggestedPresetKey against 5 shipped keys. AI call logged via logAICall so cost shows in Insights AI Usage tab
  • MODE TOGGLE in installment preset modal — tab-like toggle between "From preset" (v1.85.4 shape picker) and "✨ Describe (AI)" (v1.85.5 free-text). Single modal hosts both paths + a third "preview" state post-extraction. Unified UX so user can fall back between AI and manual
  • DESCRIBE MODE — guided textarea with placeholder listing every field AI will extract (Name, Amount, Frequency, Months/start month, Category, Provider, Notes). Example-prompt chips above the textarea seed common cases (an annual home-insurance premium, a quarterly municipal property-tax bill, etc.) with one click. Multi-line input for pasting renewal emails or policy summaries
  • PREVIEW MODE with confidence badges — overall confidence badge (high ≥80% / medium 50-79% / low <50%, colour-coded) + per-field confidence badges next to every label. Missing/low-confidence fields amber-bordered + listed in a footer warning. Suggested-preset hint banner when AI matched a shipped preset shape. Inline-editable form lets user correct any field before commit. Footer shows AI provider/model + estimated cost
  • COMMIT FLOW — `commitNaturalLanguageDraft()` resolves billing months in priority order: user-edited paymentScheduleMonths → AI suggestedPresetKey → scheduleMonthOfYear → frequency defaults. Annual-total split evenly across months. nextDueDate set to first billing month. Helpful alerts when months can't be inferred or required fields missing
  • SMART PRESET SUGGESTION — AI returns a suggested preset key when the description clearly matches a shipped preset (a municipal property tax, an annual home or monthly auto insurance, an annual vehicle registration). Preview banner surfaces the match with a one-click apply — a municipal property-tax description suggests the matching multi-installment preset shape.

Improved

  • PROMPT_SUBGROUP_ORDER extended with "Annual / Installments" slot between Recurring Templates and Income sections in the Prompts & Cache tab. Keeps the new prompt discoverable inside the existing subgroup UI
  • guard-types (+3) — 120/120. Covers: ai-prompts.ts registers extract-annual-expense template, getPromptSubgroup routes it to Annual / Installments, extract-annual-expense route exists
  • vitest (+1) — 292/292. ai-prompts-subgroups.test.ts gains a test for the new extract-annual-expense subgroup mapping + the PROMPT_SUBGROUP_ORDER insertion slot. tsc baseline unchanged at 24

v1.85.4

Annual Merge — paymentSchedule installment tracking + UpcomingInstallmentsCard + 5 Canadian presets + mark-paid creates ExpenseEntry + year carryover + AnnualObligation retirement

New

  • EXTENDED `paymentSchedule.payments[]` with optional `paid?: boolean`, `paidDate?: string`, `notes?: string`, `linkedEntryId?: string` installment-tracking fields. Preserves existing shape. Audit finding: Property Tax + Water were ALREADY RecurringExpense templates with paymentSchedule working — extending the existing schema beat adding a parallel installmentSchedule field
  • NEW `src/lib/installments.ts` — 5 pure exports: INSTALLMENT_PRESETS (5 Canadian-specific shipped schedule shapes), getUpcomingInstallments, computeInstallmentStatus, projectNextYearSchedule, shouldPromptYearCarryover. Zero fs/env deps — safe for server + client
  • NEW `POST /api/recurring/mark-installment-paid` — single atomic write that (a) creates an ExpenseEntry linked to the template with the installment amount + date, and (b) stamps `paid: true + paidDate + linkedEntryId` on the payment entry. Validates template + year + month match and refuses duplicate-paid. Response returns both updated template + entry
  • NEW `POST /api/recurring/carry-over-year` — clones a schedule block into the next year, clearing paid flags. Used by the dashboard carryover prompt when a template is fully paid for the current year AND year ≥80% through
  • NEW `UpcomingInstallmentsCard` on the dashboard — replaces v1.15.3 AnnualObligationsCard. Reads from `recurringExpenses[].paymentSchedule` (broader surface: Property Tax + Water + future installment templates). Shows current-year rollup, overdue count badge, next 3 upcoming with inline Mark-paid buttons, year-carryover prompt when ready
  • NEW installment preset picker modal on Settings > Recurring. "+ Installment" button → picks from 5 Canadian shipped presets: Property Tax (Toronto 6-installment OR Quarterly), Home Insurance Annual, Auto Insurance Monthly, Vehicle Registration Annual. Each preset ships a tip for common regional quirks (Ontario birthday-month renewal, Toronto interim/final reconciliation)
  • ANNUAL OBLIGATION RETIREMENT — `/annual-obligations` 307-redirects to `/expenses/recurring`. Sidebar entry removed (Expenses subgroup 6 → 5 items). CommandPalette entry updated to point at new location. `AnnualObligation` type + `BudgetDB.annualObligations` field marked @deprecated (kept for compile-compat; removal after v1.86.0). Dashboard fetch + state dropped. Legacy page + API route + lib helper remain as dead code (unreachable via redirect)
  • OVERDUE BADGE — dashboard card shows "N overdue" in red when any installment has `dueDate < today && !paid`. Per-row red alert icon + "Nd overdue" text on the installment list
  • YEAR CARRYOVER PROMPT — when all current-year installments are paid AND year is ≥80% through AND next-year schedule isn't populated yet, a blue banner surfaces in the card with per-template "Clone [Template Name]" buttons. Uses the v1.85.4 carryover endpoint

Improved

  • guard-types (+8) — 117/117. Covers: installments.ts exports (5 helpers), mark-installment-paid + carry-over-year routes, UpcomingInstallmentsCard exists, paymentSchedule.payments[] declares paid/paidDate/notes/linkedEntryId, next.config redirect, Sidebar does NOT include /annual-obligations (regression guard), AnnualObligation marked @deprecated
  • vitest (+20) — 291/291. installments.test.ts covers preset invariants + getUpcomingInstallments sort/filter + paid/overdue detection + computeInstallmentStatus counts + rounding + nextDue resolution + projectNextYearSchedule clone/clear + shouldPromptYearCarryover threshold checks + todayISO formatting
  • DEFERRED to v1.85.5 — AI natural-language installment adder (a plain-English description of an annual bill parsed into a template with a payment schedule). Needs a new prompt template + extract-annual-expense endpoint + UI flow. Split from v1.85.4 to keep this release tight

v1.85.3

Seasonal Enhancements 2.0 — opt-in flag + frequency-aware greying + triage accordion + inline docs + icon badge + adaptive drift detection

New

  • NEW `hasSeasonality` opt-in flag on RecurringExpense. Seasonal UI now only renders when user has EXPLICITLY marked the template seasonal (with legacy-profile grandfather clause). Non-seasonal templates see a compact opt-in prompt instead of the full grid. Addresses user ask: "if a recurring item doesn't have a seasonal component checked then I don't need to see the Seasonal Multiplier UI for it"
  • NEW "Seasonality overview" triage accordion at top of Settings > Recurring. Collapsible, default closed. Groups templates into 5 buckets: Configured / Enabled-but-no-profile / Undecided / Non-seasonal / Flat. Inline per-row toggles (Seasonal / Not seasonal / Disable / reset) for bulk decisions. Header shows counts. NEW `triageSeasonalityState()` + `SeasonalityTriageBuckets` helpers
  • Inline "How seasonality works" collapsible in the Seasonal panel — explains each preset (Hydro winter heating/lighting, Gas furnace spike, Flat no seasonality) + auto-derive algorithm + frequency awareness note. Renders right where the user is when they need it, not on a separate help page
  • NEW Thermometer icon badge next to template names in Settings table. Renders when template has active seasonal profile. Hover tooltip: "Seasonal forecast: 0.30×–1.80× (avg 1.00×) · preset-gas". At-a-glance visibility without expanding rows
  • ADAPTIVE DRIFT DETECTION — NEW `detectSeasonalDrift()` helper. Compares stored profile against what auto-derive would produce from latest ExpenseEntry history. When ≥1 month has drifted by > 0.10×, Seasonal panel shows blue "Recent history suggests N months have drifted" banner with one-click "Review & re-derive" button opening the existing diff-preview modal. Honors the user ask "as I log more entries, forecasts should auto-improve" without silently overwriting manual tuning
  • OPT-IN prompt when isVariable=true + hasSeasonality undefined — compact panel with explanatory text ("Is this charge seasonal — varies predictably by time of year? Telecom/insurance are not. Gas/hydro usually are.") + Enable / Mark non-seasonal buttons. "Disable seasonality" link always available in panel header for re-evaluation

Improved

  • forecastSeasonal() behavioral change — now requires BOTH isVariable AND hasSeasonality=true before applying multiplier. Previously just isVariable gated. Legacy templates with populated profile but no explicit flag are grandfathered in so no backward-compat breakage
  • guard-types (+3) — 109/109 up from 106. hasSeasonality declared; seasonal.ts exports all 4 new helpers (computeBillingMonths / triageSeasonalityState / SeasonalityTriageBuckets / detectSeasonalDrift); forecastSeasonal gates on hasSeasonality. Also bumped 3 RecurringExpense regex windows 10000 → 12000 chars (interface outgrew the ceiling)
  • vitest (+20) — 271/271 up from 251. New test coverage: hasSeasonality gate (opt-out / opt-in / legacy / undecided) + computeBillingMonths (every frequency path + paymentSchedule override) + triageSeasonalityState (5-bucket grouping + archive exclusion + flat-profile handling) + detectSeasonalDrift (null paths + detection + threshold respect)

Fixed

  • FREQUENCY-AWARE grid greying — tri-annual water no longer looks monthly. NEW `computeBillingMonths()` helper returns actual billing months based on frequency/paymentSchedule/schedule anchors. Panel greys out non-billing month inputs (strikethrough label + em-dash cost preview + muted bg) + mutes sparkline bars. Header shows "3× per year" count. Fixes the confusing all-12-months-populated display for Water tri-annual

v1.85.2

Seasonal Merge — /seasonal-profiles retired into Settings > Recurring 8th expand-panel + diff preview + sparkline + copy-from + source tracking + orphan detector

New

  • NEW 8th expand-row panel "Seasonal multipliers" on Settings > Recurring — only renders when `isVariable: true`. Replaces the retired /seasonal-profiles standalone page. Preserves every feature: 12-month grid, per-month inline cost preview, 3 presets (Hydro / Gas / Flat), auto-derive, reset, clear. Draft/commit flow keeps Save/Discard/Clear explicit
  • NEW sparkline preview — compact 12-point CSS bar chart above the month grid. Colour-coded: amber for peaks (≥1.2×), emerald for dips (≤0.8×), blue otherwise. Title-tooltip per bar shows exact multiplier + forecast dollar amount. No Recharts dep
  • AUTO-DERIVE gets a diff-preview modal — fixes the v1.15.4 regression risk where clicking Auto-derive silently overwrote manual tuning. Modal shows current vs proposed side-by-side (Month / Current / Proposed / Δ / New $/mo), meaningful-delta rows highlighted amber. Apply sets the draft (Save still needed to persist); Cancel discards. Mirrors v1.84.6 Populate-from-receipt pattern
  • Auto-derive window selector — inline 12 / 24 / 36 month buttons next to the derive action. `deriveProfileFromHistory` gained `monthsWindow?: number` param. Low-volume templates (quarterly utilities, rarely-paid bills) benefit from wider windows for smoother averages. Per-template state
  • NEW Copy-from-another-template dropdown — lists other templates with populated seasonalProfile. Useful for adding a new variable bill with a known shape (e.g., new hydro provider → match existing hydro curve). Source stamps as `copied-from-<templateId>`
  • NEW `seasonalProfileSource` field on RecurringExpense — provenance stamp for each profile write: `manual` / `derived-Nmo` / `preset-<key>` / `copied-from-<id>`. Surfaces as a subtle badge in the panel header so you can tell at a glance where current values came from
  • Orphan detection + "Promote to variable" banner — new `detectOrphanSeasonalProfiles()` helper flags templates with populated non-flat seasonalProfile but `isVariable: false` (values would be silently stranded). Amber banner above the Recurring table with per-template promote buttons. Defense-in-depth: current data has zero orphans but the code is ready for future drift
  • /seasonal-profiles route retired — 307 redirect to /configurations?tab=recurring in next.config.ts. Sidebar nav entry removed (Expenses subgroup drops 7 → 6 items). CommandPalette entry updated to new label "Seasonal Multipliers (inside Settings > Recurring)" pointing at the new location

Improved

  • guard-types gains 5 assertions: seasonalProfileSource declared; seasonal.ts exports DEFAULT_SEASONAL_PRESETS + detectOrphanSeasonalProfiles; deriveProfileFromHistory accepts monthsWindow; next.config.ts redirects /seasonal-profiles; Sidebar does NOT list /seasonal-profiles (regression guard). Total 106/106 up from 101/101
  • vitest gains 22 tests in new src/lib/__tests__/seasonal.test.ts — seasonalMultiplier edge cases + forecastSeasonal dispatch + deriveProfileFromHistory base case + insufficient data + v1.85.2 monthsWindow filter (both directions) + detectOrphanSeasonalProfiles + preset-registry invariants. Total 251/251 up from 229/229. tsc stays at 24 baseline

v1.85.1

Insights UX Polish — contrast fix + sticky header + chromeless refactor + per-tab sticky controls + subgroup collapsibility

New

  • STICKY PAGE HEADER — `/insights` title + subtitle + tab bar now stay pinned at the top of the scroll container. Uses `ResizeObserver` on a `headerRef` to measure the rendered height dynamically so downstream per-tab sticky rows can stack directly below. Active tab gets `font-semibold` + `bg-card/60` + border-accent for unmistakable affordance. Mirrors the v1.83.7 pattern from `/transactions/import` + `/configurations`
  • CHROMELESS PROP REFACTOR — `LogsContent`, `AccuracyContent`, `ProblemVendorsContent` all accept `{ embedded?, stickyOffset? }` props. When embedded, the outer `flex-1 overflow-y-auto` wrapper is skipped so the host page owns the scroll (fixes the nested-scroll trap that caused the v1.85.0 page header to disappear on scroll). When a `stickyOffset` is passed, the internal control rows pin at that offset
  • PER-TAB STICKY CONTROLS — each Insights tab pins its essential knobs below the page header. AI Usage: 5 stat cards (today/MTD/YTD/all-time/calls). Prompt Frequency: Stale-only checkbox + sort dropdown + counter. Logs: header + stats + full filter bar (categories / levels / search / Auto / Clear). Accuracy: 5 KPI cards (Classifications / Corrections / Corrected Rate / Avg Confidence / Total Cost). Problem Vendors: threshold + lookback filter card
  • NEW Settings toggle — `stickyControlsEnabled` (default true). Exposed in Configurations General tab under a new "Layout density" section. Power-user escape hatch for compact scrolling — when off, /insights reverts to all-scrolling behavior with no sticky header or sticky control rows
  • PROMPTS & CACHE subgroups collapsible — the 6 subgroups inside "CSV Import & Classification" (Pipeline Stages / Ad-hoc Rows / Recurring Templates / Income one-shot / Income two-step / Reclassification) are now individually collapsible. Each subgroup header is a clickable button with chevron + template count + per-subgroup customized-override count badge. Default expanded (matches v1.84.11 first-visit look). Collapse state persists in localStorage as `prompts.collapsedSubgroups`. Subgroup labels bumped to `text-primary font-semibold` for clickable affordance

Improved

  • guard-types gains 6 assertions (total 101/101): /insights wrapper does NOT use bg-primary text-primary (regression guard); /insights has sticky page header via ref={headerRef} + sticky top-0; LogsContent / AccuracyContent / ProblemVendorsContent accept embedded + stickyOffset props; Settings.stickyControlsEnabled declared
  • Scope decision locked: Seasonal merge shifts to v1.85.2 (was v1.85.1); Annual full schema merge shifts to v1.85.3 (was v1.85.2); Phase 11 Events shifts to v1.86.0

Fixed

  • CONTRAST REGRESSION FIXED — the v1.85.0 `/insights` page wrapper used `bg-primary text-primary`, both of which resolve to dark forest green (`--color-primary: #2d5a3d`). Dark-green-on-dark-green tabs were nearly invisible (user-reported with screenshots). Root cause: copy-paste from a legacy page pattern. Fix: removed `bg-primary text-primary` from the outer wrapper, added `bg-page` on `<main>` (matches every other page). Regression guard in `scripts/guard-types.mjs` prevents re-introduction

v1.85.0

Phase 10 Insights Hub — /insights tabbed observability page + sidebar reorg + FX card + Trends smart-callouts + deficiency cleanup

New

  • NEW `/insights` tabbed page consolidates Logs + Accuracy + Problem Vendors + new AI Usage + new Prompt Frequency into a single URL-synced surface. Reuses `LogsContent` / `AccuracyContent` / `ProblemVendorsContent` inner components from the legacy pages (now exported) — no code duplication, every feature of the old pages survives
  • NEW `GET /api/insights/ai-usage` — server-side aggregation of AI-category log entries. Returns totals (today / MTD / YTD / all-time / call count), 30-day daily cost breakdown, by-model + by-provider + by-template tables with stale flag (not called in 30d)
  • AI Usage TAB — 5 stat cards, 30-day bar chart (inline CSS, no Recharts required), cost-by-model + cost-by-provider tables. Zero-cost days render as flat bars for stable x-axis
  • PROMPT FREQUENCY TAB — sortable per-template table (calls / cost / lastUsed / duration) with Stale-only filter + "Registered but never called" cross-reference against PROMPT_TEMPLATES registry (surfaces cleanup candidates)
  • 307 REDIRECTS: `/logs` → `/insights?tab=logs`, `/accuracy` → `/insights?tab=accuracy`, `/accuracy/problems` → `/insights?tab=problems` via `next.config.ts`. Legacy bookmarks keep working; can flip to 308 permanent after a few releases
  • SIDEBAR IA OVERHAUL — added section headers (Transactions / Expenses / Analytics / Insights / Settings), collapsible Expenses group with chevron (state persisted in localStorage as `sidebar.sectionCollapsed`), reduced nav count from 19 → 16 (removed three top-level observability entries now in /insights tabs)
  • NEW `FXExposureCard` on dashboard — historical YTD FX fees complement to the v1.17.3 forecast-oriented CurrencyExposureCard. Aggregates across `oneTimeExpenses` + `expenseEntries` + `incomeEntries` where `foreignCurrency` is set: total CAD paid via FX, commissions paid (amber when > 0), per-currency breakdown with rate range. Silently hides when no FX activity
  • TRENDS SMART-CALLOUTS — new `TrendsSmartCallouts` component above the chart shows latest month vs 3-month baseline deltas for the selected categories. Surfaces top 3 movers + aggregate card. Colour-coded (amber for +5% growth, emerald for -5% shrink). Removes need to eyeball the chart for drift spotting

Improved

  • ORPHAN PAGES verified as already-redirects — no deletion needed. /bills, /subscriptions, /fixed-expenses, /expenses/ad-hoc/import are 5-line server-side redirect stubs that forward to /expenses/recurring or /transactions/import. They behaviorally satisfy Phase 10 item 10I without needing file deletion. Item closed in roadmap
  • guard-types.mjs gains 9 assertions: /insights page exists, /api/insights/ai-usage route exists, LogsContent + AccuracyContent + ProblemVendorsContent exported from their pages, next.config.ts redirects wired, FXExposureCard exists, matcher combined-confidence gate applied (deficiency #7), transfer-pairs uses Date.UTC (deficiency #8). Total 95/95 up from 86/86
  • Vitest 229/229 passing — new test for deficiency #7 (weak combined-confidence filtered even when vendor is perfect) + refined the existing ±10% tolerance test to use a delta that stays above floor. tsc baseline unchanged at 24

Fixed

  • DEFICIENCY #7 FIXED — real behavioral bug in `src/lib/template-alias-match.ts` (not just naming as originally audited). Combined confidence was only gated before the amount score was folded in, so a high-vendor + poor-amount match could return `borderline: false` + low combined, which the Transaction Import auto-link effect would silently treat as auto-link. Added post-compute gate `if (confidence < combinedFloor) continue`. Renamed locals `vendorThreshold` → `combinedThreshold`, `borderlineFloor` → `combinedFloor` with docstrings explaining what the thresholds actually measure. Regression test added
  • DEFICIENCY #8 FIXED — `toDayNumber()` in `src/lib/transfer-pairs.ts` now uses `Date.UTC()` instead of `new Date(y, m-1, d)`. Eliminates DST boundary ±1 day errors at the ±3 day DATE_WINDOW_DAYS edge (previously could drop a valid transfer pair straddling spring-forward or fall-back)

v1.84.11

Prompts & Cache Polish — Sub-Group Labels + Active-Variant Badges + Retro-Classify Cross-Link

New

  • NEW `getPromptSubgroup(templateId)` in src/lib/ai-prompts.ts — returns a stable sub-group label inferred from the id prefix. 9 buckets: Pipeline Stages / Ad-hoc Rows / Recurring Templates / Income (one-shot) / Income (two-step) / Reclassification / Receipts / Insights & Meta / Other. Pure — safe for server + client
  • NEW `PROMPT_SUBGROUP_ORDER` readonly array + `resolveBareVariantPair(templateId)` exports. Order matches pipeline execution (Pipeline → Ad-hoc → Recurring → Income → Reclassification → Receipts) so the UI reads top-to-bottom. Pair resolver returns `{ baseId, bareId, hasPair }` — normalizes both directions so `classify-recurring-l1` and `classify-recurring-l1-bare` map to the same pair
  • PROMPTS UI — sub-group section dividers in crowded categories. Inside "CSV Import & Classification" the 20+ templates now split into 6 sub-groups with small uppercase-muted headers. Threshold: >4 templates + >1 subgroup (short categories stay flat). Finding the right prompt drops from scan-20 to scan-3
  • ACTIVE / INACTIVE BADGE on every paired bare-variant prompt. When a template has a matching `-bare` pair, the UI shows an emerald "active" badge on whichever variant is currently in use — determined by `shouldInjectDescriptions(baseId, settings.promptDescriptionInjection)`. Tooltip explains the logic. Tells the user WHICH of the two variants actually runs without cross-referencing the Description Injection card
  • RETRO-CLASSIFY PREVIEW DIALOG footer link. The v1.84.8 "Classify N templates" preview now shows "Uses the Recurring L1 / L2 / L3 prompts. Edit in Prompts & Cache ↗". Click closes the dialog, switches the Settings tab to Prompts, and pre-expands the csv-classification category so the user lands on the editable prompts. Direct navigation to `?tab=prompts` also auto-expands the category for the same discoverability win

Improved

  • AUDIT CONFIRMED: the v1.84.7 recurring classify prompts (classify-recurring-l1 / l2 / l3) and income two-step prompts (classify-income-l1 / l2) + their 5 bare-variant companions were ALREADY exposed in /configurations?tab=prompts. Nothing to add. The UI iterates `PROMPT_TEMPLATES.filter(t => t.category === cat)` dynamically — no hardcoded allowlist. Real issue was discoverability: 20+ CSV-classification prompts rendered flat under one section
  • SCOPE DECISION: Seasonal Profiles + Annual Obligations deferred. User-initiated resequence — Phase 10 Insights hub (v1.85.0) stays next; Seasonal merge + Annual enhance consolidate to v1.86.0 with all 4 enhance tracks (quick-add buttons, next-due-date schedule math, seed example data, empty-state onboarding). Audit confirmed seasonalProfile IS a live forecasting consumer (/api/balance reads it) — merging won't regress balance, just relocates the editor
  • guard-types.mjs gains 1 assertion (ai-prompts.ts exports the 3 new helpers). Total 86/86 up from 85/85. Vitest gains 13 tests in src/lib/__tests__/ai-prompts-subgroups.test.ts covering every id family + order invariants + bare-pair resolution both directions. Total 228/228 up from 215/215

v1.84.10

Borderline Alias Yes/No Learn Loop — Auto-Link Chip + Banner + Undo Toast + Keyboard Shortcuts + Safety Warnings

New

  • NEW `src/lib/alias-signature.ts` — pure client-safe signature primitive. `computeAliasRejectSignature({ normalizedVendor, amount, templateId })` returns a plain concatenated key. Split out so the Transaction Import client bundle doesn't drag in fs/crypto from the log module. Mirrors the v1.84.8 vendor-similarity split out of vendor-cache
  • NEW `src/lib/alias-rejects-log.ts` + `data/logs/alias-rejects.jsonl` append-only audit log. Every "No" / "Always skip" click records `{ timestamp, signature, templateId, templateName, vendor, amount, decision: "no" | "always-skip", note? }`. `readRejectedSignatures()` returns a Set for O(1) matcher filtering. TTL = forever. Lazy LOG_PATH resolution so tests can chdir cleanly. Mirrors the transfer-pairs / recurring-dismiss / recurring-unhold / auto-override log family
  • NEW `GET + POST /api/alias-rejects` route handler. GET returns `{ signatures: string[] }` (supports `?full=1&limit=N` for future audit UI). POST validates the body and server-computes the signature from normalized inputs so the client never needs to pre-hash — keeps normalization authority server-side
  • Transaction Import — SILENT AUTO-LINK CHIP for high-confidence matches. Rows whose alias-match confidence ≥ `aliasMatchVendorThreshold` (default 0.85) auto-flip to `destination: recurring` + `recurringExpenseId`. A green sub-row renders: "Auto-linked to [Template] (92% confidence · exact alias) [Undo]". Undo restores the row's pre-auto-apply state. Matches respecting the persistent reject-set silently skip auto-link too
  • Transaction Import — BORDERLINE YES/NO/ALWAYS-SKIP BANNER. Confidence in the `[aliasPromptFloor, aliasMatchVendorThreshold)` band (default 0.75–0.85) surfaces an amber sub-row. Yes → PUT /api/recurring appends normalized vendor to `template.vendorAliases[]` + links the row. No → POST /api/alias-rejects logs rejection (signature scoped to vendor+amount+templateId). Always skip → same endpoint with `decision:"always-skip"` (audit-only distinction; both block future prompts for the signature)
  • UNDO TOAST (10s auto-dismiss) after every Yes click. Floating bottom-right toast shows "Added alias to [Template] · Undo · ×". Undo PUTs the template's vendorAliases[] back to its pre-add snapshot AND unlinks the row AND reopens the banner so the user can re-consider. Cleared on parse / manual dismiss / timeout
  • KEYBOARD SHORTCUTS Y / N / S apply to the first visible borderline banner. Shortcut hint "Y / N / S" shown on the first banner only. Ignored when the active element is an input / textarea / select so typing in the vendor cell never triggers a Yes/No
  • YES-CLICK SAFETY WARNINGS. (1) Conflict check — scans all OTHER non-archived templates for the normalized alias; if found, window.confirm surfaces the conflicting template name(s) and asks to proceed anyway. (2) Soft cap check — if template.vendorAliases.length is already at or beyond `Settings.aliasMaxPerTemplate` (default 20), warns before adding. Both non-blocking (user can always proceed) — prevents silent unbounded growth + cross-template claim drift
  • THREE new Settings fields on /configurations?tab=recurring. `aliasPromptFloor?: number` (default 0.75) — lower bound of borderline band, below which matches are ignored entirely. `aliasPromptsEnabled?: boolean` (default true) — master toggle for the prompt UI (auto-link still fires when off). `aliasMaxPerTemplate?: number` (default 20) — soft cap before Yes-click warning. All three optional + backward-compat — a pre-v1.84.10 Settings object behaves identically to v1.84.8

Improved

  • MATCHER EXTENSION — `matchIncomingRowToTemplate()` now accepts `opts.promptFloor?: number`. When set, overrides the v1.84.8 hard-coded threshold-minus-BAND formula. v1.84.8 callers (auto-unhold pre-check) pass no promptFloor and retain the legacy behavior. `DEFAULT_ALIAS_PROMPT_FLOOR = 0.75` exported for reference
  • guard-types.mjs extended by 7 assertions — alias-rejects-log exports, pure alias-signature client-safety guard (asserts NO fs/crypto imports), /api/alias-rejects route with GET + POST, three new Settings fields, matcher promptFloor opt. Total now 85/85 up from 78/78
  • Vitest: new `alias-rejects-log.test.ts` with 11 tests (signature determinism + template-scoping + amount-rounding + vendor-sensitivity + append/read round-trip + Set dedup + empty-file read + corrupt-line skip + limit cap + default-limit + JSONL line layout) plus 3 new promptFloor tests in template-alias-match.test.ts. Total suite 215/215 passing (was 201/201)

v1.84.9

9N Merge-Semantics EXPANDED — Receipt Precedence + Legacy Upgrade + rawMemos + updatedBy Gate + Vendor Cache from Imports

New

  • NEW `src/lib/merge-override.ts` — three pure decision primitives that gate all 9N override rules: `canOverride(target)` (false when `updatedBy === "user"`); `classifyUpgradeEligible(existing, incoming, hierarchy)` (full L1+L2+L3 incoming + non-legacy incoming + legacy/empty existing + canOverride); `appendRawMemo(existing, incoming)` (case-insensitive + whitespace-collapsed dedup append)
  • **9N-A receipt precedence** — `mergeExpenseFromReceipt` now OVERWRITES `expense.category/subCategory/subCategory2` with `receipt.l1/l2/l3Category` when linked. Also overwrites `expense.date` with `receipt.extractedData.date` (purchase date wins over import settlement date). Gated by `canOverride` so user-curated rows are immune. Fires on both auto-match (`/api/receipts/match`) AND manual-link (`PUT /api/receipts/:id`)
  • **9N-B legacy → current classification upgrade** on import merge. `mergeExpenseFromImport` + `mergeIncomeFromImport` accept optional `opts.hierarchy`; when passed AND incoming has full L1+L2+L3 triple AND incoming is non-legacy AND existing is legacy-or-empty AND `updatedBy !== "user"`, the existing row's classification is atomically replaced. Stamps `updatedBy: "import"`. `POST /api/expenses/enhance` + `POST /api/income/enhance` now pass the appropriate hierarchy
  • **9N-C rawMemos[] append with dedup** — every import merge appends `incoming.rawMemo` to `OneTimeExpense.rawMemos[]`, deduped case-insensitive + whitespace-collapsed. Additive provenance (NOT destructive) so this rule bypasses the `updatedBy: "user"` gate — user edits don't block provenance growth. Dedup prevents re-imports from ballooning the list
  • **9N-D universal `updatedBy` override gate** — every 9N rule respects `updatedBy === "user"` as immutable. User-edited rows are NEVER auto-overwritten. Schema foundation from v1.84.1 + write-path stamping from v1.84.2+ finally becomes load-bearing
  • NEW `src/lib/auto-override-log.ts` — append-only audit trail at `data/logs/auto-overrides.jsonl`. Every 9N rule firing writes `{ timestamp, rule, source, targetId, targetKind, changes: [{ field, before, after }], sourceRef, context }`. Consumers can audit why a category changed. Pattern mirrors transfer-pairs-log / recurring-dismiss-log / recurring-unhold-log
  • Transaction Import edits FEED Vendor Cache. After a commit succeeds, committed adhoc rows are scanned for corrections where final category/subCategory/subCategory2 differs from the classifier snapshot. Each correction fires `PUT /api/vendor-cache` with `source: "manual"` so next import hits cache instead of re-running AI. Pre-v1.84.9 only /expenses/ad-hoc inline edits fed the cache — import corrections were lost. Status bar now reads `· N cache corrections`

Improved

  • Merge helpers return shape extended with optional `overrideLog: OverrideLogEntry[]`. Enhance routes + receipt-match paths consume it and flush to jsonl after DB write succeeds (a failed write with a populated log would be misleading — audit trail stays truthful)
  • guard-types.mjs extended by 3 assertions — `merge-override.ts` exports, `auto-override-log.ts` export, expense-merge type exports. Total 78/78 up from 75/75
  • Vitest: new `merge-override.test.ts` (15 tests) + `expense-merge-overrides.test.ts` (14 tests). Covers all 4 rule families with edge cases. Total suite 201/201 passing (21 merge + 12 ai-batch + 32 detector + 27 cost + 33 schedule + 17 missing + 14 gate + 16 alias + 15 primitives + 14 override-rules)

v1.84.8

Template Smartness at Import — Last-Seen + Auto-Unhold + Retro-Classify + Alias Match Thresholds

New

  • 9T LAST-SEEN indicator on every template row in Settings > Recurring. Shows "Last seen: Apr 16, 2026 · 2d ago" below the Next expected charge line, derived from `max(linkedEntry.date, retroTaggedAdhoc.date)`. Historical counterpart to the forecasted next-due-date — closes the "when did I actually see this charge?" loop
  • 9S CONFIDENCE-TIERED auto-unhold on Transaction Import commit. Before writing recurring entries, scans for matches against HELD templates (enabled:false + heldSince). HIGH confidence (≥0.90 default) → silent resume + post-commit banner "· auto-resumed <template names>". BORDERLINE confidence → `window.confirm` prompt per template asking to resume. Archived templates skipped regardless of match. Audit trail logged to `data/logs/recurring-unhold.jsonl`
  • NEW `classify N templates` button in Settings > Recurring. Triggers a preview dialog listing templates with empty categories OR isLegacyCategory === true (skips archived + held). Shows reason per template ("empty" vs "legacy · was Housing/Mortgage Payment") + estimated cost (~3 AI calls × N). Confirms to fire `POST /api/recurring/retro-classify` which runs v1.84.7 classify-recurring-l1/l2/l3 against synthetic rows (template.name + provider + amount + nextDueDate) and PATCHes each template. Result banner shows Processed/Updated/Skipped/Errors
  • NEW `src/lib/template-alias-match.ts` — import-time template matcher. Scores incoming rows against recurring templates via exact alias match (1.0 confidence), vendor bigram similarity against name/provider/aliases (0.7-1.0), and amount proximity (±10% of `amount` or `amountPreTax`). Confidence = 0.7 × vendor + 0.3 × amount-proximity. Finally consumes the v1.84.2 `vendorAliases[]` schema that had been sitting unused
  • THREE new Settings fields — `aliasMatchVendorThreshold` (default 0.85), `aliasMatchAmountTolerance` (default 0.10 = ±10%), `autoUnholdConfidenceThreshold` (default 0.90). Editable inline in a new "Import-time template matching" panel on Settings > Recurring. Tooltips explain tradeoffs. User-configurable per the clarifying round
  • NEW `src/lib/recurring-unhold-log.ts` — append-only jsonl audit trail at `data/logs/recurring-unhold.jsonl`. Each entry captures `{ timestamp, templateId, templateName, triggerRow, confidence, matchKind, decisionPath (silent vs prompt), previousHeldSince, previousHeldUntil }`. Pattern mirrors v1.83.2 transfer-pairs-log and v1.84.1 recurring-dismiss-log

Improved

  • REFACTOR: pure similarity helpers extracted to `src/lib/vendor-similarity.ts`. The old chain `vendor-cache → logger → fs` broke client bundling when Transaction Import page dynamically imported `template-alias-match`. New module has zero IO / env-specific deps so it's safe from both server and client contexts. `normalizeVendor` + `similarity` re-exported from `vendor-cache.ts` for backward compatibility. `recurring-detector` (v1.84.1) + `template-alias-match` (v1.84.8) both import from the pure module now
  • guard-types.mjs extended by 6 assertions — 3 Settings fields + 1 template-alias-match export + 1 retro-classify route + 1 recurring-unhold-log export. Total now 75/75 up from 69/69
  • Vitest: new `src/lib/__tests__/template-alias-match.test.ts` with 16 tests covering exact-alias / case-insensitivity / amount-tolerance gates / borderline flag / archived-skip / held-include / best-of-N / amountPreTax preference / config overrides. Total suite 172/172 passing (21 merge + 12 ai-batch + 32 detector + 27 cost + 33 schedule + 17 missing + 14 gate + 16 alias)
  • Scope decisions from clarifying round: SHIPPED = last-seen + confidence-tiered auto-unhold + retro-classify + threshold sliders. DEFERRED to v1.84.9+: borderline-zone Yes/No prompt in the import review card (adds an alias on Yes, logs reject on No) + self-optimizing threshold adjustment based on response history. v1.84.8 keeps the matcher + thresholds in place so future wire-in is incremental

v1.84.7

AI Classify for Recurring Templates + Income Two-Step (opt-in) + Vendor-Cache Hierarchy Gate

New

  • THREE new AI endpoints — `/api/ai/classify-recurring-l1/l2/l3`. Mirror the ad-hoc L1/L2/L3 classifier shape but read `recurringCategoryHierarchy` + `recurringCategoryDescriptions` so recurring rows get bill-world categorizations (Mortgage, Telecom, AI Subscription) instead of ad-hoc ones. Closes a pre-v1.84.7 gap where type-stage flagged rows as "recurring" but no downstream classifier fired — rows sat with empty category forcing manual picker usage
  • Transaction Import pipeline now dispatches adhoc + recurring rows IN PARALLEL at stages 2/3/4 via Promise.all. Wall-clock parity with the old adhoc-only path when only adhoc rows exist; mixed imports run both branches concurrently. Separate `l1ByIndex` / `rl1ByIndex` + `l2ByIndex` / `rl2ByIndex` maps thread L1/L2 outputs per branch so categories don't cross-leak between hierarchies
  • NEW income two-step classifier — `classify-income-l1` picks L1 + moneyInType, `classify-income-l2` picks L2 given the assigned L1 (grouped per L1 with focused prompts). Additive to the existing one-shot `classify-income` endpoint. Two-step is ~2x AI cost but higher accuracy. Settings toggles `incomeClassificationMode` (default `one-shot` for fast initial import) and `incomeReclassificationMode` (default `two-step` for user-triggered reclassify — modal lands later)
  • Vendor-cache HIERARCHY-MEMBERSHIP gate. New `cachedCategoryValidForHierarchy(entry, hierarchy)` + `lookupVendorCacheValidated(vendor, hierarchy, cache?)` in `src/lib/vendor-cache.ts`. All classify-recurring-* routes gate cache hits through the recurring hierarchy so ad-hoc categories never paste onto recurring rows. Also fixes a pre-existing bug on the ad-hoc path where cached categories from a renamed/deleted L1 would leak through. `lookupVendorCacheValidated(vendor, undefined)` falls through to the un-gated lookup for backward compatibility

Improved

  • Status bar shows `L1 adhoc 12/12 + recurring 3/3` side-by-side when both branches fire. Collapses to `L1 12/12` when only one branch had rows. Income mode noted explicitly: `income (2-step)` when two-step mode is on. Lets the user confirm at a glance that recurring rows classified successfully — they were invisible in the previous pipeline
  • FIVE new prompt templates + bare variants registered in `src/lib/ai-prompts.ts`. Recurring prompts explicitly frame transactions as "recurring bills and subscriptions — monthly / yearly / repeating charges" to nudge the AI toward bill-world categorizations. Income two-step prompts split the previously-combined category + subCategory + moneyInType output across two focused calls. `DEFAULT_PROMPT_DESCRIPTION_INJECTION` extended with symmetric defaults (L1 on, L2/L3 off)
  • TWO new Settings fields — `incomeClassificationMode?: 'one-shot' | 'two-step'` + `incomeReclassificationMode?: 'one-shot' | 'two-step'`. Transaction Import reads the first on mount and threads through `runAIClassification`. Reclassification field is plumbed for a future income reclassify modal
  • guard-types.mjs extended by 14 assertions — 2 Settings fields, 5 route files exist, 5 prompt IDs registered, 2 vendor-cache exports. Total now 69/69 up from 55/55. Prevents silent deletion of v1.84.7 classifier infrastructure during future refactors
  • Vitest coverage: new `src/lib/__tests__/vendor-cache-hierarchy-gate.test.ts` with 14 tests covering `cachedCategoryValidForHierarchy` (full triple valid / L1 missing / L2 missing / L3 missing / partial entries / empty fields) and `lookupVendorCacheValidated` (returns valid hits / filters invalid hits / falls through when hierarchy undefined). Total suite 156/156 passing (21 merge + 12 ai-batch + 32 detector + 27 cost + 33 schedule + 17 missing + 14 gate)
  • Retro-classify-templates button INTENTIONALLY DEFERRED to v1.84.8. Per clarifying round, v1.84.7 ships import-pipeline path only. Retro-classify infrastructure is already in place (the 5 new endpoints accept arbitrary TransactionInput batches) so a future release can wire a synthetic row (template.name + provider + amount) through them

v1.84.6

9J Receipt Auto-Populate + 9K Missing-Charge / Missing-Receipt Indicators + expectsReceipt

New

  • NEW `expectsReceipt?: boolean` flag on RecurringExpense. Default off. Gates the 9K "logged-but-no-receipt" indicator so Netflix-style templates don't nag. Flip on per-template for things like mortgage, gas, gym, insurance renewals where you always keep a receipt. Editable via checkbox in the Pricing & Discounts panel (v1.84.3)
  • NEW `src/lib/recurring-missing.ts` — pure-function detector for two warning types. `findMissingCharge(template, entries, retroTaggedAdhoc, today)` fires when a schedule-anchored template has passed its expected date + tolerance AND no matching entry lies within ±tolerance. `findMissingReceipts(template, entries)` fires when `expectsReceipt: true` AND linked entries lack `receiptMatchId` + attachments. Shared suppression: archived / held / disabled templates skip both
  • 9K missing-charge indicator on Settings > Recurring table + /expenses/recurring Templates view. Amber pill "⚠ missing charge" with tooltip showing expected date, days past due, and tolerance used. Respects v1.84.4 tolerance defaults (weekly 2d, monthly 3d, quarterly 5d, annual 10d) so Telus-style weekend pushes don't false-positive
  • 9K missing-receipt indicator on both surfaces. Amber pill "⚠ N no receipt" when template.expectsReceipt is true AND N linked entries have no receiptMatchId + no attachments. Counts retro-tagged OneTimeExpense rows (v1.84.1) alongside regular ExpenseEntry rows
  • 9J "Populate from receipt" button — Bundle Children panel. Appears only when template has a linked recurring receipt with ≥2 line items. Deliberately manual (not auto) so curated children are never silently clobbered. Click opens preview dialog
  • 9J Preview dialog with Replace vs Append choice. Shows candidate children projected from the receipt's line items + sum-vs-parent check (green ✓ within $0.01 or amber delta). When template has existing children: two buttons — "Replace N existing" or "Append M new". When empty: single "Add N sub-items" button. Cancel always available. Modal with backdrop-dismiss. Receipt line item amount field projects to child.amount (post-discount final)

Improved

  • guard-types.mjs extended by 1 assertion for `expectsReceipt`. Total now 55/55 up from 54/54. Prevents silent deletion of the flag that gates 9K missing-receipt
  • Vitest coverage: new `src/lib/__tests__/recurring-missing.test.ts` with 17 tests. findMissingCharge (fires past tolerance, respects grace period, skips archived/held/disabled/anchorless, counts retro-tagged ad-hoc as matching), findMissingReceipts (gated by expectsReceipt, treats receiptMatchId + attachments as present, counts retro-tagged ad-hoc), computeAllTemplateWarnings batch + both-flagged-same-template. Total suite 142/142 passing (21 merge + 12 ai-batch + 32 detector + 27 cost + 33 schedule + 17 missing)
  • Scope split confirmed — v1.84.6 shipped the receipt-presence theme from the original v1.84.5 plan (split during v1.84.5 clarifying round). AI classify → v1.84.7, last-seen/auto-unhold → v1.84.8, merge-semantics 9N expanded → v1.84.9

v1.84.5

Bundle Children Editor + Combined-View Visibility (retro-tagged ad-hoc rows)

New

  • NEW Bundle children editor — 7th section in the Settings > Recurring expand-row. Per-template CRUD for named sub-items (the internet + TV + security bundle pattern). Each child has name, amount, optional category. Add/edit/delete + reorder with ▲/▼ buttons. Zero-state explainer for simple (non-bundled) templates. Children display-only in v1.84.5 — no category rollup contribution yet (reserved for a later release once real bundle usage tells us what the reporting surface should look like)
  • Sum validation banner — warn but allow. A green check confirms the children sum matches the parent's pre-tax amount within a cent; an amber warning shows the children's sum, the parent's amount and the delta when they diverge. Save still works either way — the delta is informational so mid-edit states aren't blocked.
  • Combined-view visibility on recurring ledger. /expenses/recurring now fetches /api/expenses, filters to rows with recurringExpenseId set (from v1.84.1 accept-candidate flow), synthesizes ExpenseEntry-shaped objects tagged _retroTagged, and merges into the per-template entries map. Rows interleave chronologically with real ExpenseEntry rows. Small blue "retro-tagged" badge next to the notes column — tooltip explains they came from ad-hoc
  • Hide retro-tagged rows from /expenses/ad-hoc default view. Closes the loop from v1.84.1 where retro-tagged rows were still showing in the ad-hoc list after being claimed by a template. Blue-accented toggle button in the filter bar shows "+N linked hidden" when off, "Linked recurring (N)" when on. URL param ?showLinkedRecurring=1 auto-enables the toggle (used by the Edit link from the recurring ledger)

Improved

  • Bundle children count badge on TemplateRow — violet-tinted "3 sub-items" pill next to existing template metadata on /expenses/recurring Templates view. Tooltip lists the sub-item names. Only renders when template actually has children; simple templates skip it entirely
  • Edit link from recurring-ledger retro-tagged rows. Read-only in the recurring ledger (editing would PUT to wrong endpoint). Pencil icon now routes to /expenses/ad-hoc?highlight=ROW_ID&showLinkedRecurring=1 so the user edits in the native ad-hoc surface with the row highlighted and the toggle pre-enabled
  • Scope split from original v1.84.5. Clarifying round with Alex split into two releases. v1.84.5 = template composition (9I + combined-view). v1.84.6 = receipt presence (9J auto-populate narrowed to receipt-IS-the-bundle + 9K missing-receipt indicator + new expectsReceipt flag). Plan renumbered: AI classify → v1.84.7, last-seen/auto-unhold → v1.84.8, merge-semantics 9N expanded → v1.84.9

v1.84.4

Schedule Anchors + Drift Detection — "Next Expected Charge" + Business-Day Tolerance

New

  • NEW schedule anchors on RecurringExpense — scheduleDayOfWeek (weekly), scheduleDayOfMonth (monthly-family), scheduleMonthOfYear (quarterly / semi-annual / annual). Declarative forecasting — set "16th of each month" once and computeNextDueDate returns the right date forever. Month-end clamps (31 → Feb 28 non-leap, Feb 29 in leap years). Biweekly/every_4_weeks continue to anchor via existing firstPaymentDate
  • "Next expected charge" display on Settings table Name cell AND TemplateRow in /expenses/recurring Templates view. Shows "Next: May 12 · in 25d" computed from anchors, or italic fallback to stored nextDueDate. Derived via computeNextDueDate so Settings and Ledger always agree
  • NEW Schedule editor panel — 6th section in the Settings Recurring expand-row. Frequency-aware form fields: day-of-week dropdown for weekly, day-of-month number input for monthly, month dropdown for annual-family, first-payment-date picker for biweekly. Helper caption explains clamp behavior + the declarative principle. One-click "Use day from last due date" backfill button
  • 9M expected-vs-actual drift detection on ledger rows. Every ExpenseEntry row in /expenses/recurring ledger view now gets a drift badge next to its date: 🟢 "on time" / muted "+2d late" (business-day jitter) / amber-bold "8d late (drift)" when outside tolerance. Tolerances default per frequency: weekly/biweekly ±2, monthly ±3, quarterly ±5, annual ±10. Compares against NEAREST in-period anchor (May 19 actual vs monthly 16th anchor compares with May 16, not Apr 16 nor Jun 16)
  • Declarative-anchor guarantee: import commit loop NEVER writes to schedule anchors. ExpenseEntry.date captures the real charge date from CSV; template anchors stay at whatever was configured. Solves the business-day problem — some providers only charge on business days, so a template anchored to one day of the month occasionally settles a day or two later when that day lands on a weekend or holiday. Drift tolerance absorbs the jitter without crying wolf
  • NEW src/lib/recurring-schedule.ts pure-function helper. computeNextDueDate (handles all frequencies with month-end clamp), driftDetection (nearest in-period anchor + tolerance), isValidScheduleAnchor (per-frequency anchor completeness check), defaultToleranceDays (per-frequency business-day tolerance), inferAnchorsFromNextDueDate (powers the backfill button). Zero IO, zero mutation — pure math

Improved

  • Month-end clamp + leap-year handling. scheduleDayOfMonth 31 in 30-day months → 30th. scheduleDayOfMonth 29 in annual Feb → 28 non-leap or 29 leap, auto-adjusted year-by-year. Stored anchor stays at the configured value; clamp happens at computation time only
  • "Use day from last due date" opt-in backfill button per template. Appears in the Schedule panel when no anchors set but a valid nextDueDate exists. Clicks infer the right anchor fields for the frequency — weekly parses day-of-week, monthly parses day-of-month, annual parses both month and day. User opt-in per template so templates with irregular stored dates aren't wrongly anchored
  • guard-types.mjs extended by 3 assertions — scheduleDayOfWeek, scheduleDayOfMonth, scheduleMonthOfYear. Total now 54/54 up from 51/51. Prevents silent deletion of scheduling fields that forecasting and drift-detection depend on
  • Vitest coverage: new src/lib/__tests__/recurring-schedule.test.ts with 33 tests. isValidScheduleAnchor per-frequency, computeNextDueDate for weekly/monthly/annual/quarterly (+ user-reported May 4 and Feb 14 annual cases), month-end clamp, fallback to stored date, driftDetection on-time/within-tolerance/drift/early/null-no-anchor, defaultToleranceDays values, inferAnchorsFromNextDueDate per frequency. Total suite 125/125 passing

v1.84.3

Template Economics — Pre-Tax / Discounts / Self-Calibrating USD Rate / Scan from Ad-hoc

New

  • NEW Pricing & Discounts panel in the Settings > Recurring expand-row (5th section). Pre-tax amount inline editor (amber when derived, themed when explicit), derived tax, total, currency selector. Full discount CRUD: add / edit / delete, % vs $ toggle, start date + optional end date, reorder up/down for stacking, per-row active / expired / future / trial-suppressed badge. Summary row shows effective native amount + CAD equivalent for USD templates. Discounts now work on ALL template types (removed the telecom/insurance gate)
  • NEW amountPreTax?: number field on RecurringExpense. Lets you record the pre-tax subtotal for a recurring bill so the tax breakdown derives as the remainder, instead of always back-computing from the default HST rate. Editable inline in the Pricing & Discounts panel
  • Self-calibrating USD→CAD rate. New historicalUsdToCadRate(db) reads the last 90 days of imported rows with foreignCurrency:"USD", computes the median of their exchangeRate values. No external API dep — uses YOUR card's actually-applied conversions. Falls back to USD_TO_CAD constant (1.37) when no USD imports exist. Self-updating as you import more USD activity
  • NEW GET /api/recurring-cost endpoint — thin adapter returning { usdToCadRate, calibrated, sampleCount, daysBack, fallbackConstant }. Used by the /configurations and /expenses/recurring pages to fetch the calibrated rate once without pulling the full ledger client-side. Supports ?daysBack for non-default windows
  • NEW src/lib/recurring-cost.ts shared helper module. Functions: effectiveMonthlyCost(template, opts) combines trial-vs-post-trial, active discounts, and FX into one structured result; applyDiscountsSequentially(amount, discounts) stacks discounts in user-defined order with overshoot cap at zero; activeDiscountsAt(template, at) filters by date + suppresses during trial window; taxBreakdown(template) returns preTax/tax/total. Designed as the single source of truth for template cost math across Settings, Templates view, Dashboard, and Phase 14 budget work
  • "Scan for recurring patterns" button added to /expenses/ad-hoc page header. Third entry point alongside Settings > Recurring Templates tab button and /expenses/recurring header button. Routes to /expenses/recurring?scan=open which auto-opens the review card there — same destination, one review surface, no duplicate UI

Improved

  • USD + CAD equivalent display in the Settings > Recurring table Amount column AND the /expenses/recurring Templates view. A USD template now shows its converted CAD amount underneath, with a tooltip explaining whether the rate is calibrated from your imports or a fallback constant. The monthly-equivalent column also converts to CAD for USD templates
  • Sequential discount stacking in user-defined order — a flat credit and a percentage promo now apply in whichever order you set, with move up/down buttons in the editor. A percentage and a flat-dollar discount can combine on one line. Trial-period templates suppress ALL discounts while inTrial === true
  • guard-types.mjs extended by 3 assertions — RecurringExpense.amountPreTax, Discount interface, RecurringExpense.discounts[]. Total now 51/51 up from 48/48. Prevents a future refactor from silently breaking the Pricing & Discounts editor contract before v1.84.5 wires import-match
  • Vitest coverage: new src/lib/__tests__/recurring-cost.test.ts with 27 tests. Covers historicalUsdToCadRate (median math, window filter, currency filter, multi-table aggregation, constant fallback), activeDiscountsAt (date window, trial suppression, order preservation), applyDiscountsSequentially (stacking order, % + $, overshoot cap, combined on same line), effectiveMonthlyCost (trial / post-trial / USD conversion / fallback / trial-discount suppression), taxBreakdown (explicit vs back-computed). Total suite 92/92 passing

v1.84.2

Template UX — Sort by Category + Sticky Header + Vendor Aliases + Expand-Row

New

  • NEW vendorAliases?: string[] on RecurringExpense. Curate a list of alternate statement descriptors that should match this template (NETFLIX.COM, NFLX RECURRING CHG, "Netflix Subscription"). Normalized lowercased + trimmed, deduped case-insensitively. Stored + displayed now; v1.84.5 wires import-match to read the list when scoring incoming CSV rows so recurring charges with varying statement text still auto-link
  • Per-row expand chevron on the Settings > Recurring table. Click toggles a detail sub-row with 4 panels: Notes (inline-editable textarea), Vendor aliases (editable chips with add/remove form), Seen in the wild (distinct raw descriptors from past charges linked to this template, with count + source + lastSeen + "+ alias" promote button per row), Detection provenance (detectedFrom metadata for auto-created templates). Lazy-loads linked-memos on first expand, caches, invalidates on alias promote
  • New GET /api/recurring/[id]/linked-memos endpoint. Aggregates distinct raw descriptors across OneTimeExpense.description/vendor/rawMemos and ExpenseEntry.provider/notes for rows with recurringExpenseId matching the template. Each entry carries count + source ("adhoc" or "entry") + lastSeen + alreadyAlias flag so the UI can show which descriptors are already promoted

Improved

  • Settings > Recurring Templates rows now sort by Category ascending first, then Name ascending. Preserves the outer grouping (archived last, held second-to-last, disabled third-to-last) so visually-grouped subscriptions don't lose the archive-bottom convention. Empty category falls to the end within its group
  • Sticky table header on Settings > Recurring Templates — Google Sheets-style freeze so the column labels stay visible while scrolling through dozens of templates. Applied the v1.83.7 root-cause pattern: removed the overflow-x-auto wrapper (which was creating a new scroll container that captured sticky scrollport), added position:sticky + top:0 + z-10 + bg-page classes to each <th> cell individually
  • Accept-candidate endpoint (from v1.84.1) now auto-promotes matched rows' raw memos to the target template's vendorAliases[]. Works for both buckets — misplaced grows the existing template's alias list, newCandidate seeds initial aliases on the auto-created template. Future imports + scans match faster without manual curation
  • Settings > Recurring edits now explicitly stamp updatedBy: "user" on every PUT to /api/recurring. Wires the passive v1.84.1 schema prereq — the v1.84.5 override gate will treat user-touched templates as sacred (no auto-overwrite from future import or AI flows)
  • guard-types.mjs extended by 1 assertion — RecurringExpense.vendorAliases regression guard. Total assertions 48/48 up from 47/47
  • Plan updated: v1.84.x reshuffled. Bundle children / receipt auto-populate / missing-receipt / expected-vs-actual slip from v1.84.2 to v1.84.3 so the template-UX improvements ship immediately. Subsequent sub-releases renumbered: AI classification → v1.84.4, last-seen + auto-unhold + smart matching (now reads vendorAliases) → v1.84.5, vendor cache + merge-semantics upgrade + Seasonal/Annual fate → v1.84.6

v1.84.1

Phase 9 Part 2 — Recurring-Pattern Detector + Scan UI + Merge-Semantics Schema Prereq

New

  • New src/lib/recurring-detector.ts — deterministic pattern detector with two threshold profiles (Strict: ≥3 charges, vendor ≥0.85, amount ±$2/5%, cadence ±5d monthly, 90d history; Permissive: ≥2 charges, vendor ≥0.70, amount ±$5/10%, cadence ±10d, 60d history). Groups ad-hoc rows by Dice-bigram vendor similarity, analyzes cadence, rejects irregular patterns, and buckets results into (A) rows matching an existing template and (B) new-template candidates
  • New POST /api/recurring/detect endpoint. Accepts `{ profile?: "strict" | "permissive" }` body (falls back to Settings.recurringDetectorProfile, then "strict"). Reads current DB, filters out dismissed signatures, returns { misplaced, newCandidates, dismissedIgnored, stats }
  • RecurringDetectorProfile toggle in Settings > Recurring Templates tab. Two buttons (Strict / Permissive) above the templates table that write Settings.recurringDetectorProfile. Caption below shows the exact thresholds for the active profile
  • "Scan for recurring patterns" button in TWO places: (1) /expenses/recurring page header toolbar, (2) Settings > Recurring Templates tab header. The Settings button routes to /expenses/recurring?scan=open which auto-runs the detector on mount. Review card renders inline on /expenses/recurring above the templates view
  • Review card with two collapsible result sections: "Link to existing templates (N)" and "Create new templates (M)". Each candidate shows vendor, matched row count, median amount, cadence label (e.g. "monthly (±3d)"), and a confidence badge colored green (≥90%), amber (≥75%), or gray. Per-candidate Accept + Dismiss buttons. Includes stats line ("scanned 412 rows · 90d window · 2 previously dismissed")
  • Accept flow (9D) — "Link to existing" click → POST /api/recurring/accept-candidate with bucket:"misplaced" stamps recurringExpenseId + updatedBy:"user" on each matching OneTimeExpense (ad-hoc rows retro-tagged, no destructive conversion). "Create template" click → opens AddTemplateDialog pre-filled with detected vendor/amount/frequency/owner; on save, a single call to /api/recurring/accept-candidate with bucket:"newCandidate" creates the template AND links the seed rows atomically
  • Dismiss flow (9E) — Dismiss click prompts for an optional note, POSTs to /api/recurring/dismiss-pattern which appends a JSONL entry to data/logs/recurring-dismissed.jsonl. Future scans read the log and filter out dismissed signatures (SHA1 hash of normalized vendor + rounded amount + cadence label) so the same candidate doesn't re-surface
  • Detection provenance stamped on auto-created templates: `detectedFrom: { rowIds, detectedAt, confidence, profile }` on RecurringExpense. Read-only in UI for now; future template detail view will surface "Auto-detected 2026-04-17 from 3 charges · 92% confidence" so users can see template origin

Improved

  • NEW OneTimeExpense.recurringExpenseId? optional pointer added for retro-tagging ad-hoc rows as belonging to a recurring template (set by accept-candidate flow). v1.84.2 will add combined-view visibility on /expenses/recurring so tagged rows surface under their template
  • MERGE-SEMANTICS SCHEMA PREREQ (passive, no behavior change): updatedBy?: "user" | "ai" | "import" | "receipt" added to OneTimeExpense, ExpenseEntry, IncomeEntry, RecurringExpense, ReceiptRecord. Transaction Import commit loop now stamps updatedBy:"import" on new rows. v1.84.5 override logic will gate legacy-classification upgrades on this field — manual "user" edits are never auto-overwritten
  • MERGE-SEMANTICS SCHEMA PREREQ (passive): rawMemos?: string[] added to OneTimeExpense. Import commit stamps the raw CSV memo on new rows; v1.84.5 will accumulate additional memos from dupe merges so the detail panel can render full provenance ("Netflix was imported from AMEX as NETFLIX.COM 8887789090 AND from RBC as NFLX RECURRING CHG")
  • MERGE-SEMANTICS SCHEMA PREREQ (passive): ImportEnhancement interface extended with category / subCategory / subCategory2 / aiConfidence / rawMemo fields. Helpers currently ignore the new fields (still fill-if-missing). v1.84.5 will wire override rule: if isLegacyCategory(existing, hierarchy) && incoming non-legacy && existing.updatedBy !== "user", overwrite classifications
  • Dice-bigram similarity() function exported from src/lib/vendor-cache.ts so recurring-detector reuses the same vendor-matching primitive the vendor cache has used since v1.28.0. Detector's findMatchingTemplate adds aggressive-normalization step (strips .com/.net/.ca, trailing phone-digit runs) so a template named "Netflix" matches an AMEX memo "NETFLIX.COM 8887789090" without lowering the global similarity floor
  • guard-types.mjs extended by 15 assertions — covers UpdatedBy type alias, updatedBy on all 5 row types, OneTimeExpense.rawMemos, RecurringDetectionProvenance interface, RecurringExpense.detectedFrom, Settings.recurringDetectorProfile, and the 5 new ImportEnhancement fields. Prevents a future refactor from silently deleting v1.84.5's override gate fields. Now 47/47 assertions up from 32/32
  • Vitest coverage: new src/lib/__tests__/recurring-detector.test.ts with 32 tests covering threshold profiles, cadence classification (monthly / bi-weekly / irregular), amount stability, exclusion rules (nullified / pendingNullification / transfer pair leg / already-linked / history window / non-positive amount), misplaced vs newCandidate bucketing, signature stability + dismiss filtering, confidence ordering, sort order. Total suite now 65/65

v1.84.0

Phase 9 Part 1 — Editable Recurring Templates + Own Settings Tab + Hold Schema

New

  • New /configurations?tab=recurring Settings tab. Migrated Recurring Templates Frequency Overview out of the semantically-mismatched "Prompts & Cache" tab into its own dedicated tab. Matches the IA pattern of Imports / Dedupe / Categories / etc.
  • Frequency Overview is now fully editable. Inline-edit Name + Provider (text, saves on blur), Frequency + Owner (editable selects), Amount (number input, saves on blur). Monthly-equivalent column stays derived. Inline saving-state dims the row while the PUT is in flight
  • Per-row Hold / Resume action button. Hold prompts for optional expected-resume date → stamps { enabled:false, heldSince:now, heldUntil? }. Resume clears heldSince/heldUntil + flips enabled:true. Held rows get an amber-tinted background + "On hold" badge with tooltip. Distinct from Archive (permanent) — Hold is temporary
  • Per-row Archive / Unarchive action button. Archive stamps { archived:true, archivedReason:"cancelled", archivedDate:now }. Archived rows sort to the bottom and are dimmed
  • Category column shows L1 / L2 / L3 per template in stacked compact format. Read-only here; editing categories lives in AddTemplateDialog (create) or /expenses/recurring detail panel (edit)
  • "Add new template" button in the Recurring tab header. Opens the same AddTemplateDialog the /expenses/recurring page uses. POST hits /api/recurring; the returned record appends to the in-memory list for immediate display
  • Schema: heldSince?: string + heldUntil?: string added to RecurringExpense. Lightweight hold modeling — forecasting + balance projection already skip enabled:false, so the new fields just carry the "when paused" + "when to expect resume" metadata. v1.84.4 will add auto-unhold on matching charge detection

Improved

  • Plan file: Phase 9 split into 6 sub-releases (v1.84.0 → v1.84.5). v1.84.1 = detector + scans + ad-hoc→recurring. v1.84.2 = bundle children + receipt auto-populate + missing-receipt + expected-vs-actual. v1.84.3 = AI classification for recurring + income L2. v1.84.4 = last-seen + auto-unhold + smart template matching. v1.84.5 = Transaction-Import vendor cache + classification precedence + Seasonal/Annual fate

v1.83.7#o5wzi

Sticky Table Header Root-Cause Fix — Removed overflow-x-auto Scrollport

Fixed

  • Root-cause fix for sticky table header still misplaced after 1.83.7#7udce. The 7udce patch correctly moved position:sticky from <thead> to each <th> cell, but the real culprit was the <div className="overflow-x-auto mt-3"> wrapper around the table. Per CSS spec, overflow-x:auto forces overflow-y into auto behavior too, creating a new scroll container that captured the sticky scrollport. The header was sticking to a container that doesn't scroll vertically, so it rendered in natural flow and scrolled away. Removed the wrapper div entirely; sticky <th> cells now use the page scroll as their scrollport and settle correctly at top: toolbarHeight

v1.83.7#7udce

Sticky Table Header Fix — Per-Cell Sticky

Fixed

  • Fixed sticky table header rendering below the first data row and overlapping row 2. v1.83.7 applied position:sticky to the <thead>/<tr> but those elements are not boxes in the CSS spec — most browsers ignore sticky on them. Moved the sticky class + top-offset inline style to each individual <th> cell (which IS a valid sticky target), plus added z-10 + bg-card so rows scroll cleanly behind the header

v1.83.7

Import Page UX — Narrower Destination + Auto-Collapse Upload + Sticky Headers

New

  • Upload zone now collapsible + auto-manages. Parse lands rows → zone auto-collapses so the review table gets the screen space. Commit clears rows → zone auto-expands for the next file. Manual chevron toggle always available in the card header
  • Review toolbar (counts + AI classify status + Import button + stage progress + duplicate toggle) sticks to the top of the scroll container. Table header sticks just below via a dynamic top offset measured with ResizeObserver. Both remain visible while scrolling through hundreds of rows

Improved

  • Destination column in the review table narrowed from auto-width to a fixed w-24. Was ~2x the space it needed. Other columns keep auto-width so Vendor/Description/Category don't get cramped

v1.83.6

Template UX + Merge Preview + Ripple Link

New

  • "+ Create new template…" option in the template dropdown. Opens /expenses/recurring in a new tab with the add-template dialog pre-filled from the current row (vendor as name, amount, frequency=monthly). Query params stripped on mount so refresh doesn't re-open the dialog
  • AddTemplateDialog accepts a `defaults` prop and pre-fills name/provider/amount/frequency/type on open. Does not clobber user edits mid-session
  • Pre-commit merge preview on auto-skip duplicates. Green "✓ merging (N fields)" caption inline under the verdict badge, plus a "On commit: merge into existing row" box in the breakdown expansion listing exactly which fields will be filled. Runs mergeExpenseFromImport client-side for adhoc dupes (same function the server runs at /api/expenses/enhance)
  • Ripple template link — after you manually link one row to a template (via the dropdown OR the "Assign to [Template]" button from v1.83.4), a confirm dialog scans the session for other rows with ≥0.9 vendor similarity AND exact amount match, listing them for batch-apply. Catches the "three OrangeTheory charges in one statement" case with one confirm click

Improved

  • Recurring template dropdown sorted by vendor similarity to the row. Most-likely template now bubbles to the top instead of forcing scrolling through 18 unrelated templates. Uses the same Dice-bigram algorithm the dedupe scorer uses
  • Plan: Phase 9 (v1.84.0) extended with item 9Q (recurring L1/L2/L3 classification pipeline — new AI endpoints walking recurringCategoryHierarchy) and 9R (income L2 classification for parity). Fills the gap that recurring rows currently get destination but no category from the AI classifier

v1.83.5

Recurring Category Dropdown Fix + Dupe Breakdown Expansion

New

  • Dupe verdict breakdown expansion. Click the new chevron next to the verdict badge on any matched row to see why the scorer flagged it: a 6-factor score table (amount / date / vendor / payment method / card last 4 / description) with colored progress bars (green ≥80%, amber ≥50%, red <50%) and the weight applied to each factor. Side-by-side panel shows the incoming row vs the existing candidate it matched against — same fields in both columns so you can spot "yes that's definitely the same charge" or "no, scorer overfit" at a glance

Improved

  • Plan file extended: Phase 9 (Recurring domain, v1.84.0) now includes item 9P — smart ad-hoc→recurring conversion. When the recurring-detector scanner finds a same-vendor + amount-variance-<10% + regular-cadence pattern among historical ad-hoc expenses, the accept flow offers BOTH options: link the series to an existing RecurringExpense template, OR create a new template inline from the detected pattern (for cases where no template exists yet)

Fixed

  • Fixed recurring rows showing ad-hoc categories in the Category dropdown. When a Transaction Import row's destination is flipped to "Recurring", the Category / Sub / Detail dropdowns now populate from Settings.recurringCategoryHierarchy (Memberships / Subscriptions / Utilities / Telecom / Insurance / Mortgage / Property Tax / etc.) instead of the ad-hoc tree. Income rows continue to use incomeHierarchy as before

v1.83.4#vxxjv

AI Classify State-Timing Fix + React Key Warning Fix

Fixed

  • Fixed AI Classify not actually running its later stages. The pipeline was checking for output from a state update that hadn't landed yet, so every stage after the first was silently skipped and only its (much smaller) cost was ever spent. Stage outputs are now threaded forward directly instead of relying on that state timing, so all stages now run and propagate correctly.
  • Fixed React console warning "Each child in a list should have a unique 'key' prop" on the Features page Bug Fixes tab. 9 legacy bugFixes entries in data/budget.json had undefined id. Made the render defensive with a positional fallback key AND backfilled the 9 missing ids (all 96 entries now have unique ids)

v1.83.4

AI-Classify Diagnostics + Dupe-to-Recurring Reclassify + Manual Nullify + Pending Refund

New

  • Per-stage diagnostic counters in the AI-classify pipeline. Status bar now reports "Classified type 12/15 · income 2/2 · L1 11/12 · L2 10/11 · L3 8/10" after completion. When a stage has input but returned zero results despite no explicit error, surfaces "AI returned empty classifications — check /api/ai/* endpoints and provider API keys."
  • Dupe-to-recurring one-click reclassify. When the dedupe scorer auto-skips a row because it matched an ExpenseEntry, a purple "Assign to [TemplateName]" button now appears next to the duplicate badge. One click flips destination=recurring + sets recurringExpenseId + clears dupe flag + re-includes the row. Closes the "this isn't a duplicate, it's a new occurrence of my subscription" gap Alex flagged
  • Manual nullify button on ad-hoc expense rows. EyeOff icon in each row's action cell → prompt for reason (charged twice / refund / returned / etc.) → PUT /api/expenses stamps nullified + nullifiedReason. Row drops from active totals, appears in the existing Nullified section
  • Pending-nullification flag. New pendingNullification + pendingNullificationReason schema fields. Mark expenses as "soon to be refunded" (return shipped, price-match, warranty) while they still count in totals. Amber "pending" chip surfaces in the amount cell + RotateCcw toggle button in the action column. Phase 12 refund-detection flow will auto-clear this when a matching refund lands
  • 12 new vitest tests for classifyInBatches error paths + summarizeBatchErrors formatting. Total test count now 33/33 passing across 2 test files (expense-merge.test.ts + ai-batch.test.ts)

Improved

  • Balance + Trends icon differentiation. Previously both used BarChart3. Balance → Scale (balance-sheet semantics); Trends → TrendingUp (where's this going)

Fixed

  • Fixed AI-classify diagnostic blindness. classifyInBatches now returns { results, errors, totalCost } instead of silently dropping malformed batch responses. Every failure mode (HTTP error, non-JSON body, missing expected array key, fetch network throw) is captured as a BatchError and surfaced via the import page's aiError state

v1.83.3

Phase 8 Part 3 — PDF Scaffold + Enhance Parity + Merge-Helper Tests

New

  • 8G — PDF statement parser scaffold. Added "pdf" to BankFormat + a rowsFromPdf() stub that returns an empty BankParseResult with a "not yet implemented" warning. UploadDropZone accepts .pdf so users can drop PDFs without OS rejection. POST /api/transaction-files returns a structured 415 with code=pdf_not_implemented when a PDF arrives. Import page renders a persistent amber banner explaining the CSV/XLS workaround
  • 8I — New POST /api/expense-entries/enhance endpoint. Mirror of /api/expenses/enhance for the recurring ledger, using mergeEntryFromImport with fill-if-missing semantics, enhancedFrom audit-trail stamping, and budget.json backup on mutation
  • 8I — New POST /api/income/enhance endpoint. Mirror for the income ledger using mergeIncomeFromImport. Closes the silent data-loss hole where income duplicates dropped their FX + statement metadata
  • 8I — Transaction Import commit flow fires all three enhance paths. Payload collection now dispatches by matchDestination into adhoc/recurring/income buckets and fires /api/expenses/enhance + /api/expense-entries/enhance + /api/income/enhance in parallel. Status bar sums enhanced counts across destinations. Standalone duplicates-only drops also fire all three routes
  • 8I — Vitest config + 21 merge-helper unit tests in src/lib/__tests__/expense-merge.test.ts. Covers mergeExpenseFromImport, mergeEntryFromImport, mergeIncomeFromImport, unmergeReceiptFromExpense, stampEnhancementAudit, summarizeChangedFields. Vitest pinned to ^2.1.9 for Vite 5 compat with the workspace root. All tests pass

v1.83.2

Phase 8 Part 3 — Transfer-Pair Persistence (Auto + Scan)

New

  • 8F — Deterministic transfer-pair matcher. New src/lib/transfer-pairs.ts exports findDeterministicPairs(candidates) that pairs same-owner, opposite-destination (one income + one expense) rows with amounts within $0.01 and dates within ±3 days, rejecting self-pairs via sourceFileId/cardLastFour discrimination. Returns pairs in canonical order with a fresh UUID pairId and a confidence score (0.5–1.0)
  • 8F — Append-only jsonl audit log at data/logs/transfer-pairs.jsonl. Every pair decision (pairId, leg IDs, owner, amount, dateDelta, confidence, reason, source tag) appended atomically. Lets a bad pair be audited or rolled back long after the fact
  • 8F — POST /api/transfer-pairs/scan endpoint. Reads the DB, runs the matcher against all unnullified OneTimeExpense + IncomeEntry rows, stamps nullified + nullifiedPairId + nullifiedReason + nullificationConfidence on both legs of every matched pair in one atomic write. Idempotent — already-nullified rows are skipped. Creates a budget.json backup before mutating
  • 8F — Auto-scan on import. After every Transaction Import commit, the scan runs automatically so same-batch AND cross-batch transfer pairs get linked without user action. Surfaces "· N transfer pairs auto-nullified" in the import status bar. Non-critical — a scan failure doesn't roll back the successful commit
  • 8F — "Scan for transfer pairs" button on Settings → Dedupe. Same scan endpoint with source=retro-scan, for retroactive cleanup across historical uploads. Result banner shows pairs found / rows stamped. Safe to re-run (idempotent). Docs explain the matching criteria, the backup guarantee, and the auto-scan behavior

v1.83.1

Phase 8 Part 2 — Transaction Inbox Lifecycle + Pending Inbox Card

New

  • 8C — Data layout reorg. Replaced the flat data/transactions/YYYY/MM/ layout with a two-tier lifecycle: inbox/{id}.{ext} for unparsed uploads, analyzed/{bankKey}/{product}/{YYYY-MM}/{canonical}.{ext} for parsed statements. New helpers: getInboxDir, getAnalyzedDir, buildInboxPath, buildAnalyzedPath, resolveStoragePath. Plus a migrateLegacyLayout() safety net that opportunistically moves v1.79.0-layout files into the new tree on every POST
  • 8D — Upload-lifecycle split. POST /api/transaction-files now takes ?mode=parse (default, back-compat) or ?mode=inbox (archive bytes, no parse, return inboxId for later analysis). New POST /api/transaction-files/[id]/analyze dynamic route reads an inbox file, parses, moves to analyzed/, returns { fileRecord, parse } in the same shape the Transaction Import page consumes. GET also gets a ?status= filter (analyzed / inbox / all) so the Settings cards can load queues independently
  • 8E — Pending Inbox card on Settings → Imports. New card above "Recent archived files" listing every inbox-status file with uploaded date, filename, size, age, and per-row Analyze/Delete buttons. Stale-inbox warning (amber AlertTriangle) when a file sits unparsed for ≥7 days. Count badge in the header. Empty state explains inbox semantics. Also updated the archived-files card description to reflect the new analyzed/{bank}/{product}/{YYYY-MM}/ layout
  • Schema — TransactionFileRecord gains status?: "inbox" | "analyzed" (undefined treated as analyzed for back-compat) and analyzedAt?: string. storagePath comment documents all three layout conventions (v1.79.0 legacy, v1.83.1 inbox, v1.83.1 analyzed). resolveStoragePath() handles reads across any layout

v1.83.0

Phase 8 Part 1 — Upload Drop Zone Fix + Multi-File Cross-Batch Dedupe

New

  • 8B — Cross-file dedupe. Multi-file drops now dedupe each successive file against the DB pools AND the rows already accepted from earlier files in the same batch. Prevents an AMEX February row from leaking through when it also appears in a January drop. Cross-file duplicates flagged with a distinct marker (prefix "__batch__:") so the UI can distinguish them from DB duplicates
  • ClassifiedRow extended with sourceFileId + sourceFilename + crossFileDuplicate fields so multi-file batches can stamp the correct TransactionFileRecord FK onto each committed row and surface the origin in the review UI

Improved

  • 8H — Filename echo in the drop zone. After selecting files, the zone label updates to show them: single file → "Selected: amex-feb.xls"; multi-file → "Selected: 3 files (amex-feb.xls + rbc-feb.csv + td-mar.csv)". Busy state also reflects count: "Uploading 3 files…" vs "Uploading & parsing…"
  • Commit flow (adhoc + enhance-payload) now prefers per-row sourceFileId over the page-level state, so multi-file batches attribute each charge to the correct archived statement file

Fixed

  • 8A — Broken upload button fixed. The "Upload file" button on /transactions/import was a mode toggle that did NOT open a file picker — a hidden raw input sat below it and was the only way to actually select files. Replaced with the shared <UploadDropZone> component: drag-drop onto the page OR click the zone to pick, multi-file support, styled busy spinner. Paste option demoted to a small disclosure below the drop zone

v1.82.3

Phase 8 Foundations — Schema Fields, Merge Helpers, Shared Hook & Component

New

  • F1 — enhancedFrom?: EnhancementAudit[] on OneTimeExpense / ExpenseEntry / IncomeEntry. Captures a stamped audit trail every time the import or receipt-match path fills missing fields from an incoming source. Consumed by Phase 8I enhance-vs-duplicate audit UI
  • F2 — children?: RecurringChild[] on RecurringExpense. Models packaged recurring bills (internet + TV + home security) as one parent with an expandable child list. Invariant sum(children.amount) ≈ parent.amount ± $0.01 enforced by Phase 9 editor
  • F3 — eventId?: string on ReceiptRecord so receipts uploaded before an expense row exists can still carry the Event tag
  • F4 — Event type extended with EventType union (vacation / renovation / work_reimbursement / side_gig) plus optional type, participants[], location, metadata, and budgetCeiling fields. Back-compat preserved — category field kept as-is
  • F5 — isReimbursement?: boolean on IncomeEntry. Phase 11 excludes flagged-and-tagged income rows from the main /income dashboard via a hideReimbursementsFromMainIncome setting
  • F6 — FX + statement-metadata fields (datePosted, foreignAmount, foreignCurrency, exchangeRate, foreignCommission, cardLastFour, cardBrand, sourceFileId, plus cardmember/chequeNumber on ExpenseEntry) ported from OneTimeExpense onto ExpenseEntry + IncomeEntry so Phase 10 FX rollup covers all three row types
  • F7 — Three new merge helpers in src/lib/expense-merge.ts: mergeEntryFromImport + mergeIncomeFromImport (siblings of existing mergeExpenseFromImport, needed by Phase 8I), unmergeReceiptFromExpense (Phase 7D unlink rollback with per-field decision map), and stampEnhancementAudit helper
  • F9 — New src/components/shared/UploadDropZone.tsx. Drag-drop + click-to-pick + busy spinner with typed props. /receipts page refactored to consume it. Phase 8A will replace the broken /transactions/import upload button with the same component
  • F10 — New scripts/guard-types.mjs — 32-assertion regression script that verifies load-bearing type fields and merge-helper exports still exist. Catches a future refactor silently deleting optional fields that downstream phases (8/9/10/11) depend on

Improved

  • F8 — New src/lib/hooks.ts with useHighlightRow. Consolidates the ?highlight= query-param + scroll + flash + URL-clear logic that was duplicated across /receipts and /expenses/ad-hoc. Both pages refactored to consume the shared hook

v1.82.2

Missing File Indicator + Mismatch Filter + Re-analyze Mismatches

New

  • File-not-found indicator with auto-heal — receipts with missing files show an orange "Missing" badge. The image endpoint auto-recovers stale paths by searching analyzed/ and inbox/ folders by receipt ID and silently fixes the index
  • Mismatch filter toggle — clicking the "X mismatched" badge now filters the receipt list to show only mismatched receipts. Badge changes to active state with dismiss button. Includes a "Re-analyze Mismatches" button for full re-extraction of all mismatched receipts

v1.82.1

Editable Receipt Totals + Smart Total Logic + Describe Monetary Corrections

New

  • Inline-editable receipt monetary fields — total, subtotal, tax, and tip are now click-to-edit in the receipt detail view using the same controlled-input pattern as line item amounts. PUT endpoint accepts all monetary fields plus vendor and date
  • Smart total auto-computation — when subtotal and tax are both known, total auto-computes as subtotal + tax + tip. Applied in both backend (PUT endpoint) and frontend (drift warning with one-click fix). Prevents nonsensical totals from extraction errors

Improved

  • AI Describe accepts monetary corrections via user hint — all 3 describe modes now parse a typed correction to the total and apply it directly. Smart total also runs after hint-based corrections

v1.82.0

Receipt Extraction Quality — Prompt Rewrites, Confidence Scoring, Auto-Fix (Phase 7.5)

New

  • D5 — Per-receipt confidence scoring replaces hardcoded 0.8 default. Weighted composite: subtotal match 40%, no orphan discounts 20%, all descriptions 15%, plausible amounts 15%, item count range 10%. New test runner script (scripts/test-extraction.ts) with baseline snapshots for a set of reference receipts
  • D3 — Pre-commit subtotal validation. Computes abs(sum(lineItems) - subtotal) after extraction. Flags receipts with extractionMismatch: true when delta > $0.05. Mismatches logged to data/logs/extraction-mismatches.jsonl. Flag-only mode — does not block saving
  • D4 — TPD/discount auto-fix heuristic post-pass. Catches unflagged TPD/discount lines, tax/subtotal/total lines captured as items, negative amounts without isDiscount, and exact duplicates. Merges newly-flagged discounts into parent items. Re-runs tax/tip distribution if changes made

Improved

  • D1 — Receipt-extract prompt rewrite for all three extraction templates (vision, phase-1, text). Added mandatory 3-step process: scan entire receipt first, extract every line item, then self-check count + subtotal + duplicates before returning. Strengthened item ID, amount, and discount extraction rules
  • D2 — Describe prompt rewrite: all three describe templates (from-scratch, modify, append) now receive full receipt context (date, total, subtotal, classification, item count) so the AI can use vendor + spending context for disambiguation

v1.81.5#a7e3c

PDF Preview in Dedupe Modal + Match Rate Denominator Fix

Fixed

  • Fixed PDF receipts not rendering in dedupe modal side-by-side preview. Switched from <img> to <object type="application/pdf"> for PDF files, matching the pattern used in the main receipt preview. Added mimeType to dedupe scan summary types
  • Fixed match rate denominator including unanalyzed inbox receipts. Now calculates rate against only analyzed receipts (matched + analyzed + orphan), so the percentage accurately reflects matching performance

v1.81.5

Enhanced Dedupe Modal, Auto-Dedupe After Analysis, Quantity Extraction (Phase 7 Part 6)

New

  • Post-analysis auto-dedupe: after Analyze All or Analyze All Inbox completes, automatically runs duplicate detection scan and opens the review modal if potential duplicates are found
  • Quantity and unit price extraction for multi-quantity line items. AI prompts detect 4@2.95, QTY 3 × $5.00, and similar patterns. Displays quantity × unit price as sub-text below line item descriptions in the receipt detail view

Improved

  • Enhanced dedupe modal with inline side-by-side receipt image previews, detailed per-factor score breakdown with colored progress bars (red ≥80%, amber ≥50%, green <50%), and expand/shrink to full-screen comparison mode

v1.81.4

Receipt Dedupe + receiptNumber Extraction + Backfill (Phase 7 Part 5)

New

  • 7H — Receipt duplicate detection. New weighted multi-factor scorer (total 30%, vendor 25%, date 25%, receiptNumber 20%) with generalized weight renormalization when factors are missing. Backfill-only trigger via "Find Duplicates" button. Side-by-side comparison modal with keep-left/keep-right/dismiss actions. API at /api/receipts/dedupe (GET scan + POST resolve)
  • 7I — receiptNumber extraction. All three AI extraction prompts (vision, phase-1 large, text) now include a RECEIPT NUMBER rule that extracts transaction/receipt/invoice/order numbers. Stored as extractedData.receiptNumber. Displayed in the E2 sticky header (with # prefix and dot separator) and in the preview modal detail header
  • 7J — Standalone backfill script for receiptNumber. scripts/backfill-receipt-numbers.ts walks all analyzed receipts missing a receiptNumber, re-analyzes them via the API, and logs results to data/logs/receipt-number-backfill.jsonl. Supports --dry-run, idempotent, prints cost estimate before starting

v1.81.3#f4c1b

List View Column Overflow + Sticky Header Overlap Fixes

Fixed

  • Fixed mismatch Δ badge pushing Eye/AI icons off the right edge of receipt list rows. Redistributed column widths: Vendor 90→80px, Date 100→90px, Total 80→70px, Category 140→120px, Analyzed 100→80px, Status 100→140px to give the Status column enough room for badge + icons
  • Fixed "Add item" button and item count text hidden behind the E2 sticky header in expanded receipt detail. Added pt-2 to the LineItemTable root container so the toolbar clears the sticky header bottom edge

v1.81.3#e7d2a

Item ID Chip Width Fix

Fixed

  • Widened item ID chip from w-10 (40px) to w-16 (64px) when populated, and from w-8 to w-10 when empty. Added px-1 padding and gap-1.5 spacing so 7-8 character receipt IDs (common on Costco, Shoppers, etc.) display without truncation

v1.81.3

Receipt Unlink / Relink / Jump Navigation (Phase 7 Part 4)

New

  • 7D — Receipt unlink flow. Matched receipts now show an Unlink button (Link2Off icon) in the preview modal. Clicking clears receiptMatchId, matchedExpenseId, and removes the receipt from the expense attachments. Receipt status returns to "analyzed" where the Smart Match UI auto-appears for relinking
  • 7E — Receipt relink via unlink + Smart Match. After unlinking, the receipt becomes "analyzed" and the existing Smart Match UI (match picker with vendor/amount filter) automatically appears. No separate relink endpoint — the existing manual-match flow handles it
  • 7F — Bidirectional jump-to-linked navigation. Receipt detail modal shows "View in Ad-hoc page" link with ExternalLink icon when matched to an expense. Ad-hoc receipt preview modal's "Open in Receipt Manager" link now passes ?highlight= param for targeted scroll
  • 7G — ?highlight= query param on both /receipts and /expenses/ad-hoc. Auto-switches to the correct tab, clears active search filters, scrolls the target row into view with smooth behavior, and applies a 2-second amber ring flash animation. Query param is cleared from URL after use via history.replaceState

v1.81.2

Receipt UX Polish — Sticky Headers + Accuracy Alerts (Phase 7 Part 3)

Improved

  • E1 — Sticky line-item table header. The Description / Amount / +Tax / L1/L2/L3 column headers now stay pinned at the top of the scroll container when scrolling through long receipt line-item lists. Uses sticky top-0 z-[2] with an opaque bg-page background and subtle bottom border
  • E2 — Sticky receipt summary header. In the list view, a compact vendor/total/date/subtotal/tax line is now pinned inside the existing sticky detail header so receipt context is always visible while editing line items. In the preview modal, the full vendor/total/date block plus overall classification are wrapped in a sticky container so they remain visible while scrolling
  • F1 — Collapsed-receipt mismatch red alert. Receipt rows in the list view now show a red "⚠ Δ$X.XX" badge when the line-item sum differs from the subtotal by more than $0.05. The badge appears on collapsed rows so mismatches are visible at a glance without expanding each receipt. Tooltip explains the full discrepancy
  • F2 — Mini accuracy dashboard. A progress bar at the top of the receipts page shows "X/Y match (Z%)" with color coding: green ≥90%, amber ≥70%, red <70%. A clickable "N mismatched" link switches to the analyzed tab for investigation
  • Regression guard expanded from 14 to 20 assertions. New checks: JSX brace/paren balance (catches stray )} syntax errors like the v1.81.1 build break), sticky header presence, collapsed mismatch badge, and accuracy dashboard

v1.81.1

Receipt Overall Category Editor + Classify Guard (Phase 7 Part 2)

New

  • 7B — Click-to-edit overall L1/L2/L3 category on receipts. Both list view (expanded detail) and preview modal now show the overall classification as a clickable badge with a pencil icon. Clicking opens compact CategoryDropdowns inline; selecting a category saves immediately via PUT /api/receipts/:id and sets overallClassificationSource to "manual". Unclassified receipts show an italic "Unclassified" placeholder that is also clickable
  • 7B — Classify Only overwrite guard. New manuallyEdited boolean on ReceiptRecord. When a user manually edits the overall L1/L2/L3 via the new click-to-edit UI, the flag is set to true. The Classify Only pipeline (/api/receipts/analyze mode=classify) now skips the overall classification step when manuallyEdited is true — line items are still re-classified. An amber "manually edited" chip appears next to the category badge with an X button to clear the flag and re-allow AI classification

Fixed

  • Item ID chip reverted from blue tint to high-contrast neutral (text-zinc-900/100) after user feedback that the blue made it worse. Populated state now uses subtle bg-zinc-100 with very dark text for maximum readability

v1.81.0

Receipts Visual Fix Bundle (Phase 7 Part 1)

Improved

  • 7C' — Added standalone Classify Only button (indigo, Tag icon) next to AI Describer and Re-analyze in the line items section header. One-click classification without re-extracting descriptions or amounts. Also still available in the AnalyzeSplitButton chevron dropdown alongside Full Analysis and Extract Only
  • Item ID chip polish per user feedback: bumped text from 8px to 9px for readability, darkened populated-state text from blue-700 to blue-800, widened empty-state chip from w-6 to w-8 for easier click targeting
  • New static-analysis regression guard at scripts/guard-receipt-line-item-styles.mjs with 15 assertions covering all Phase 7A visual fixes: amber L1 bar, edit-state trackers, button contrast, item ID 3-state chip, modal scroll fix, and standalone Classify Only button. Runs via node scripts/guard-receipt-line-item-styles.mjs — zero dependencies, exit code 0/1

Fixed

  • 7A — Fixed 5-source compound grey-out bug in receipt line-item rows. (1) L1 color bar uncategorized fallback replaced with repeating amber-dashed gradient instead of plain var(--border). (2) Item ID chip reworked to 3-state visual: empty shows transparent background with subtle amber-dashed border signaling "needs attention", populated shows a blue-tinted chip so data reads as present, and edited shows amber highlight consistent with other fields. (3) Raw description text bumped from text-muted to text-zinc-700/300. (4) Delete button contrast bumped from nearly invisible text-zinc-300/600 to text-zinc-400/500. (5) Added per-field edit-state trackers (descriptionEdited, rawDescEdited, itemIdEdited) with amber ternary classNames so editing any field flips the row to an active visual state
  • 7C — Fixed Create Expense button being clipped on long receipts in the preview modal. Classic flexbox overflow issue: the modal body flex container lacked min-h-0, preventing the right panel's overflow-y-auto from creating a scrollbar. Added min-h-0 to the body flex and pb-6 bottom padding to the right panel for breathing room

v1.80.0

Duplicate Enhancement + Foreign Currency Display (Phase 6 Part 3)

New

  • Fill-if-missing merge on duplicate import. When a Transaction Import row hits the auto-skip verdict against an existing ad-hoc expense, the importer no longer silently drops it — instead it batches the row into a new POST /api/expenses/enhance call that runs mergeExpenseFromImport() from the new src/lib/expense-merge.ts helper. Every field the incoming row knows that the existing expense is missing (datePosted, cardmember, chequeNumber, foreignAmount, foreignCurrency, exchangeRate, foreignCommission, cardBrand, cardLastFour, sourceFileId) gets filled in using a generic fillIfMissing() primitive; existing non-empty values are never overwritten. paymentMethods merge as a union by method+cardLastFour and attachments merge as a union by storagePath, so re-importing a statement enriches the existing expense with the statement's card info without creating a second payment allocation. The commit result bar now reports "… · N duplicates enhanced" when any duplicates were merged; 0-selected imports still run the enhance pass so duplicate-only uploads still enrich the store. The endpoint creates a backup and writes back only when at least one field actually changed
  • Fill-if-missing merge on receipt match. The existing receipts/match and receipts/[id] PUT handlers previously shoved the receipt attachment + line items onto the linked expense with hand-rolled de-dupe guards. Those blocks are now replaced by a call to mergeExpenseFromReceipt() in expense-merge.ts, which (a) unions the attachment array by storagePath, (b) always overwrites receiptMatchId to point at the most recent linked receipt, (c) fills missing vendor/tax/subtotal/cardBrand/cardLastFour/lineItems/paymentMethods from the receipt's extracted data, and (d) returns a changedFields list so the API can skip createBackup + writeDB when nothing actually moved. A receiptCardTypeToBrand() helper bridges the ReceiptExtractedData lowercase cardType ("amex"/"visa"/…) to the uppercase OneTimeExpense PaymentMethod enum. Net effect: attaching a receipt to an existing expense now enriches the expense with anything the receipt knows that the expense doesn't, not just the PDF pointer
  • New ForeignCurrencyDisplay component. Shows a foreign-currency charge and its converted CAD amount side by side — a compact badge in table rows with a tooltip for the exchange rate and FX fee, and a fuller labelled block in the detail panel. Renders nothing when no foreign-currency data is present; all budget math stays in CAD, this is purely a render-time augmentation for context.

Improved

  • AMEX cardmember→card resolution refinement. ImportProfile gained a cardmemberCardMap: Record<string, { owner: Owner; cardLastFour?: string }> field that resolves BOTH the household owner AND the card last-four from the Cardmember text column on AMEX rows, replacing the simpler cardmemberOwnerMap (which is kept as a legacy fallback for back-compat). This is necessary because AMEX .xls statements have no per-row last-four column — the Cardmember string is the only reliable per-row signal for distinguishing primary vs. supplementary cards. The seeded AMEX Aeroplan Reserve starter profile now resolves each cardmember name → { owner, cardLastFour }, including the supplementary card held in one household member's name but paid by the other. The import commit loop reads cardmemberCardMap first and falls back through cardmemberOwnerMap → detectedSource.defaultOwner → "alex" so legacy profiles still work. The starter profile top-level cardLastFour was corrected to match the primary card

v1.79.0

Transaction File Archive + AMEX .xls Parser (Phase 6 Part 2)

New

  • Every uploaded statement is now archived under data/transactions/YYYY/MM/{canonical-name}.{ext} and indexed in data/transactions/transactions-index.json, mirroring the Phase 4 receipt archive pattern. The canonical filename is built from the detected bank+product, account/card hint, and parsed date range — e.g. amex-aeroplan-reserve-1234-YYYYMMDD_YYYYMMDD.xls, rbc-chequing-5678-YYYYMMDD_YYYYMMDD.csv — so two re-downloads of the same statement never collide and a month-by-month folder listing stays human-readable. Filename collisions get a short random tag appended. A new sourceFileId foreign key is stamped on every OneTimeExpense committed from the archived file so any individual charge can be traced back to the exact statement it came from, with a matching TransactionFileRecord carrying originalFilename, canonicalFilename, storagePath, bankKey, cardLastFour, accountHint, dateRangeStart/End, and rowCount
  • New POST/GET/DELETE /api/transaction-files route handles the entire upload → parse → archive → index cycle in one round-trip. POST accepts multipart form-data with a file field, writes the bytes to a temp path, runs parseBankRecords() against them (CSV via parseCSV, binary .xls/.xlsx via a lazy server-only require("xlsx") so the client bundle never ships SheetJS), computes the canonical filename from the parse result, renames via write+unlink (cross-device-safe instead of rename which can fail on mounted filesystems), upserts a TransactionFileRecord to the index, and returns { fileRecord, parse } so the client can skip its own parse step entirely. GET returns the newest 100 index entries for the Settings → Imports recent-files card; DELETE unlinks the archived file and drops the row from the index but leaves committed expenses alone (their sourceFileId FK just dangles, which is acceptable for a manual cleanup flow)
  • AMEX .xls / .xlsx / .xlsm binary statement parsing. Real-world AMEX statement exports prepend an 11-row metadata preamble before the actual header row, so a new findHeaderRow() scan walks the first 30 rows looking for a "Date" or "Transaction Date" token in the first three cells and returns the index of the real header (0 for CSVs, 11 for AMEX xls). A per-profile headerRowHint field can override the heuristic when needed. The rewritten rowsFromAmex() builder maps every AMEX column — Date, Date Processed, Description, Cardmember, Amount (sign convention: positive = charge, opposite of RBC), Foreign Spend Amount, Commission, Exchange Rate, Merchant, Merchant Address, Additional Information — into the extended ParsedRow shape with dedicated datePosted / cardmember / foreignAmount / foreignCurrency / exchangeRate / foreignCommission fields. A new parseForeignCell() helper parses strings like "45.20 UNITED STATES DOLLAR" into { amount: 45.20, currency: "USD" } with mappings for USD/EUR/GBP/CAD/JPY
  • RBC Chequing vs RBC Mastercard disambiguation. Both products export identical CSV headers (Account Type, Account Number, Transaction Date, Cheque Number, Description 1, Description 2, CAD$, USD$) so v1.78.0 incorrectly routed every RBC CSV through a single rowsFromRbc() that hardcoded DEBIT as the payment method. The rewrite adds a deriveBankKey(format, firstDataRow, headers) helper that reads the Account Type column value from the first data row and splits rbc-chequing / rbc-savings / rbc-mastercard apart, then rowsFromRbc() assigns each row its correct PaymentMethod (DEBIT for chequing, MASTERCARD for credit) based on the per-row Account Type. A new accountNumberPattern regex on ImportProfile provides a secondary disambiguation path: RBC Chequing rows carry a hyphenated branch-and-account number while RBC Mastercard rows carry a 16-digit card number — same columns, different patterns, so detectSource() can match on that too even when the filename is ambiguous. RBC chequing rows also now surface chequeNumber and accountNumber as dedicated ParsedRow fields
  • AMEX per-row cardmember → owner split. AMEX statements routinely contain charges from multiple cardholders (primary + authorized user) in a single file. ImportProfile gains a cardmemberOwnerMap: Record<string, Owner> that maps the raw Cardmember column value (the cardholder name printed on the statement) to the internal Owner enum; at commit time the Transaction Import page looks up each row's cardmember in the map and stamps the resolved owner on the OneTimeExpense, falling back through detectedSource.defaultOwner ?? "alex" when nothing matches. Previously every row from a multi-cardholder statement would be silently stamped with a single file-level default, so Chelsea's charges ended up misattributed to Alex until someone caught them on the ad-hoc table. Other per-row metadata the new AMEX parser now surfaces onto OneTimeExpense: datePosted (the processed date, separate from the transaction date), foreignAmount, foreignCurrency, exchangeRate, foreignCommission — all flow through ParsedRow → OneTimeExpense as optional columns and are immediately visible in the existing dashboards
  • Settings → Imports tab gains a "Seed example profiles" button that one-click-adds three starter ImportProfiles from sample files: an AMEX Aeroplan Reserve card, an RBC Chequing account, and an RBC Mastercard, each pre-configured with its bank key and matching pattern. Idempotent — re-clicking skips any starter already present, so you can seed once and delete what you don't need. The profiles table also gains a Bank key column.
  • New "Recent archived files" card in Settings → Imports below the profile editor. Queries /api/transaction-files on tab enter and renders a newest-first table with eight columns: upload date, canonical filename (monospace with copy-friendly highlighting), original filename (truncated with hover tooltip), bankKey, card last-four, date range, row count, and a Remove button that calls DELETE /api/transaction-files?id=… to unlink the archived file and drop the index entry. Committed expenses are left alone; only the FK dangles. The card surfaces the file archive in the Settings UI instead of hiding it behind a filesystem walk, so users can verify their statements are being archived and clean up old imports without touching the terminal
  • Transaction Import page now accepts .xls / .xlsx / .xlsm binary statement files in the upload input alongside the existing .csv / .tsv / .txt. The file picker accept attribute is expanded and the upload path routes through POST /api/transaction-files instead of doing client-side parsing, so the full Phase 6 Part 2 pipeline (archive + parse + extended metadata extraction) runs on every upload regardless of file format. The paste-mode flow is unchanged — pasted CSV text still parses client-side via parseBankCSV, but with no sourceFileId stamped on the committed expenses because no file was archived. When the user uploads a CSV file we also eagerly read it into the textarea preview so switching from upload to paste mode still shows the raw text. For binary .xls uploads the textarea stays empty — binary bytes aren't useful for preview

Improved

  • parseBankRecords(records, filename, profiles?) is the new single entry point for both server and client parse paths. Handles the full pipeline: (1) drop empty rows, (2) find the header row via findHeaderRow() or the matched profile's headerRowHint, (3) run detectSource with the first data row so deriveBankKey can read Account Type, (4) delegate to the per-bank rowsFromXXX() builder with (header, rest) instead of the old (records) shape, (5) compute dateRangeStart/End from the sorted parsed row dates. Returns a BankParseResult that extends ParseResult with bankKey, detected, headerRowIndex, accountHint, dateRangeStart, dateRangeEnd. parseBankCSV(text) is now a thin compatibility wrapper that calls parseBankRecords for the old text-in / ParseResult-out shape used by the paste flow

v1.78.0

CSV Source Auto-Detection v2 — Filename Hints, Import Profiles, Card Stamping (Phase 6)

New

  • New detectSource(headers, filename, profiles?) helper in src/lib/csv-import.ts extends the existing header-based detectFormat() with filename-aware heuristics. The helper extracts a rightmost 4-digit run as a probable card last-four (rejecting 1900–2199 so years don't get mistaken for card numbers), matches brand keywords (amex / mc / mastercard / visa / debit / chequing) against the filename, and falls back to a format-implied brand (amex → AMEX, scotiabank → DEBIT). When a list of saved ImportProfile records is provided, each profile's filenameRegex is tested first — a profile match is authoritative and wins over filename heuristics, so once a file pattern is taught, future uploads of the same statement auto-stamp the correct card every time. Returns { format, cardBrand?, cardLastFour?, defaultOwner?, matchedProfileId?, profileNickname?, cardInferredFromFormat? } so every downstream consumer can tell apart a profile match from a heuristic guess
  • New ImportProfile type { id, nickname, filenameRegex, format?, cardBrand?, cardLastFour?, defaultOwner?, createdAt, updatedAt, lastUsedAt? } added to Settings.importProfiles?: ImportProfile[]. A new Settings → Imports sub-tab renders saved profiles as a table with nickname / regex / card brand / last-four / last-used / delete button, alongside an add-profile form that validates the regex compiles, enforces a 4-digit pattern on last-four, and offers a live test input where you can paste a filename and see whether the profile matches before saving. Profiles are consumed by the Transaction Import page on parse so card stamping works immediately on the next upload
  • Transaction Import commit() now stamps detectSource().cardBrand + cardLastFour onto every ad-hoc row at both the top-level OneTimeExpense fields (Phase 1A) AND the first PaymentAllocation inside paymentMethods[], so per-card drill-downs and top-level card filters both see the card info. Recurring ExpenseEntry rows get the cardLastFour stamped on their per-occurrence PaymentAllocation (ExpenseEntry has no top-level card fields by design). Income rows are left untouched — income is not tied to a card in the same way. The owner field on both ad-hoc and income payloads now falls back through detectedSource.defaultOwner ?? 'alex' so profile-owned statements route to the right household member automatically
  • Post-commit learn-from-file prompt appears when the uploaded file did not match any saved profile but detectSource() still surfaced usable card info. A one-click "Save profile" button generalizes the actual filename into a regex by replacing every run of digits with \d+ (so "amex-1234-feb.csv" becomes ^amex-\d+-feb\.csv$), pre-fills nickname from the detected card info, carries brand / last-four / defaultOwner from detectSource, and PUTs the profile to /api/settings so the very next import of the same pattern auto-stamps without asking. A dismissible "Not now" button sets learnDismissed so the prompt stays gone for the rest of the session
  • Detected-source preview banner between the Upload/Paste card and the Review table shows the parsed file's card brand and masked ending, its detected format, and either the matched profile name or "unsaved". When the brand was only inferred from the CSV format, the banner says so, so you can tell a confident match from a soft guess before pressing Commit.

Improved

  • Matched-profile bookkeeping runs after every successful commit: if detectedSource.matchedProfileId is set, that profile's lastUsedAt and updatedAt get bumped in settings.importProfiles and the Settings → Imports table shows freshness on the next visit. Shallow-merged through /api/settings PUT so editing one profile does not clobber the rest of the Settings payload. When the learn prompt is accepted, the new profile's lastUsedAt is seeded to the commit timestamp so the first-use row appears immediately without requiring a second import

v1.77.2

In-App Dedupe Correction Log Viewer

New

  • Settings → Dedupe tab's Correction Log card now renders the append-only JSONL ledger directly inside the app instead of pointing at the raw file. The card header shows the full ledger count (via GET /api/dedupe/corrections which already caps at the most recent 500 entries) and a scrollable table renders the newest-first entries with five columns per row: timestamp (localized), suggested→user verdict pair with color-coded chips and an agreement ✓ / disagreement ⚠ badge, composite score, destination bucket (ad-hoc / recurring / income), and the incoming row signature (date · amount · vendor) so a correction can be mentally tied back to a specific CSV row without leaving the Settings page. A Phase 5I convergence column shows ✓ (tuned to match user) or ↻ (still off) for review-band entries and a dash for auto-skip/accept entries that didn't go through re-scoring
  • Each row expands to a drill-down panel with two halves: (1) a per-factor breakdown showing the six factor scores at suggestion time rendered as horizontal bars (0–1 each) so you can eyeball which factor was carrying the verdict, and (2) a weights-before → weights-after → Δ table per factor with green/red coloring on non-zero deltas and the nudge magnitude reported inline. When the entry has a verdictAfterTune field (Phase 5I re-run), the panel also prints the post-tune score and verdict with an explicit "converged with user verdict" or "still off" callout so a single row tells the full story of what the auto-tune loop did with the correction

Improved

  • Client-side pagination via a "Load 25 more" button that expands the render limit in 25-entry increments without re-fetching the API. The viewer renders the first 25 newest entries on tab enter and caps the working set at whatever the server returned (≤500); older entries beyond that live on disk and are footnoted in the pagination footer ("ledger has N older entries on disk") so the user knows the UI is not the full history
  • Replaces the v1.77.1 placeholder copy on the Dedupe tab that said "A future release will add an in-app viewer". The GET /api/dedupe/corrections endpoint that Phase 5 shipped for exactly this use case is now hooked up — no new backend routes or schema changes. A lightweight DedupeLogEntry interface is declared locally in configurations/page.tsx so the client bundle stays decoupled from src/lib/dedupe-log.ts (which imports fs) and only the fields the viewer actually renders are typed

v1.77.1

Settings → Dedupe Card (Preset Picker, Thresholds, Weight Readout)

New

  • New Dedupe sub-tab on the Settings page surfaces the full v1.77.0 Phase 5 tuning machinery that previously had no UI. Four cards: (1) an Auto-tune card with a Conservative (0.01) / Balanced (0.03, default) / Reactive (0.05) / Custom preset picker and an on/off toggle that, when off, still logs corrections but freezes the currentWeights vector so you can stop the scorer from drifting further; (2) a Verdict thresholds card with range sliders for the auto-skip cutoff (0.5–1.0) and the review cutoff (0.3–0.85), where review is clamped to stay ≤ autoSkip and a Reset-to-defaults button appears whenever either slider moves off 0.85/0.55; (3) a Factor weights card that renders a default vs. current vs. Δ table across all six factors (amount, date, vendor, payment method, card last four, description) with green/red coloring on non-zero deltas, a last-updated timestamp pulled from dedupeTuning.lastUpdated, and a Reset-to-defaults button that drops currentWeights so the scorer falls back to DEFAULT_WEIGHTS on the next parse; (4) a Correction log pointer card that fetches the running total via GET /api/dedupe/corrections and explains that an in-app viewer is still pending — the raw JSONL file at data/logs/dedupe-corrections.jsonl remains the source of truth for calibration analysis
  • DedupeTuningConfig gains two new optional fields: autoTuneEnabled (boolean, default true so pre-1.77.1 settings rows keep tuning on) and thresholds: { autoSkip, review } that override the scorer's built-in DEFAULT_THRESHOLDS. The corrections API route honors both: when autoTuneEnabled is false, the correction is still appended to the log (so the calibration dataset continues to grow) but currentWeights is not mutated and lastUpdated is not bumped; when thresholds is set, both the re-score that powers the Phase 5I tuning-converged badge AND the initial parse on the Transaction Import page use the user-configured bands instead of 0.85/0.55

Improved

  • Transaction Import page now pulls dedupeTuning.thresholds from settings on mount (alongside the existing currentWeights read) so the first pass of bestCrossDestinationMatch runs through the user's bands. The Settings save path uses a shallow merge so editing any single field (preset, toggle, one slider, one threshold) does not clobber currentWeights or lastUpdated — only the reset button explicitly drops the tuned vector

Fixed

  • resolveNudge helper in /api/dedupe/corrections reworked: previously "typeof weightNudge === number" won unconditionally, which meant flipping the preset picker away from Custom had no effect on the server side because the stale custom value was still read. Now the preset wins (conservative → 0.01, balanced → 0.03, reactive → 0.05) and weightNudge is only consulted when preset === "custom", with a back-compat branch for pre-1.77.1 sessions that stored a raw weightNudge without a preset field

v1.77.0

Repeat Detection v2 + Unified Transaction Import (Phase 5)

New

  • New src/lib/transaction-match.ts — a weighted multi-factor scorer (amount 0.35, date 0.25, vendor 0.15, paymentMethod 0.10, cardLastFour 0.10, description 0.05) that returns { score, breakdown, verdict, destination, candidateId } where verdict is 'auto-skip' at ≥0.85, 'review' between 0.55 and 0.85, and 'accept' below 0.55. Amount scoring is exact-match 1.0 with linear decay to 0 at ±$5; date scoring is stepped (1.0 same-day, 0.85 ±1 day, 0.5 ±3 days, 0.2 ±7 days, 0 beyond); vendor uses Dice bigram similarity; description uses Jaccard-like token overlap; payment method and card last four use brand/exact equality with a 0.3 partial-credit floor when either side is unknown so missing metadata doesn't inflate the composite. Mirrors the receipt-match-engine weighted-factor pattern so the scoring surface is consistent across the two dedupe engines
  • Cross-destination dedupe via a new bestCrossDestinationMatch(incoming, pools) entry point that scores every incoming CSV row against oneTimeExpenses AND expenseEntries (recurring charges) AND income in a single pass, returning the winning destination alongside the score. This closes the pathological case where a Costco charge from a re-uploaded statement looked 'unseen' because it was committed to recurring instead of ad-hoc the first time around. Each destination has its own typed candidate projection (provider → vendor for recurring templates, employer → vendor and Math.abs(amount) for income) so the comparisons stay apples-to-apples. A cheap pre-filter rejects candidates more than $10 off in amount or more than 14 days off in date before running the expensive bigram work
  • New src/lib/dedupe-log.ts — an append-only JSONL ledger at data/logs/dedupe-corrections.jsonl that captures every verdict decision the user makes on the import page, both confirmations and corrections, with the full state the auto-tune operated on: breakdown, weightsBefore, weightsAfter, score, verdict, and the Phase 5I re-score result. Mirrors the classification-log append-only pattern so there is no read-modify-write and no concurrency landmines. A GET /api/dedupe/corrections endpoint returns the most recent 500 entries for a future calibration dashboard
  • Auto-tune weight nudges — Settings.dedupeTuning = { preset?: 'conservative' | 'balanced' | 'reactive' | 'custom', weightNudge?: number, currentWeights?: TransactionMatchWeights, lastUpdated?: string }. Presets: Conservative 0.01, Balanced 0.03 (default), Reactive 0.05; any float in [0, 0.2] is accepted as a custom value. On every correction, nudgeWeights() applies direction · nudge · (factor − 0.5) per breakdown factor, clamps each weight to [0.01, 0.9], renormalizes the vector to unit sum, and writes the result back to Settings.dedupeTuning.currentWeights for all subsequent rows — both in the same import session and across future sessions. The full before/after vector is snapshotted in the correction log entry so a bad nudge can be rolled back
  • New /transactions/import page as a top-level Transactions section in the sidebar. Reuses the existing 5-stage AI classification pipeline (transfers → type → income → L1 → L2 → L3) from /expenses/ad-hoc/import verbatim but keeps income and recurring rows live in the table instead of banishing them to a collapsible skipped panel. Each row carries a resolved destination ('adhoc' | 'recurring' | 'income' | 'skip'), a match score / verdict / candidate summary from the Phase 5B cross-destination scorer, and a 'tuning converged' flag once the Phase 5I re-score lands. The classification pipeline itself is unchanged, but the destination handling and commit surface are rewritten to treat all three buckets as first-class
  • Per-row destination dropdown (Ad-hoc / Recurring / Income / Skip) with a default that tracks the AI classifier's txType. Flipping a row overrides the classifier pick and stamps classificationSource='manual' on commit. When a row is flipped to 'recurring' an inline picker appears for selecting which RecurringExpense template the new ExpenseEntry should link to (loaded from /api/recurring). When flipped to 'income' the category / sub-category columns switch to the income hierarchy instead of the ad-hoc hierarchy. Skip rows simply aren't committed
  • Three-bucket commit loop — the commit pass now dispatches selected rows into three buckets by destination and runs three commit loops sharing a single "Committed X of Y" progress indicator. Ad-hoc uses the existing POST /api/expenses batch array endpoint; expenseEntries and income POST one at a time since those routes don't accept arrays. Recurring rows missing a recurringExpenseId link are filtered out with a user-visible skipped warning. The summary line reads "Imported N ad-hoc · M recurring · K income · S skipped" so the user gets a single glanceable result. The existing orphan-receipt rematch pass and vendor-cache update still run afterwards
  • Old /expenses/ad-hoc/import route replaced with a thin client redirect to /transactions/import so existing bookmarks and muscle memory keep working. The old ~1000-line file is gone; everything useful was ported to the new page
  • Validation mode for review-band corrections — when the user confirms a review-band verdict, POST /api/dedupe/corrections immediately re-scores the same row with the freshly-tuned weights against the full candidate pool and returns whether the new verdict matches the user's choice. The row surfaces a ✓ tuned badge on convergence or a ↻ still-off badge otherwise, giving instant feedback on whether the nudge was useful. Both the before/after verdicts and the tuningConverged flag are persisted to the correction log so the calibration dataset captures the convergence signal, not just the disagreement — over time this tells us whether the 0.03 default weightNudge is too timid or too aggressive

v1.76.0

Per-Row + Bulk Classification Triggers — Click-to-Classify + All / Unclassified / Legacy

New

  • New rightmost AI column on the ad-hoc expenses table adds a per-row Sparkles button that opens ReclassifyModal pre-scoped to a single-expense list. The modal already handles length-1 inputs as a degenerate case of its multi-phase / flat pipelines, so no modal-side changes were needed — the single-row path reuses the same classifyInBatches machinery, the same review-and-accept diff table, and the same /api/expenses PUT write-back logic the selection-based path has had since v1.33.0. Click handler stops row-click propagation so tapping the icon doesn't accidentally open the inline editor. Table colSpans, loading row, empty row, and both expense-line-item and receipt-line-item sub-rows all bumped from 12 to 13 columns in the same edit
  • Three new top-of-table bulk classify buttons above the ad-hoc expenses table, sitting just below the Filters / Reset row in their own compact flex row: Classify All (N), Classify Unclassified (N), and Classify Legacy (N). The counts are computed as useMemo slices over activeExpenses (not filteredExpenses) so they represent the backlog the user cares about — the filter bar narrows the visible rows, but the bulk classify buttons still operate on the whole active set so the counts stay stable as the user filters. Unclassified is defined as !category || !category.trim(), which is distinct from the Phase 2F legacy definition (legacy means "category exists but no longer maps to a hierarchy node" — empty is its own state). Legacy reuses the Phase 2F isLegacyCategory(exp, hierarchy) predicate verbatim
  • Each of the three bulk classify buttons is auto-disabled and greyed (opacity-40, disabled cursor, native disabled attribute) when its count is zero, so a click on Classify Legacy when you have no legacy rows is a no-op instead of opening a ReclassifyModal over an empty list. Classify All and Classify Unclassified share the purple brand color that Reclassify (N) uses to mark AI-adjacent actions; Classify Legacy uses amber to line up with the legacy chip in the row view
  • New unified requestReclassify(label, target, { requireConfirmOverSize }) entry point on the page funnels every classify path — per-row AI icon, the three header buttons, and the existing selection-based Reclassify (N) button — through the same state setter. This replaces the pre-1.76 showReclassify boolean with a reclassifyTarget: Expense[] | null state whose contents drive the modal directly. Per-row and selection-based calls skip the >50-row confirm dialog (the user just clicked those specific rows), while the three bulk buttons opt into it via requireConfirmOverSize: true. Cleaning up this state machine was a prerequisite for letting four entry points share one modal without any prop drilling
  • New inline confirm modal triggered when a bulk classify action targets > 50 rows. Shows the action label ("You're about to reclassify 312 legacy expenses through the AI pipeline"), the row count, and a rough cost estimate derived from ~$0.15 per 100 rows ($0.0015 × count, shown to 2 decimal places). Cancel clears the pending target; Proceed promotes the pending target into reclassifyTarget, opening the normal ReclassifyModal flow. Prevents an accidental click on Classify All from silently firing a $0.50+ API call. Per-row Phase 3A clicks bypass this guard (length 1) and the selection-based button bypasses it too because the user explicitly ticked those rows — the guard is specifically about the bulk-by-count actions where a single click could mean several hundred rows

Improved

  • Classification batching reliability: the Phase 3B bulk buttons can target arbitrarily large sets, but all paths now route through ReclassifyModal which already uses classifyInBatches() from src/lib/ai-batch.ts for every stage of both the multi-phase (Type → L1 → L2 → L3) and flat (Type → Full) pipelines. classifyInBatches splits any input array into BATCH_SIZE=10 chunks before POSTing and wires an onProgress callback that the modal surfaces as "Batch X of Y" beneath the active-stage indicator. Cost estimates returned from each /api/ai/* endpoint are summed across stages and shown in the review screen summary bar as "Est. cost: $0.XX". Since both the progress indicator and the cost estimate already live inside the modal we didn't add a redundant page-level toast — the modal is always open during a classify run, so its progress surface is always visible to the user running the action

v1.75.0

Ad-hoc UI Polish — Quick Filters, Sort, Color Dots, Brand Icons, Card Column

New

  • New quick-filter bar on /expenses/ad-hoc with five client-side filters running over activeExpenses before the sort step: Category (multi-select chip list with a palette-colored dot per L1 so the filter UI and the row view share the same visual language), Vendor (contains-match against the existing vendorList memo, wired to the page datalist so typeahead suggestions are free), Date range (from→to date inputs plus one-click shortcuts for This month / Last month / YTD), Amount range (min/max numeric inputs), Payment method (multi-select chip list styled with the new PAYMENT_STYLES brand colors), and a Legacy-only toggle that reuses the Phase 2F predicate. A header toggle on the existing receipt filter row opens/collapses the whole bar, with an active-filter count badge and a Reset button that clears everything at once. A "N of M" counter on the right shows filtered vs total active rows at a glance
  • Quick-filter state is mirrored to URL search params (f_cats, f_vendor, f_from, f_to, f_amin, f_amax, f_pm, f_legacy) via window.location + history.replaceState — the same non-Suspense-requiring pattern the Settings page uses — so deep-links like /expenses/ad-hoc?f_cats=Food%20%26%20Dining&f_pm=AMEX restore the exact filter set on load. URL state is layered on top of a parallel localStorage mirror under adhoc.quickFilters.v1 for session memory, with a hydration precedence of URL > localStorage > defaults. Resetting the filters clears both stores in one pass. The filter bar auto-opens on mount if any filter was active in the deep-link
  • Sortable column headers on the ad-hoc table via a new local SortableHeader component that matches the existing ad-hoc header styling (text-sm, p-3, font-medium) rather than copying the Features page version verbatim. Sortable keys: Date, Amount, Vendor, Category, Sub-Category, Payment, Owner, Event. Click a header to toggle asc/desc on the same key; click a different header to switch. First click on a text column defaults to asc, first click on Date or Amount defaults to desc (most recent / largest first — matching the pre-Phase-2 render order). A secondary sort by date desc keeps the order stable when the primary key ties
  • Category cell now leads with a small h-2 w-2 rounded color dot pulled from Settings.categoryColors (the L1→palette slot map introduced in v1.20.2). The slot is resolved through getCategoryStyle() from src/lib/category-colors.ts which returns CSS variables (var(--cat-${slot}-bg) / -text) so the colors automatically follow the active theme. Categories without a color slot fall back to a neutral border-only ring so the horizontal alignment stays stable across rows. Line-item rows suppress the dot because "multiple" is the rendered label
  • Payment cell replaced with a new PaymentBadge component that renders a small brand pill with a lucide-react icon: AMEX (blue pill with CreditCard icon), MASTERCARD (orange pill labeled MC), VISA (indigo pill), DEBIT (slate pill with Wallet icon), CASH (emerald pill with Banknote icon), and OTHER (zinc pill — hover tooltip surfaces the allocation.note so the legacy "pre-authorized" string is still visible). When isMultiMethod() returns true the cell shows the primary badge plus a +N chip indicating additional allocations, laying the visual groundwork for Phase 7 split-tender without touching the badge layer again
  • New Card column between Payment and Owner shows the masked card ending from the row's payment data, with a fallback dash when none is set. When the full card number is available (encryption-gated, future auth phase), clicking the masked ending reveals it in place and clicking again hides it; expanding a row for line items never also reveals its card.

Improved

  • New isLegacyCategory(exp, hierarchy) predicate extracted from the three inline (legacy) <option> guards in the ad-hoc form (page.tsx ~1018/1030/1042) into src/lib/utils.ts. Walks category → subCategory → subCategory2 against the provided CategoryHierarchy and returns true if any non-empty level fails to map to a real node. An empty category is NOT legacy — it's "unclassified", a distinct state Phase 3 treats separately. Row view now renders an amber legacy chip next to the category name whenever the predicate returns true, and the filter bar exposes a "Legacy only (N)" toggle with the count computed via the same helper. The form dropdowns were left alone because they check against the currently-selected L1's children, which is a different question than the whole-hierarchy check — extracting the form's logic too would have coupled the two unnecessarily

v1.74.0

Payment Method Schema Foundation — paymentMethods[] Allocations

New

  • New PaymentAllocation type replaces the legacy single paymentMethod string across the entire expense schema, with its own amount and optional card fields. The amount field enables future multi-method splits (e.g. part cash, part card on one purchase), and a note field preserves legacy raw strings that don't map to a canonical method. The full-card-number field is flagged for a future encryption gate.
  • OneTimeExpense.paymentMethod (string) is removed and replaced with paymentMethods: PaymentAllocation[] (required — may be an empty array for legacy rows with no payment method). The expense type also gains optional top-level cardLastFour, cardBrand, and cardFullNumber convenience fields for the common single-allocation case where they mirror the first allocation entry. RecurringExpense gains paymentMethods?: PaymentAllocation[] (optional). ExpenseEntry (per-occurrence payments) — which previously had no payment field at all — gains paymentMethods?: PaymentAllocation[], closing the schema gap and allowing per-entry overrides when the actual charge landed on a different card than the template default
  • New one-shot migration script at scripts/migrate-payment-methods.ts (run via `npx tsx`) converts every legacy paymentMethod string into a length-1 PaymentAllocation array across oneTimeExpenses, recurringExpenses, expenseEntries, and receipts.extractedData. Fuzzy canonicalization maps "AMEX" / "American Express" → AMEX, "VISA" → VISA, "MASTER" / "MC" → MASTERCARD, "DEBIT" / "INTERAC" → DEBIT, "CASH" → CASH, with anything else falling into OTHER while preserving the raw string in allocation.note. Writes timestamped backups to data/backups/ before any mutation, idempotent on re-run. First run converted 304 oneTimeExpenses, 13 recurringExpenses, 19 receipts, 0 paymentMethod keys remaining
  • Four new read/write helpers in src/lib/utils.ts: primaryMethod(expense) returns allocations[0]?.method (the single-allocation fast path used everywhere in the render layer), primaryAllocation(expense) returns allocations[0] for the full shape, totalByMethod(expense, method) sums amounts across all allocations matching a method (Phase 7 dashboard reporting), isMultiMethod(expense) returns true when allocations.length > 1 (Phase 2 "+N more" badge hook), and singleAllocation(method, amount, extras?) builds the length-1 array used by every form-save path so the dropdown-UI → persisted-array conversion lives in exactly one place
  • Every write path now constructs a paymentMethods array instead of a single string: the ad-hoc form, CSV import, AI Entry, receipt analyze (with a new helper that fuzzy-maps a freeform OCR payment string into a structured allocation while preserving the raw text), the receipt "Create Expense" buttons, the recurring endpoint, and seed data. The single-dropdown UX stays for Phase 1; multi-allocation UI ships in Phase 7.

Improved

  • Every read-path call site updated to go through primaryMethod(): ad-hoc table payment cell, vendors detail page transaction table, CategoryDrillDrawer (dashboard slice → transaction list), receipt preview payment row (now prefers the raw OCR string with structured fallback), receipt search index (searches both paymentMethodRaw and the canonical methods), smart-match candidate display, and the receipt-match-engine card-type scoring factor. The UX is unchanged in Phase 1 — every row still shows just the primary method — but the rendering layer is now ready for Phase 2 (brand icons + "+N more" multi-method badge) to layer in without touching reads again

v1.73.0

Receipt In-Line Itemization v2 — Three-Mode Analyze + AI Describer

New

  • POST /api/receipts/analyze now accepts a mode parameter with three values: "full" (the pre-1.73 default — extract line items + classify them + run overall classification + move the file into analyzed/{expenseType}/{L1}/), "extract" (run extraction only, skip every classification step, file still moves but to analyzed/ad-hoc/Unclassified/ so the user can review line items without spending tokens on classify), and "classify" (skip extraction entirely and re-run classification against the receipt's existing extractedData — pure metadata update, does NOT move the file). The receipts page split-button now surfaces all three modes as a dropdown on both "Analyze All Inbox" and "Re-analyze Selected", and the row-level / preview-modal re-analyze controls also expose the dropdown. Backward compatible — omitting mode defaults to "full"
  • New shared classification library at src/lib/receipt-classify.ts — the line-item classifier and the overall-receipt classifier were previously duplicated across /api/ai/receipt-scan (inlined line-item classification) and /api/receipts/analyze (inlined overall classification). Phase 4's Classify Only mode needed to re-run classification without re-extracting, so both routines were extracted here as classifyReceiptLineItems() and classifyReceiptOverall(). The analyze route now calls the shared library for both the full-mode overall-classification step and the classify-only path, collapsing two copies of the consensus-vs-AI-vendor logic into one
  • The line-item classifier now prefers userDescription over the raw description when building the prompt input — previously the classifier only ever saw the raw cryptic receipt text, so any cleanup the user did in the inline editor was ignored on re-classification. The fix is in classifyReceiptLineItems where the display-text builder reads `li.userDescription?.trim() || li.description`, and mirrored in classifyReceiptOverall's lineItemSummary. This is the keystone behaviour that makes the Classify Only mode + AI Describer combination meaningful — the user can now describe items once and have every subsequent re-classification pick up the cleaner text without another extraction round
  • New AI Receipt Describer modal (accessible from the preview modal's line item header and from the row-expanded detail pane) asks the AI to look at the receipt image and regenerate / improve / enrich the userDescription field on every line item, then writes the updated descriptions back into the receipt index in one shot. Three mode radio options: "From Scratch" (regenerate all descriptions, ignoring any existing user input), "Modify Existing" (keep correct ones, fix clearly-wrong ones, fill in the empty ones), and "Append Context" (preserve existing text and append brand / size / category hints to help downstream classification pick more specific L2/L3 buckets). An optional user hint textarea lets you pass freeform disambiguation context ("All items are from a hardware store") directly into the prompt
  • New /api/ai/describe-receipt route + three receipt-describe-* prompt templates registered in src/lib/ai-prompts.ts (receipt-describe-from-scratch, receipt-describe-modify, receipt-describe-append). All three templates return the same JSON shape [{ index, userDescription }] so the route applies updates uniformly regardless of mode. The route loads the receipt image via the same prepareInput-style vision pipeline as receipt-scan (supports JPG/PNG/WEBP/GIF and PDFs — tries page-1 rasterization first, falls back to raw-PDF-vision), sends it along with the prompt, parses the response, and updates receipt.extractedData.lineItems[i].userDescription by index. Does NOT re-run classification automatically — the workflow is Describe → review → Classify Only

Improved

  • New lastDescribedAt field on ReceiptRecord (Toronto-timezone ISO-8601) captures when the describer was last run against a receipt, set by the describe-receipt route. Mirrors the existing lastAnalyzedAt field so a future UI change can surface both timestamps side by side. analyzeReceipts() on the receipts page now accepts (ids, mode?, force?) and the batch Re-analyze Selected path passes force=true so the split-button respects the existing force-reanalysis behaviour

v1.72.0

Settings Page — Sub-Page Tab Navigation

New

  • Settings (Configurations) page restructured from a single 1760-line vertical scroll of 13 stacked sections into a 6-tab sub-page layout mirroring the Features page pattern: sticky header with the page title and tab pill row stays visible while the tab contents scroll beneath. Tabs are General (profile: view mode, owner, currency; backend logging; data management/backup/version), AI Providers (API keys for OpenAI / Anthropic / Gemini, preferred provider, model selection), Categories (ad-hoc / income / recurring CategoryEditor components — now one click away instead of buried at the bottom of the page under hundreds of lines of classifier tuning UI), Classifier (AI Classification Mode, L1 Candidate Filter Layer, Semantic Pre-Filter with prototype store and seed vendors, Category Description Injection, Low-Confidence Review Gate), Experiments (Classifier A/B Test with random and historical dispatch modes), and Prompts & Cache (AI Prompt Templates editor, Vendor Cache table, Recurring Templates Frequency Overview). Default landing tab is General. Each section retains its original wording, save logic, and visual design — this is pure reorganization with zero behavior changes inside any existing card
  • Tab state is URL-synced via a ?tab= query param using window.location and history.replaceState (chose this over next/navigation's useSearchParams to avoid the Suspense-boundary requirement that the Next 15 app router enforces on client-side search param readers). Deep links like /configurations?tab=categories jump straight to the Categories tab, the browser back button navigates between tabs, and ?tab=general is normalized to no query string so the default tab has a clean URL

Improved

  • Sections are wrapped in-place with {tab === 'x' && (...)} conditional render guards rather than extracted into separate component files, so all existing state (settings, apiKeys, prototype status, recurring templates, vendor cache, prompt draft, seed vendor draft, etc.) continues to live in a single parent component. This avoids prop-drilling through six new section components and keeps the diff reviewable — every save handler and local UI state hook is unchanged
  • Saving indicator moved from a free-standing strip at the top of the content area into the sticky header h1 as an inline chip, so the feedback is visible from every tab without taking up vertical space inside the tab content. Header styling mirrors the Features page header so the two pages now feel visually consistent

v1.71.0

Sticky Page Headers & Release Version-Bump Filter

New

  • Features page — all three subpages (Features, Bug Fixes, Release Notes) now freeze the page header in place while scrolling. On the Features and Bug Fixes tabs the frozen region covers the h1 + tab row + summary cards + filter pills + the table's column headers; on Release Notes it covers the h1 + tab row + releases count row (with cumulative totals) + the newest/oldest sort button + the new version-bump filter. Implemented with a single sticky wrapper div measured via useRef + useLayoutEffect + ResizeObserver so the table thead can be given a precise `top: headerHeight` offset via a new stickyTop prop on SortableHeader. Removed overflow-hidden from the table card wrapper and the inner overflow-x-auto because both were creating intermediate scroll containers that would have clipped any sticky children
  • Release Notes — new "Version bump" filter pill row beneath the sort button lets you filter the release list by bump type: Major (first number changes — hasn't happened yet), Minor (middle number changes), Patch (third number changes), and Bug fix (version unchanged but #hash suffix differs from the previous release). Bump types are computed chronologically by comparing each release to the one that immediately preceded it in time, so the classification always reflects the real shipping history rather than guessing from the version string in isolation. Filter pills reuse the existing FilterPills component and the releases count header dynamically switches to "N of M releases" when any filter is active

v1.70.1

Release Notes Header — Cumulative Totals

Improved

  • Release Notes subpage header now shows cumulative feature / enhancement / bugfix totals inline next to the "N releases" count. Aggregated once at render time by reducing over RELEASES and counting item.type, so it stays in sync with whatever gets added to the array. Gives a one-glance summary of everything that has ever shipped across the full release history without needing to scroll or expand any cards

v1.70.0

Release Note Count Pills in Collapsed Header

Improved

  • Release Notes subpage — each release card now shows the feature / enhancement / bugfix counts (Rocket / Sparkles / Bug icons + number) in the collapsed header row, so you can skim the whole page and see exactly what shipped in each release without having to expand a single card. The counts live on the right side of the header next to the timestamp and are consistently positioned whether the card is collapsed or expanded. Removed the now-redundant count row that previously rendered inside the expanded body — the expanded view goes straight into the items list since the header already carries the breakdown

v1.69.0#k9f3a

Fix Recurring Hydration Mismatch After Version Bumps

Improved

  • Added a mandatory "After a version bump — clear the dev cache" step to AGENTS.md so future feature releases carry the mitigation automatically and we stop shipping this class of regression on every minor bump

Fixed

  • Hydration error reappeared after the v1.69.0 release — stale .next cache held the old version string in server-rendered HTML while the client bundle had 1.69.0, same root cause as the earlier 1.44.0#h7d2a fix
  • Clearing .next and restarting the dev server resolved the immediate issue

v1.69.0

Category Description Staleness Tracking

New

  • New per-description snapshot fingerprinting — each generated category description now carries a CategoryDescriptionSnapshot { generatedAt, children[] } recording the sorted immediate-child names that existed when the description was written. Stored in three new parallel Settings fields (categoryDescriptionSnapshots, incomeCategoryDescriptionSnapshots, recurringCategoryDescriptionSnapshots) keyed identically to the existing CategoryDescriptions maps. Adds the CategoryDescriptionSnapshot / CategoryDescriptionSnapshots types to src/types/index.ts
  • Strict staleness rule wired into src/lib/category-descriptions.ts — L1 descriptions depend ONLY on their sorted L2 child names, L2 descriptions depend ONLY on their sorted L3 child names, and L3 leaf descriptions depend on nothing (only stale on rename/key-change). Adding a new L3 under an existing L2 therefore stales JUST that L2 — the parent L1's description stays fresh because its children (the L2 names) haven't changed. New computeSnapshotChildren / buildSnapshot / isDescriptionStale / countStaleDescriptions / enumerateStaleKeys helpers + a DescriptionFreshness = fresh | stale | untracked union. Also ships nowTorontoIso() — a tz-aware wall-clock ISO builder that works in Node and the browser via Intl.DateTimeFormat('America/Toronto')
  • Extended POST /api/ai/generate-description with snapshot stamping and a new mode:"stale" — single mode now returns { description, snapshot } so the client can persist both atomically. all/stale modes return { updated, updatedSnapshots } keyed the same way so the merge path on the client is identical. stale mode calls enumerateStaleKeys(hierarchy, descriptions, snapshots) server-side and regenerates only drifted entries, capped at the same MAX_ALL_CALLS=400 safety cap as all-mode. The route still does NOT write back to settings — the client persists via saveSettings, keeping the endpoint idempotent and safe to retry
  • CategoryEditor gained stale badges, a "Regenerate all stale" button, and a "Mark current as up-to-date" migration helper — the per-row description footer renders an amber "stale" pill (with tooltip) next to drifted descriptions and a muted "untracked" pill for legacy pre-v1.69 descriptions. The banner expands to show a second line with stale/untracked counts broken out by L1/L2/L3 level. A new amber "Regenerate all stale" button calls mode:"stale" and merges both descriptions and snapshots in one pass. "Mark current as up-to-date" stamps fresh snapshots on every untracked description without calling the AI — a zero-cost migration path for existing users
  • Automatic snapshot plumbing on every mutation — the rename and delete handlers for L1/L2/L3 in CategoryEditor now mirror their description-map updates onto the snapshots map, so renaming "Food & Dining" to "Food" carries the snapshot across the key change, and deleting a category removes its orphaned snapshot. Manual description edits in the inline editor refresh the snapshot to the current children so a user-written description is treated as authoritative for the current hierarchy state. Configurations page wires snapshots+onSnapshotsChange into all three CategoryEditor call sites (ad-hoc, income, recurring)

Improved

  • Semantic Pre-Filter handoff breadcrumb — when an ad-hoc L1 description drifts, the banner surfaces an italic note that the Semantic Pre-Filter prototypes will auto-rebuild on the next classify call. The embedding store's existing sha256 drift detection (v1.66.0) already invalidates stale prototypes automatically because buildPrototypeText includes the L1 description, so no forced rebuild is needed — the staleness UI just surfaces the implicit handoff so the user knows where to look

v1.68.0#a4f2

Bug fix — AI Expense Entry crashing with "Unexpected end of JSON input"

Fixed

  • Fixed temporal-dead-zone ReferenceError in /api/ai/extract-adhoc-expense (introduced in v1.67.0 when the route was wired into historical A/B routing). The new pickABArm call was placed on the line BEFORE the `const settings = applyABArmOverrides(...)` declaration, so Node threw "Cannot access settings before initialization" the moment a user submitted any free-form expense that needed classification. The crash happened before any inner try/catch could form a JSON body, so Next.js returned an empty 500 and the client's `await res.json()` surfaced the misleading "Unexpected end of JSON input". Reordered to call pickABArm with db.settings (mirroring classify-adhoc-l1's baseSettings → settings sequence) and only then derive the arm-merged settings const
  • Added a top-level try/catch envelope to the extract-adhoc-expense POST handler. Any unhandled throw now returns a real JSON body `{ error, draft: {}, missingFields: [], confidence: 0, classificationMode }` with HTTP 500 instead of an empty body, AND writes the failure to the backend logger via logError('/api/ai/extract-adhoc-expense', ...). Future regressions in this route will now surface on the /logs page under category=system level=error within seconds — closes the observability gap that hid this v1.67.0 regression for two days
  • Hardened the AI Composer's client-side submitAiCompose() in src/app/expenses/ad-hoc/page.tsx to read the response as text first and only then JSON.parse. A non-JSON 500 (HTML error page, empty body, network blip) now shows the user the real HTTP status + a 200-char body snippet pointing them at /logs, instead of the generic browser-thrown "Unexpected end of JSON input" that gave zero diagnostic value

v1.68.0

Feedback Learning Loop — Problem Vendors, Overrides & Review Gate

New

  • New problem-vendor detection scanner (src/lib/problem-vendors.ts) — walks the v1.65 classification audit log (data/logs/classifications.jsonl), weak-joins correction entries back to their originating classification by vendor+date+amount, normalises vendors via normalizeVendor, groups by normalised key, and ranks vendors by correction count inside a configurable lookback window. Surfaces per-vendor modal AI triple, modal user triple, distinct-triple counts, estimated wasted AI cost, and last-corrected timestamp. detectStableProblemVendors() further filters to vendors where the user's most-frequent triple has a strict majority over the AI's pick — the "the user clearly prefers X" signal used for auto-seeding
  • Vendor cache gained an "override" source priority — VendorCacheEntry.source now accepts 'ai' | 'manual' | 'override'. upsertVendorCache() gains an isOverride guard: once an entry is source="override", subsequent 'ai' upserts still bump hitCount/lastUsed but cannot clobber the category/subCategory/subCategory2 triple. Only a matching 'manual' or 'override' upsert can overwrite it. lookupVendorCache() returns confidence=0.99 for override matches so they trump ambient AI confidence. New upsertVendorOverride() helper creates/updates override entries without bumping hit counts (override is a policy decision, not a usage signal)
  • New /api/ai/problem-vendors route — GET returns the ranked list with query-params threshold (min correction count, default 2) and lookbackDays (default 30). POST promotes a single problem vendor to a permanent cache override via upsertVendorOverride(vendor, category, subCategory, subCategory2). 400s on malformed body. Closes the loop: user corrects vendor three times → dashboard surfaces it → one click writes an override → classifier stops calling the AI on that vendor entirely
  • Auto-seed stable problem vendors into embedding prototypes — rebuild-prototypes route (v1.66) now imports detectStableProblemVendors and merges each stable vendor's displayName into the seedVendors array for its user-preferred L1 before computing the prototype text. Capped at 20 extra seeds per L1, deduped case-insensitive against the manual seed list. Errors inside the scanner are swallowed so a corrupt log line can never block a rebuild. Response now includes an autoSeededProblemVendors count so the UI can surface the graft
  • Low-Confidence Review Gate — new Settings.lowConfidenceGate ({ enabled, defaultFloor, categoryFloors? }) with DEFAULT_LOW_CONFIDENCE_GATE = { enabled: false, defaultFloor: 0.60, categoryFloors: {} }. New resolveConfidenceFloor(gate, category) and shouldMarkNeedsReview(gate, category, confidence) helpers handle per-category floor overrides. OneTimeExpense gains an optional needsReview?: boolean flag. The gate is evaluated as a derived view at read-time rather than stamped into the classify path, so it costs nothing when disabled and picks up floor changes instantly without any reclassify
  • New /review page and /api/review endpoints — GET /api/review derives the queue from db.oneTimeExpenses: not nullified, needsReview !== false, classifierConfidence below the resolved floor, within the lookback window (default 90 days). Sorted by shortfall (floor − confidence) descending so the worst picks float to the top. POST /api/review/dismiss clears needsReview on a single row (user accepts the AI pick). GET /api/review/count is a lightweight count endpoint for sidebar badges. The /review page renders the queue as a table with per-row Approve (dismiss API) and Correct (links to /expenses?edit={id}) actions, and shows a gate-disabled empty state pointing to Settings when Settings.lowConfidenceGate.enabled is false
  • Auto-clear review flag on classification edits — expenses PUT handler now sets needsReview: false whenever the user touches category / subCategory / subCategory2 on an expense that was previously flagged. Reuses the existing v1.65 touchedClassification branch so editing a classification both appends a correction log entry AND removes the row from the review queue in a single round trip. No explicit "mark reviewed" step required
  • New Problem Vendors dashboard at /accuracy/problems — sticky controls for threshold (≥2 / ≥3 / ≥5) and lookback window (7d / 30d / 90d / all time), table of offending vendors showing the normalised key, correction count, AI's most-frequent wrong triple, the user's most-frequent correction triple, estimated wasted AI $, last-corrected timestamp, and a one-click "Create Override" button that POSTs to /api/ai/problem-vendors and flips the row into an "Overridden" badge without a page reload. Empty-state copy tells the user to widen lookback or lower the threshold when nothing matches
  • New Feedback Learning Loop settings card in /configurations — enable/disable toggle for the Low-Confidence Review Gate, a default-confidence-floor slider (40–90%, default 60%), a direct link to the Problem Vendors dashboard, and explanatory copy that connects the four surfaces (corrections → problem vendors → cache overrides → prototype seeds). Lives between the A/B Test card and the AI Prompt Templates card, wired through saveSettings({ lowConfidenceGate })

Improved

  • Sidebar nav gained two entries under the Logs/Accuracy group — "Problem Vendors" (→ /accuracy/problems, Sparkles icon) and "Review Queue" (→ /review, Inbox icon) — both indented to match the existing sub-group styling. Keeps the feedback-loop surfaces one click away without disturbing the top-level nav order

v1.67.0

A/B Test Historical Mode + Per-Arm Filter Strategy

New

  • Per-arm filterStrategy picker in the A/B Test card — each arm now has its own 4-button picker (off / ai / embedding / hybrid) that sets configA.filterStrategy and configB.filterStrategy independently. Replaces the legacy "Filter layer (multi-phase/flat)" checkboxes with a direct, head-to-head strategy comparison, so you can A/B the v1.63 AI filter against the v1.66 semantic pre-filter in a single run. Arm override flows through applyABArmOverrides → resolveFilterStrategy → runFilterStrategy so every classify call on that arm sees the arm-specific strategy
  • Per-arm embedding confidence-floor slider — when an arm's strategy is "hybrid", a per-arm embeddingConfidenceFloor slider appears so the two arms can A/B the hybrid fallback threshold (e.g. Arm A floor=0.55 vs Arm B floor=0.70). Undefined arm fields fall through to base settings so existing arms keep working unchanged
  • New Historical A/B test mode — Settings.abTestMode.mode now accepts "random" (v1.65 coin-flip-inside-window behavior) or "historical" (new). In historical mode each arm defines its own dateRange {start, end} and the arm is routed by the representative transaction date of the batch instead of a random coin flip against today's calendar date. Lets you retroactively A/B-compare strategies across historical quarters without touching today's live classification traffic
  • pickABArm dispatcher rewrite — pickABArm now takes {todayYMD, representativeTxDate?} and branches on mode. Random mode preserves the v1.65 window + 50/50 behavior. Historical mode ignores today's date, compares the representative tx date against each arm's dateRange, and returns the first arm whose range contains the tx (else undefined). Second-arg string callers still compile via a backwards-compat overload
  • applyABArmOverrides extended for filterStrategy + confidence floor — propagates arm.filterStrategy and arm.embeddingConfidenceFloor onto the merged Settings object so downstream readers (resolveFilterStrategy, runFilterStrategy, semantic-filter) see the arm-specific values for the duration of a classify call
  • Classify route wiring for historical arm routing — classify-adhoc-l1, reclassify-full, and extract-adhoc-expense now pass {todayYMD, representativeTxDate: items[0]?.date} to pickABArm so historical mode can resolve an arm from the batch. extract-adhoc-expense previously did not participate in A/B tests at all — closed out so all three classify entry points support both dispatch modes
  • Accuracy dashboard A/B panel annotations — the /accuracy dashboard's A/B Arm Comparison card now shows each arm's resolved filterStrategy and (in historical mode) the arm's dateRange alongside its correction rate. A "mode: random / historical" badge makes it obvious at a glance which comparison is running. Snapshot of abTestMode ships inside the /api/accuracy response so the page renders it without a second fetch
  • Prefilled Q4 2025 vs Q1 2026 historical A/B test — ships a pre-configured historical comparison in data/budget.json → settings.abTestMode: Arm A = filterStrategy="ai" with dateRange 2025-10-01 → 2025-12-31 (Q4 2025, legacy AI filter baseline) vs Arm B = filterStrategy="embedding" with dateRange 2026-01-01 → 2026-03-31 (Q1 2026, zero-AI-cost semantic filter). Both arms force promptDescriptionInjection on for classify-adhoc-l1 and reclassify-full so the only axis that varies is the filter strategy — directly answers "is the embedding filter as accurate as paying for the AI filter?"

v1.66.0

Semantic Pre-Filter — Zero-AI-Cost L1 Narrowing

New

  • New semantic pre-filter — each L1 category is embedded once into a "prototype" vector built from its name, description, L2/L3 subtree, and optional seed vendors. At classify time each transaction is embedded once and the top L1 candidates are picked by cosine similarity. No AI call for narrowing — three orders of magnitude cheaper than the v1.63 narrow-l1-candidates AI filter ($0.02/1M tokens vs GPT-4 rates). Uses OpenAI text-embedding-3-small (1536 dim). Lives in new src/lib/semantic-filter.ts and src/lib/embedding-store.ts
  • New filterStrategy setting (off | ai | embedding | hybrid) supersedes the legacy filterL1Candidates boolean. Hybrid mode runs embeddings first and falls through to the v1.63 AI filter only for transactions whose top-1 cosine similarity falls below a configurable confidence floor (default 0.55). The remaining embedding-confident transactions pass through unchanged. Best of both worlds: embedding cost on the easy 90% and AI accuracy on the ambiguous tail
  • Unified filter-strategy dispatcher — new src/lib/filter-strategy.ts runFilterStrategy collapses the v1.63 AI path and the v1.66 embedding path behind a single entry point. classify-adhoc-l1, reclassify-full, and extract-adhoc-expense (both flat and multi-phase) now call the dispatcher instead of branching on separate boolean toggles. Returns a per-transaction providerByIndex map so each classification log entry can be stamped with the exact provider that narrowed it (ai, embedding, hybrid-embedding, hybrid-ai-fallback)
  • Prototype store with drift detection — data/embeddings/categories.json persists the per-L1 vectors along with a sha256 source-hash of the prototype texts (which includes the embedding model id so provider swaps invalidate automatically). isStoreFresh() compares the on-disk hash against what the current hierarchy would produce so stale prototypes surface in the UI. Classify reads the store; it never auto-rebuilds to avoid surprise cost spikes on cold starts
  • New /api/ai/rebuild-prototypes route — POST builds (or rebuilds) the prototype store for all L1 categories in one batched embeddings call; GET returns the current store status (built-at, l1 count, freshness vs. current settings) for the Settings card. Unlike the classify hot path, rebuild surfaces errors to the caller directly so the Rebuild button can show meaningful failure messages
  • New Semantic Pre-Filter settings card — Settings → Semantic Pre-Filter lets you pick the filter strategy (off / ai / embedding / hybrid), tune the hybrid confidence floor with a slider, trigger prototype rebuilds, see a fresh/stale/not-built badge with the last-built timestamp, and add per-L1 seed vendors to tighten the prototype vectors for payment-processor-heavy vendor strings (SQ *MERCHANT, POS PURCHASE 1234, etc.)
  • Observability extension — classification log entries now carry filterProvider (ai | embedding | hybrid-embedding | hybrid-ai-fallback) alongside the existing filter field. /api/accuracy aggregates a new byFilterProvider breakdown and the /accuracy dashboard renders a head-to-head Corrected-Rate table so you can see whether the semantic pre-filter is actually pulling its weight vs the AI filter. Recent-50 stream is annotated with the per-row provider tag too
  • A/B arm override for filterStrategy — existing ABTestArm shape extended to include filterStrategy and pushed through applyABArmOverrides, so you can A/B "AI filter vs embedding filter" using the existing A/B infrastructure. No new code paths in classify routes — the arm override just feeds into resolveFilterStrategy

Improved

  • Backwards-compatible migration — resolveFilterStrategy() falls through to the legacy filterL1Candidates booleans when filterStrategy is undefined. Existing installs that had the v1.63 filter enabled keep seeing "ai" behavior until they opt into a new strategy from the settings card. Zero user action required to upgrade
  • Graceful embedding fallback — any throw inside the semantic-filter path silently falls through to the v1.63 AI filter (in hybrid mode) or to the legacy unfiltered full hierarchy (in embedding-only mode). Matches the v1.63/v1.64 pattern — classification can never be blocked by an embedding infrastructure outage

v1.65.0

Observability & Evaluation — Accuracy Dashboard + A/B Mode

New

  • New classification audit log — every AI classification now appends a JSONL entry to data/logs/classifications.jsonl with templateId, injection flag, filter state, candidate shortlist, model, per-item cost, confidence, and (when active) the A/B arm. Cache hits are logged too. Writes silent-fail on filesystem errors so logging can never break a classify call
  • Correction detection — expenses now store a classifier snapshot (classifierCategory/SubCategory/SubCategory2/Confidence). The expenses PUT handler compares the user's new triple against the snapshot and appends a correction entry to the audit log when the user overrides the AI. Reclassify calls roll the snapshot forward so fresh AI decisions are not mis-flagged as corrections
  • A/B test mode — new Settings.abTestMode with an enable flag, start/end date window, and per-arm overrides for filter-layer toggles and description injection. pickABArm rolls a 50/50 per classify call inside the window; applyABArmOverrides merges the arm onto base settings for the duration of that call. The chosen arm is stamped on every classification log entry and returned to the caller
  • New /accuracy dashboard — KPI cards (classifications, corrections, corrected-rate, avg confidence, total cost) plus breakdowns by configuration (templateId × injection × filter), L1 category, model, and A/B arm. Includes a recent-50 stream with corrected/ok status. Backed by /api/accuracy which aggregates the JSONL log and weak-joins corrections by vendor+date+amount
  • Observability wired into classify-adhoc-l1 and reclassify-full — both routes pick an A/B arm at the top of the handler, merge overrides onto base settings, and emit a classification log entry for every transaction (including vendor-cache hits) with per-item cost attribution. Both respect the filter layer and log the candidate shortlist when one is active
  • Filter layer close-out: extract-adhoc-expense — both the flat-mode and multi-phase paths of the single-expense extractor now run narrow-l1-candidates before classification when Settings.filterL1Candidates is enabled. Uses buildFilteredSingleHierarchyList (flat) or buildFilteredCategoryL1List (multi-phase) to hand the model a pruned category list. Closes out filter-layer coverage across the classification surface
  • A/B Test Settings card — new Classifier A/B Test card under AI Classification lets the user enable/disable the split, set start/end dates, and configure per-arm overrides for filter layer (multi-phase, flat) and description injection (classify-adhoc-l1, reclassify-full). Links directly to the /accuracy dashboard for comparison

Improved

  • Sidebar gained an Accuracy nav entry under Logs with the Activity icon so the dashboard is one click away from any page

v1.64.0

L1 Candidate Filter Layer (Flat Mode)

New

  • Filter layer now wires into flat-mode classification — the v1.63.0 pre-classification narrow-l1-candidates step runs in front of reclassify-full, so users on the flat classifier also get the accuracy lift that multi-phase users got last release. The shared helper in src/lib/narrow-l1-candidates.ts is mode-agnostic, so no new prompt template was needed
  • Flat-mode toggle activated in Settings → AI Classification → L1 Candidate Filter Layer — the previously disabled "Flat classification" checkbox is now live. Enable it to route every reclassify-full call through the filter layer. Still off by default so existing behavior is unchanged until you opt in
  • Pruned hierarchy per group — when the filter is on, expenses are grouped by their sorted L1 candidate-set signature and each group gets its own reclassify-full call with the full L1/L2/L3 blob pruned down to just the selected L1s (and their full L2/L3 subtrees). Batches of similar expenses still become a single classify call instead of one per expense

Improved

  • New buildFilteredSingleHierarchyList helper in src/lib/narrow-l1-candidates.ts — companion to the multi-phase buildFilteredCategoryL1List. Falls back to the full unfiltered hierarchy if the candidate set would leave the prompt empty, so the flat classifier never sees an empty candidate pool
  • Graceful fallback — if the filter call throws for any reason, reclassify-full silently falls through to the legacy unfiltered path. Reclassification can never be blocked by a filter-layer outage
  • Filter cost surfaced in the response — reclassify-full now returns filtered: true, filterCost, and groups: N alongside the reclassifications array so the UI can surface how the cost broke down between the filter step and the per-group classification calls

v1.63.0

L1 Candidate Filter Layer (Multi-Phase)

New

  • New pre-classification step — an optional narrow-l1-candidates AI call runs before the main classifier and hands it only the top 5 most plausible L1 categories per transaction instead of all 17. Sharply reduces misfires for ambiguous vendor strings (e.g. "TPG PAYMENT" or "SQ *MERCHANT") where the classifier previously had too many choices
  • Wired into multi-phase classification — runs in front of classify-adhoc-l1 on every CSV import batch. The classifier groups transactions by their shared candidate set, so a batch of 20 transactions that all narrow to the same 5 categories still becomes a single classify call, not 20
  • Off by default — enable in Settings → AI Classification → L1 Candidate Filter Layer card. Multi-phase toggle is fully wired; flat-mode toggle is visible but pinned to v1.64.0
  • Vendor cache bypass — transactions already covered by a ≥0.7 vendor cache hit skip the filter call entirely. You keep every ounce of the cost savings the vendor cache gives you, while uncached transactions still get the accuracy lift
  • New /api/ai/narrow-l1-candidates standalone route — also callable directly, returns candidates: Record<number, string[]> plus cachedIndices for the transactions it skipped. Useful for future flat-mode integration and for ad-hoc diagnostics
  • New narrow-l1-candidates prompt template in Settings → AI Prompt Templates — editable like every other prompt, with {{categories}}, {{transactions}}, and {{count}} placeholders. Wording biases toward recall so the classifier downstream has room to make corrections

Improved

  • Graceful fallback — if the filter call fails for any reason, classify-adhoc-l1 silently falls through to the legacy unfiltered path so a filter outage never breaks CSV imports

v1.62.0

Per-Prompt Category Description Injection

New

  • Per-prompt "Category Description Injection" setting — new Settings → AI Classification card with one checkbox per classification prompt (classify-adhoc-l1/l2/l3, classify-income, receipt-classify, receipt-classify-overall, reclassify-l1, reclassify-full) lets you trade token cost for disambiguation accuracy on each prompt independently
  • Smart defaults — L1 classification and all reclassification prompts default to ON (biggest accuracy lift when the candidate list is widest); L2/L3 and both receipt flows default to OFF (already narrowed, cheaper). Each row shows a default: on/off badge until you override it
  • Dual prompt templates (described + bare) — every injection-aware prompt now ships as two sibling templates in Settings → AI Prompt Templates: the original describes-each-candidate variant and a new "-bare" sibling worded for plain candidate lists. Both are independently customizable so you can tune the cheap path and the rich path separately

Improved

  • Graceful description fallthrough — when a category has no description on file, the AI still sees the bare category name on the same line (no placeholder, no fail). This means you can leave descriptions partially filled without breaking any prompt
  • Unified list builders — category-descriptions.ts refactored so buildCategoryL1List/L2List/L3List, buildSingleHierarchyList, and buildMergedReceiptList all take an explicit withDescriptions flag. Legacy buildDescribed* helpers kept as thin wrappers for backwards compatibility

v1.61.0

AI-Assisted Ad-hoc Expense Entry

New

  • AI (sparkle) button added beside the Full Form button on the Ad-hoc Expenses page — opens a natural-language composer where you describe an expense in plain English and the AI extracts date, amount, vendor, description, notes, payment method, and any categories you explicitly named
  • Live slot-fill indicator on the composer — chips flip from grey to green as each field is captured, so you always know what the AI has pulled from your message
  • New extract-adhoc-expense prompt template under a brand-new "Expense Entry" category in Settings → AI Prompts, fully editable via the standard promptOverrides flow
  • New /api/ai/extract-adhoc-expense dual-mode route — extracts slots in one cheap call, then delegates L1/L2/L3 classification to the existing flat or multi-phase pipeline (honouring Settings → Classification Mode), with vendor cache short-circuit
  • Full Form opens pre-filled after AI extraction — any required fields the AI could not capture are highlighted with amber rings so you know exactly what to finish before saving. The Full Form remains the final confirmation step in every case

Improved

  • Payment-method fuzzy normalization — the AI is allowed to say "amex" or "american express" and the route maps it to the canonical PAYMENT_METHODS enum

v1.60.0

Category Descriptions & Dual-Mode Classification

New

  • Category descriptions on AI prompts — every L1/L2/L3 option sent to the model is annotated with a 1-sentence description to disambiguate similar category names (applies to CSV classify L1/L2/L3/income, receipt line-item classify, receipt overall classify, and both reclassify prompts)
  • AI category description generator — Sparkles button on every category row in Settings auto-synthesizes a 1-sentence description from the node's children and siblings; "Generate all missing" fills every empty description in a hierarchy in one batched run
  • Dual-mode classification toggle — Settings → AI Classification Mode lets the user pick between multi-phase (sequential L1→L2→L3 calls, default) and flat (single hierarchy-wide call) for the reclassify modal
  • ClassificationTracker orchestration helper — new src/lib/classification-tracker.ts manages per-item state through L1→L2→L3, groups items for efficient batched calls, handles short-circuits when sub-levels don't exist, and accumulates cost/source metadata for logging
  • New /api/ai/reclassify-full route — wires the pre-existing reclassify-full prompt template into a live endpoint, called from the ReclassifyModal's flat-mode branch to re-evaluate L1/L2/L3 in one AI call

Improved

  • classifyInBatches helper now accepts a payload-key override to support routes that expect { expenses: [...] } instead of { transactions: [...] }

v1.59.0#d7f2b

Fix Canvas Module for Receipt PDF Rendering

Fixed

  • Fixed canvas native module crash in receipt PDF-to-image rendering — turbopack was bundling the .node binary instead of externalizing it, forcing every scanned PDF through the expensive raw-PDF-vision path

v1.59.0#c4e9a

Stale Receipt Index Recovery

Fixed

  • Fixed analyze failure when receipt index reverts to stale inbox state — now auto-recovers records by finding analyzed files matching receipt ID
  • Inbox scan cleanup removes or recovers orphaned inbox records pointing to non-existent files

v1.59.0

Receipt Search & Feature Tracker Fix

New

  • Receipt search bar — live-filter receipts by vendor, item description (raw/AI/manual), category (L1/L2/L3), card type/last four, payment method, total, or item ID

Fixed

  • Fixed missing feature names for v1.56–1.58 entries on Features page (title→name field mismatch)

v1.58.0

Expense↔Receipt Interop & Polish — Phases 8+9

New

  • Receipt preview modal on expense page — click receipt link icon to view image, extracted data, and line items inline
  • Expandable expense rows show receipt line items — when expense has a matched receipt, expand row to see receipt items as sub-rows
  • Receipt→expense category sync — changing a receipt's classification auto-updates the matched expense's category
  • Card-to-owner mapping configuration — auto-detect cards from receipts, assign owners, used when creating expenses from receipts
  • Match summary dashboard — total receipts, match rate %, matched/unmatched dollar values, and inbox count shown at top of receipts page
  • Batch re-analyze — select multiple analyzed receipts and re-run AI analysis with updated prompts
  • File rename migration utility — rename existing analyzed files to standardized naming convention with dry-run option

Improved

  • Graceful handling for receipts with no extracted line items — shows overall classification, vendor, and total instead of empty table
  • Card-to-owner mapping persists in settings and auto-resolves owner for split expenses too

Fixed

  • Expense category editing verified — PUT handler correctly persists category, subCategory, and subCategory2 changes

v1.57.0

Smart Match Engine & Expandable Receipt Modal

New

  • Smart match scoring engine — weighted multi-factor scoring (amount 30%, date 25%, vendor 20%, card type 15%, category 10%) replaces simple text search
  • New /api/receipts/smart-match endpoint — returns top 10 ranked expenses with score breakdown per factor
  • Smart match UI in receipt modal — ranked results with visual score bars and color-coded factor badges (green/amber/red)
  • Expandable receipt modal — toggle between compact (w-96) and expanded (95vw) views with image 1/3 + details 2/3 layout

Improved

  • Card type and last-4 digits shown in modal header for quick identification
  • Auto-match route upgraded to use smart match engine with 0.80 threshold

v1.56.0

Receipt Linking & Orphan Fix — Phase 5

New

  • Per-row receipt link indicators on expenses page — green link icon for linked, amber alert for no receipt
  • Receipt filter bar on expenses page — filter by All, Linked, or No Receipt with count badge

Fixed

  • Unmatched receipts now stay as "analyzed" instead of being auto-orphaned after Run Matching
  • Unmatching a receipt returns it to "analyzed" status (previously set to "orphan")

v1.55.0

Multi-Phase AI Extraction for Large Receipts & New Item Tax Calculation

New

  • Multi-phase AI extraction for large receipts (>10 items) — Phase 1 raw text extraction with validation, Phase 2 gap-filling for missing items
  • Phase 1 prompt enforces exact OCR text, validates against Items Sold count and subtotal, self-corrects before returning
  • Phase 2 verification pass detects missing items and amount errors, splices corrections into Phase 1 results

Fixed

  • New line items now auto-calculate +Tax column using receipt-level tax rate as fallback

v1.54.0

Editable Receipt Line Items & Analyzed Tab Categorized View

New

  • Raw receipt description and item ID are now editable on line items — correct AI extraction errors inline
  • New items can be added with exact receipt name, optional ID, and AI-interpreted description
  • Secondary sort picker added next to Group By — choose "then Vendor A-Z" or "then Date ↓" for within-group ordering

Improved

  • Analyzed tab defaults to categorized (group by category) view on selection

v1.53.0#r7k3

Receipt Line Items — Two-Line Descriptions, Discount Editing & Tax Column

Fixed

  • Two-line description restored — raw receipt text on line 1, editable AI-interpreted name on line 2
  • Discount display shows original price, minus sign, editable discount amount, and remove (×) button
  • +Tax column auto-updates proportionally when amount is edited, with amber highlight for recalculated values
  • Discount add button redesigned as visible −$ pill with functional add/edit/remove flow
  • Decimal point input fixed in all amount fields — typing "2.50" no longer strips the decimal
  • Hydration mismatch warning resolved for version display in sidebar

v1.53.0#u4f2

Receipt Page UI Polish — Sticky Headers, Compact Layout & List Default

Fixed

  • LineItemTable category columns rebalanced from 2fr to 1fr for better horizontal space utilization
  • Sticky page header — title, action bar, tabs, and list column headers remain visible while scrolling
  • Sticky expanded receipt detail header — line item count and actions stay pinned when scrolling through items
  • Default view mode changed from card to list for more practical receipt management
  • Upload zone made compact (single row) and scrolls away while controls stay sticky

v1.53.0

Manual Line Item Overrides & Item ID Display

New

  • Receipt item IDs (Costco SKUs, PLU codes) now extracted by AI and displayed as mono badges next to descriptions
  • Manual discount add/edit/remove — add discounts to any item, edit original price and discount with auto-recalculation
  • Insert missed line items at any position — "Add item" at bottom or click between rows to insert
  • Full manual override of all AI-extracted fields — fix wrong amounts, descriptions, and discounts to resolve sum mismatches

Improved

  • PUT route rewritten to handle full edit shape: insertions with position, discount field updates, and item deletions

v1.52.0

Line Item Editing, Filtering & Verification

New

  • AI extraction preserves original receipt line item order — items appear top-to-bottom as printed
  • Filter toggle to hide classified items and show only those needing manual classification
  • Delete individual line items with trash icon — includes undo and permanent removal on save
  • Editable line item amounts — click to change dollar values with amber highlight for modified amounts
  • Line item sum verification — compares item total against receipt subtotal/total with smart mismatch display

Improved

  • Red warning banner for mismatches >$0.05, subtle indicator for minor rounding differences, green checkmark for exact match

v1.51.0

Discount Line Item Detection & Merging

New

  • AI extraction now detects discount/negative line items — Costco TPD codes, DISC/COUPON/PROMO keywords, and negative amounts
  • Discount lines automatically merged into their parent item with original price, discount amount, and final price

Improved

  • Line items with discounts show struck-through original price, green discount badge, and bold final price
  • Discount description (e.g. "TPD/2082506") shown below product name in green text
  • Total item count reduced to real products only — discount rows no longer count as separate items

v1.50.0

List View Sorting, Tip Display, $0 Line Item Filter

New

  • Sortable column headers in receipt list view — click any header to sort asc/desc with arrow indicator
  • All 7 columns sortable: Receipt, Vendor, Date, Total, Category, Analyzed, Status
  • $0 line items automatically filtered out during receipt analysis — reduces noise from placeholder/voided items

Improved

  • Tip/gratuity row displayed in receipt totals section between Tax and Total
  • AI extraction prompts updated to skip $0 items at source
  • Analyzed column sorts with unanalyzed receipts always at end; defaults to most-recent-first

v1.49.0#vpjo

Water & Sewer — Ledger Entry, Forecast Chart, Balance Accuracy

Improved

  • HistoryForecastChart now projects future payments from template paymentSchedule/frequency when no entries exist — fixes empty "No payment history yet" for new templates with scheduled payments

Fixed

  • Payment schedule corrected to include the carry-over from an earlier unpaid bill — Balance page now shows the accurate tri-annual amount
  • Expense entry added for the Water & Sewer payment, discounted via pre-authorized payment

v1.49.0

Water & Sewer Utility — Tri-Annual Recurring Expense + Balance Column

New

  • Water & Sewer recurring expense template with custom tri-annual frequency (3x/year), payment schedule, early payment discount, and pre-authorized payment details taken from the municipal utility bills
  • Water/Sewage column added to Balance Projection page between Hydro and Gas — included in committed total, quarterly/annual grouping, and YoY comparison

Improved

  • Forecast based on an estimated per-cycle amount derived from the latest bill (current billing minus arrears)

v1.48.0

Receipt Enrichment — Dual Descriptions, Overall Classification, Tax Distribution

New

  • Dual-description model: exact receipt text preserved separately from AI-interpreted product name, with editable user override
  • Receipt overall L1/L2/L3 classification using line item consensus or new receipt-classify-overall AI prompt
  • AI analyzed timestamp (lastAnalyzedAt) with Toronto timezone — updated on every analyze/re-analyze
  • Filename convention overhaul: {date}_{VENDOR}_{L1_Category}_items_{N}_{id}.ext
  • Tip/gratuity extraction from receipts with proportional tax+tip distribution to line items
  • New receipt-classify-overall AI prompt template for vendor-based receipt classification

Improved

  • Line items show both base cost and +tax distributed cost when tax exists
  • Overall classification (L1 › L2 › L3) displayed in card view, list view, expanded detail, and preview modal with source badge
  • New "Analyzed" column in list view showing last analysis date-time
  • Extraction prompts now preserve character-for-character receipt text without AI cleanup

v1.47.1

Backend Logs — Deep AI + Data Visibility

Improved

  • Full rendered prompt now logged and visible in AI call detail — see exactly what was sent to the model
  • Classification pipeline step indicators (Step 1/4 Type Detection → Step 2/4 L1 → Step 3/4 L2 → Step 4/4 L3) with item counts and context
  • Data mutation detail now shows collection breakdown (income, expenses, entries, accounts, etc.) instead of just "wrote to file"
  • Receipt index writes show status breakdown (inbox/analyzed/orphan counts)
  • Vendor cache writes show recent vendor names
  • AI response preview with expand/collapse toggle for both prompt and response
  • Dedicated DataDetail renderer with file badge, operation badge, size, and collection grid

v1.47.0

Backend Activity Logs — Full Observability Dashboard

New

  • Backend activity logging system — in-memory ring buffer with debounced file persistence to data/logs.json
  • Logs page with real-time 2s polling, category/level/search filters, and collapsible detail cards
  • AI call instrumentation — model, provider, template ID, prompt/response length, cost, duration, fallback status, response preview

Improved

  • Data mutation tracking — budget.json, receipts-index.json, and vendor-cache.json write operations logged
  • Receipt pipeline stage logging — analyze-start, extracted, classified, filed, error stages with progress visualization
  • Settings toggle for backend logging with live sync — zero overhead when disabled
  • Color-coded category bars (AI=blue, Data=green, Receipt=purple, API=teal, Batch=amber, System=gray)
  • Structured detail rendering with syntax-colored JSON values (strings=green, numbers=blue, booleans=amber)

v1.46.0

List View Analyze Button + AI Prompt Visibility Confirmation

New

  • Analyze / Re-analyze button (Sparkles icon) in list view rows next to the eye icon

Improved

  • Inbox receipts show Analyze with loading spinner; analyzed/orphan receipts show Re-analyze
  • Confirmed all three receipt AI prompts (extract, extract-text, classify) visible and editable in Configurations → AI Prompt Templates → Receipt Scanning

v1.45.0

Payment Card Extraction, Category Colors Across All Views

New

  • Structured payment card extraction — card type (AMEX/Visa/MC/Debit) and last 4 digits parsed from receipts

Improved

  • Card type and last-four shown in card view, list metadata row, and line item detail
  • L1 category badge with configured color now shown in card view
  • L1 category in list view rows now uses configured palette color

Fixed

  • Fix: Line item color bar now uses items-stretch and darker text color for visibility

v1.44.0#h7d2a

Fix Hydration Mismatch from Stale Build Cache

Fixed

  • Cleared stale .next cache that served old version string (1.43.0) while client had 1.44.0, causing React hydration error

v1.44.0

Mortgage Extra Payments — One-Time Lump Sums Reduce Amortization

New

  • Extra mortgage payments from recurring expenses integrated into amortization schedule as 100% principal
  • relatedExpenseId field links one-time extra payments to their parent mortgage

Improved

  • Extra payments reduce remaining balance and shorten amortization in all charts and forecasts
  • Extra payment rows highlighted with green "Extra" badge in payment history and full schedule

v1.43.0

Smart Classification — L3 Categories, Product Identification, Category Colors

New

  • L3 category classification — AI prompt now includes full L1>L2>L3 hierarchy for deepest-level matching
  • AI line item description enrichment — cryptic receipt abbreviations resolved to human-readable product names
  • Fallback classification indicator — orange "fallback" badge on items using receipt-level category instead of independent classification

Improved

  • L1 category color bar on each line item in the receipt table
  • Eye icon in list view rows for quick receipt image/PDF preview

v1.42.0

Extraction Method Indicator — See How Each Receipt Was Analyzed

New

  • Extraction method badge on analyzed receipts in card view, list view, and line item table

Improved

  • Receipt-scan API now tracks and returns which extraction pipeline was used (pdfjs-text, canvas-vision, raw-pdf-vision, image-vision)
  • extractionMethod field added to ReceiptRecord type and persisted on analysis

v1.41.0

Mortgage Accuracy Overhaul — Canadian Compounding, Payment Charts, Date Fix

New

  • Canadian semi-annual compounding — matches the lender's own amortization report within $10 over the full term
  • Payment breakdown trendline chart showing interest, principal, and total per payment
  • Interest vs principal % stacked area chart showing payment composition over time

Improved

  • Red "Today" marker line on all mortgage charts (nearest-point matching)

Fixed

  • Fix: Payment dates now land on correct day of week (Wed not Thu) via firstPaymentDate anchor
  • Fix: Interest calculation uses Canadian semi-annual compounding instead of simple rate/52

v1.40.0

Receipt List View — Category Grouping, Text Tables, Editable Classifications

New

  • Card / List view toggle for receipts page
  • Tabular list view with vendor, date, total, category, and status columns
  • Category and date grouping with collapsible sections
  • Text receipt table showing line items with description, amount, and L1/L2/L3 classification
  • Reusable CategoryDropdowns component for cascading L1→L2→L3 selection
  • Inline editable line item categories that persist to receipts-index.json

v1.39.0#p7d2

PDF Error Reporting + Analyzed Filename Display

Fixed

  • Fix: PDF analysis now reports specific failure reason instead of generic "file not found"
  • Fix: Analyzed receipts show structured filename instead of original upload name

v1.39.0

Receipt System Hardening — Security, Error Handling, Settings

Improved

  • Error feedback banner for all receipt API operations
  • Expense owner read from app settings instead of hardcoded
  • Removed unused l2Category/l3Category type fields

Fixed

  • Fix: Path traversal vulnerability in receipt DELETE endpoint
  • Fix: HEIC format removed from inbox scanner (consistent with upload route)
  • Fix: File extension fallback now uses mimeType instead of defaulting to .jpg
  • Fix: Classification failure now flagged on receipt record instead of silent

v1.38.0

Mortgage Dashboard Overhaul — Weekly Payments, Deal Summary, Term Tracking

New

  • Weekly/biweekly amortization support in payment calculator
  • Payment history table with weekly, monthly, and quarterly view toggle
  • Closing costs & deal summary section showing full mortgage deal breakdown
  • Mortgage term tracking — provider, rate, term type, maturity date with renewal countdown
  • Renovation holdback tracking with release date and future event linking
  • Remaining amortization display (frequency-aware, matches bank figure)

Improved

  • Sticky page header stays visible when scrolling
  • Collapsible loan terms form (auto-collapses when populated)

Fixed

  • Fix: Balance forecast chart now renders correctly for weekly-frequency mortgages

v1.37.2

PDF Thumbnail Previews + Bug Fix Hash Backfill

New

  • PDF receipt cards now show auto-rendered page 1 thumbnail instead of generic icon

Fixed

  • Fix: Retroactive buildHash added to all 8 bug fix entries that were missing them

v1.37.1

Feature Name Fix + Receipt File Type Badge

New

  • File type badge (PDF/PNG/JPG) on receipt card thumbnails

Improved

  • Bug fix hash column no longer shows misleading dash for version-bump releases

Fixed

  • Fix: Feature entries now display correctly in Features table

v1.37.0

Receipt System Polish — Delete, Re-analyze, Error Display, Bug Fixes

New

  • Delete receipts from system with file cleanup
  • Re-analyze previously analyzed receipts
  • Analysis error messages displayed on receipt cards

Improved

  • Dead code cleanup (unused fileToBase64 function)

Fixed

  • Fix: PDF receipts with sparse text no longer silently dropped
  • Fix: Split expense creation now correctly links receipt to first expense
  • Fix: HEIC upload removed until conversion support is added

v1.36.0#r2f4a

Receipt System Round 2 Fixes

Fixed

  • Fix: Top-level canvas import no longer crashes PDF text extraction (dynamic import in pdfPageToImage only)
  • Fix: Receipt classifier now sees both ad-hoc and recurring categories (subscriptions no longer misclassified as Shopping)
  • Fix: Analyze route dynamically detects recurring vs ad-hoc expense type based on classified category

v1.36.0

Receipt System Overhaul — PDF Support, Multi-Vendor, Bug Fixes

New

  • PDF preview in receipt modal using embedded iframe viewer
  • Multi-vendor receipt detection with option to create split expenses per vendor group

Improved

  • Timezone-aware date and time display for receipt uploads
  • Stale receipt data cleaned up: 2 misanalyzed PNGs reset to inbox, 2 duplicate entries removed

Fixed

  • Fix: Receipt analysis now correctly extracts vendor, date, total, and line items (was reading wrong response path)
  • Fix: Receipt image preview no longer crashes on Unicode filenames (ByteString sanitization)
  • Fix: PDF receipts can now be analyzed via text extraction or page-to-image conversion
  • Fix: Empty vendor no longer produces malformed analyzed filenames

v1.35.0#f7c2d

Legacy Category Fix in Edit Form

Fixed

  • Edit form now shows stored category/subcategory as "(legacy)" option when value does not match current hierarchy

v1.35.0

UI Polish — Vendor Cache, Coverage & Orphan Indicators

New

  • Vendor detail page shows multi-category badge, line item expansion with per-item categories
  • Vendor cache management UI in Configurations with search, stats, delete per entry, and clear all
  • Receipt coverage widget on dashboard shows attachment rate as progress bar
  • Orphan indicator on ad-hoc page counts CSV imports without receipts

v1.34.0

UI Polish — Inline Editing, Badges & Nullification

New

  • Inline row editing: click any expense row to open pre-populated edit form with update and delete support
  • Import source badges (CSV/Receipt icons) displayed next to date in ad-hoc table rows
  • Receipt attachment paperclip indicator on rows with linked receipts
  • Nullified items section with Show/Hide toggle, strikethrough styling, reason display, and undo button
  • Dashboard excludes nullified expenses and income from all aggregates (totals, charts, pacing, vendors)

Improved

  • DELETE endpoint added to expense API for single-expense deletion with backup

v1.33.0

Batch Reclassification Pipeline

New

  • Batch reclassification modal with 4-stage AI pipeline (Type → L1 → L2 → L3) for re-categorizing existing expenses
  • Diff review table with per-row accept/reject, confidence badges, and Accept All/Reject All controls
  • Reclassify-type API route confirms whether ad-hoc expenses are correctly typed vs recurring/income
  • Reclassify-L1 API route re-evaluates L1 category with vendor cache integration

Improved

  • Extracted shared classifyInBatches utility and confidenceColor helper to @/lib/ai-batch for reuse across import and reclassification
  • Vendor cache auto-updated when reclassification changes are accepted

v1.32.0#e4a71

importSource Consistency Fix

Fixed

  • CSV import now sets importSource: "csv" on all committed expenses for audit trail tracking
  • Manual expense creation (full form + quick-add) now sets importSource: "manual" consistently

v1.32.0

Receipt Management System + Cross-Matching

New

  • Receipt-centric management page with drag-and-drop upload zone supporting images and PDFs
  • Receipt lifecycle: Inbox → Analyzed → Matched/Orphan with status tabs and count badges
  • AI-powered receipt analysis extracts vendor, date, line items, totals, and payment method; files auto-organized into structured folders by category
  • Auto-matching engine links receipts to expenses by amount (±$0.01), vendor fuzzy match (>80%), and date proximity (±3 days)
  • Receipt preview modal with side-by-side image viewer and extracted data panel, manual match search, and "Create Expense" from receipt

Improved

  • Orphan receipts auto-matched when new CSV imports are committed
  • Inbox filesystem scanner auto-detects receipt files dropped into data/receipts/inbox/ without using the upload UI
  • Receipt index (receipts-index.json) tracks full audit trail with analysis state, match confidence, and file movement history

v1.31.0#d9e3b

Pre-Phase Fixes — 8 Quality & Feature Readiness Fixes

Improved

  • Scotiabank and TD bank CSV import formats added — auto-detected from header columns
  • Manual expense saves (full form + quick-add) now upsert the vendor cache for future AI lookups
  • Added reclassify-type prompt template for Phase 7 batch reclassification of existing expenses
  • Vendor cache upsert now accepts defaultLineItems for multi-category vendors like Costco

Fixed

  • PUT endpoint vendor guard prevents [object Object] corruption on expense edits (parity with POST handler)
  • Trends page refactored to use shared expandExpenseForAnalytics() instead of inline line-item expansion
  • New AI-suggested L3 category values are now persisted to the hierarchy on import commit
  • Balance page audited — uses pre-aggregated totals, no line-item expansion needed

v1.31.0#c7f2a

Consolidation Fix Pass — 11 Bug Fixes

Fixed

  • Vendor field guard prevents [object Object] from being stored — existing corrupted entries repaired
  • Import pipeline adds transfer detection stage before type classification — internal transfers auto-detected and excluded
  • Nullification data model added to OneTimeExpense and IncomeEntry types for transfer pair tracking
  • AI Insights route switched from hardcoded prompt to renderPrompt()/callAI() so user overrides take effect
  • Receipt scan vendor cache no longer over-applies same category to all line items — AI always classifies per-item with cache as hint only
  • Income-typed rows from CSV import now classified via /api/ai/classify-income with category and moneyInType
  • Trends page correctly splits multi-category expenses using line items instead of top-level category only
  • All classification API calls chunked in batches of 10 with "Batch N of M" progress indicator
  • Date display fixed across ad-hoc and income pages — T00:00:00 suffix prevents UTC date-shift in local timezone
  • Receipt scan route now recognizes PDF files with correct application/pdf MIME type
  • createFromScan on Receipts page now preserves source expense attachments when creating from scan results

v1.31.0

AI Receipt Scanning & Extraction

New

  • AI vision-powered receipt scanning extracts vendor name, date, line items with amounts, subtotal, tax, total, and payment method from uploaded receipt images
  • Extracted receipt line items are automatically classified into expense categories via AI or vendor cache, with confidence badges and editable category dropdowns
  • "Scan" button appears next to image attachments on the Receipts page — results expand inline with full line item detail and category assignment

Improved

  • "Apply to expense" updates the parent expense with scanned vendor, categories, and multi-category line items; vendor cache auto-updated on apply
  • Vision support added to callAI() — both OpenAI and Anthropic providers can now receive image input for multimodal prompts

v1.30.0

AI-Powered CSV Classification Pipeline

New

  • Four-stage AI classification pipeline on CSV import: transaction type detection, L1 category, L2 subcategory, and L3 detail — all with confidence scores and visual progress bar
  • Six new AI API routes: detect-transfers, classify-type, classify-adhoc-l1, classify-adhoc-l2, classify-adhoc-l3, and classify-income using prompt templates from Phase 6
  • Reusable callAI() utility with automatic provider fallback, JSON extraction, and cost estimation shared across all AI endpoints

Improved

  • Vendor cache integration: L1/L2/L3 classification checks vendor cache first and skips AI calls for previously-seen vendors, with automatic cache updates on import commit
  • Import page shows AI confidence badges (green/amber/red) on each category assignment, transaction type pills (Ad-hoc/Recurring/Income/Skip), and classification source indicators
  • Rows classified as income or skip are auto-excluded from import and collapsed into an expandable section for review

v1.29.0

AI Prompt Template Management

New

  • Central prompt template registry with 11 templates across 4 categories: Financial Insights, CSV Classification, Receipt Scanning, and Batch Reclassification
  • AI Prompt Templates section in Configurations with accordion-grouped categories, inline editing, and per-template customization with reset-to-default
  • renderPrompt() utility with {{placeholder}} substitution that checks user overrides in settings.promptOverrides before falling back to defaults

Improved

  • Templates show variable documentation, customization badges, and override counts per category

v1.28.0

Vendor Classification Cache

New

  • Vendor cache system that stores vendor → category mappings so repeat vendors are instantly classified without AI calls
  • Fuzzy matching via Dice coefficient bigrams — recognizes vendor name variations (e.g., "COSTCO WHOLESALE #123 ON" matches "costco wholesale") with configurable similarity threshold
  • Vendor cache REST API (GET/PUT/DELETE) with search filtering, hit-count sorting, and statement pattern tracking

Improved

  • Classification source badges on the ad-hoc expense table show how each expense was categorized (AI, Cache, Manual, Receipt, Fallback)

v1.27.0

Multi-Category Line Items

New

  • Ad-hoc expenses can now be split across multiple categories using line items — a single Costco trip can attribute amounts to Groceries, Household Supplies, and Sports Equipment separately
  • New "Split into categories" toggle in the full expense form with a dynamic line items editor supporting both dollar amount and percentage-based splits
  • LineItemsEditor component with independent L1/L2/L3 category dropdowns per line, running total/percentage balance indicator, and add/remove controls

Improved

  • Multi-category expenses show expandable sub-rows in the expense table with a chevron toggle, line count badge, and per-line category breakdowns
  • Dashboard pie charts, AnnualizedCategoryCard, and category drill-down correctly attribute line item amounts to their respective categories instead of the parent expense category
  • API validation ensures line item amounts sum to the parent expense total (within $0.01 tolerance) before saving

v1.26.0

Property Tax Schedule, Editable Frequency & Ledger UX

New

  • Property tax balance projection now uses exact per-month payment schedule from CSV data — shows one instalment amount in the spring months, a higher one in the summer months, and nothing in the off months. Supports multi-year schedules with varying amounts
  • Recurring template frequency and amount are now editable inline in the TemplateDetail panel — change frequency, amount, and payments-per-year without recreating the template
  • Category view in the Recurring Ledger now pivots by subcategory — expanding a category shows nested subcategory headers with counts and subtotals, then individual entries within each subcategory
  • All view now has clickable category filter pills — click categories to show only matching entries while keeping the date-based grouping. Active filters dim unselected pills and show a Clear button

Improved

  • All view in the Recurring Ledger now has an "Expand All / Collapse All" button to open or close every year and month section at once
  • Date column across all ledger views now includes the year (e.g., "Jan 15, 2026" instead of "Jan 15") for unambiguous date identification

Fixed

  • v1.25.0#b3f1 version correction — bug-fix-only release now correctly uses build hash instead of version bump

v1.25.0#b3f1

Balance Page Bug Fixes & Frequency Overview

New

  • New Frequency Overview section in Configurations — read-only table showing all recurring templates with name, provider, frequency, amount, monthly equivalent, owner, and status

Fixed

  • Balance page: Home Insurance and Auto Insurance now show correct amounts — fixed template name mismatch in the NAME_TO_COL mapping
  • Balance page: Property Tax now shows correct monthly equivalent — getMonthlyAmount() now handles custom frequency with paymentsPerYear instead of returning $0
  • Balance page: Mortgage and other weekly/biweekly expenses now count actual payment occurrences per calendar month (e.g., 4 or 5 Wednesdays) instead of using a flat 52/12 average
  • Features & Bug Fixes pages: All timestamps for v1.20.2–v1.25.0 entries corrected from date-only strings (which displayed as previous day in EDT) to proper ISO timestamps with timezone offset

v1.25.0

Recurring Ledger — Inline Editing & Add New Template

New

  • Every ledger row now has pencil and trash icons for inline editing and deletion across all 3 sub-views (Focused, All, Category). Editable fields: date, amount, and notes
  • New Template button in the page header opens a dialog to create recurring templates with all required fields: name, provider, type, amount, frequency, category, owner, and notes

Improved

  • Refactored ledger row rendering into a shared function to eliminate triple-duplicated JSX across the three sub-views

v1.24.0

Recurring Ledger — Category View, Current Month Indicator, Frequency Types

New

  • New Category sub-view in the Ledger groups all entries by L1 category with colored headers, expand/collapse, and subtotals per category
  • Current month row in the All view is highlighted with an accent left border and "current" badge for quick identification

Improved

  • New frequency types: Daily, Every 4 Weeks, and Custom (with configurable payments-per-year for irregular schedules like property tax)
  • Property tax template updated to use Custom frequency (6 payments/year) instead of Monthly

v1.23.0

Recurring Ledger — All View with Year/Month Grouping & Duo Palette Fix

New

  • New sub-view toggle within the Ledger: "Focused" shows a single month (existing behavior) and "All" shows a rolling view grouped by year and month with collapsible sections, subtotals, and year totals

Improved

  • Ledger is now the default view when opening Recurring Expenses
  • Month navigation arrows hide when the All sub-view is active since month filtering does not apply

Fixed

  • Fix Duo mode category color palette — now interleaves Alex (odd slots) and Chelsea (even slots) instead of using a muddied copy of Alex colors

v1.22.0

Recurring Expenses — Dual View with Month Navigation & Category Rollup

New

  • Template view now has month navigation — browse back/forward through months to see payment amounts per period, with a Today button to jump back to the current month
  • New Ledger view shows a flat table of all recorded payments for the selected month with type icons, vendor, date, amount, L1/L2 categories, and notes
  • Category rollup pills at the top of the Ledger view show per-category spending totals with theme-aware colors. Grand total pill at the end

Improved

  • View toggle (Templates / Ledger) in the sticky header. Both views share filters and month navigation

v1.21.2

Ad-hoc Expenses — 3-Column Categories & Form Fixes

Fixed

  • Ad-hoc expenses table now shows 3 separate columns — Category, Sub-Category, and Detail — instead of concatenating all levels into one. Empty L3 shows an em dash
  • Quick Add and Full Form always show the L3 Detail dropdown (disabled when no options available) instead of conditionally rendering it, which was breaking the Quick Add grid layout

v1.21.1

Bug Fixes Table — Sorting & Filtering

New

  • All Bug Fixes table columns are now sortable — click any column header to sort ascending/descending
  • Filter pills for Severity (critical/high/medium/low) and Status (open/in progress/fixed). Shows count of matching bugs vs total

v1.21.0

Features Table — Sorting, Filtering & New Statuses

New

  • All Features table columns are now sortable — click any column header to sort ascending/descending. Active sort column shows a directional arrow
  • Filter pills for Status (planned/in progress/completed/replaced/cancelled), Effort (low/medium/high), and Impact (low/medium/high). Multiple filters combine to narrow results. Shows count of matching features vs total

Improved

  • Two new feature statuses: "replaced" (purple badge) for features superseded by newer implementations, and "cancelled" (gray badge) for features that were dropped

v1.20.2

L1 Category Colors — theme-aware palette, auto-assign, color picker

New

  • 17-slot category color palette with distinct hues per theme (Alex/Chelsea/Duo). Colors auto-assign to L1 categories on first load and can be customized via a color picker circle in Settings
  • Category colors propagate to recurring expense template pills, spending trends page filter pills and line charts, and dashboard pie chart slices. Falls back gracefully when no color is assigned

v1.20.1

Editable Categories — L3 editing, reusable CategoryEditor, all 3 sections editable

New

  • 1. (v1.20.0) L3 detail items in Ad-hoc Expense Categories are now fully editable — expand any L2 subcategory to see, add, rename, or delete L3 detail items. L2 rows with existing L3 show a chevron toggle; all L2 rows have a "+" button to add new details
  • 2. (v1.20.1) Extracted reusable CategoryEditor component and applied to all 3 category sections. Income Categories and Recurring Bill Categories are now fully editable with add/rename/delete for L1, L2, and L3 levels — matching Ad-hoc functionality. Changes propagate to all consumer pages on next load

v1.19.0#d2e8

Bug Fix — Features table v1.19.0 entry ordering

Fixed

  • Features table: v1.19.0 feature entry was at array index 0 (top of table) instead of at the end. Moved to correct chronological position so it appears after all v1.18.x features, consistent with oldest-first ordering

v1.19.0#f7c3

Bug Fixes — Features display + L3 category visibility

Fixed

  • 1. Features page blank row for v1.19.0 — the v1.19.0 feature entry in budget.json used wrong field names (title/priority/completedAt instead of name/effort/impact/dateDeveloped), causing the Features tab to show a row with no name, effort, impact, or dates. Fixed by correcting the data entry to match the Feature interface
  • 2. Configurations page missing L3 category details — the Ad-hoc Expense Categories section only showed L1 and L2 levels but did not render L3 detail items (e.g., Dining Out → Restaurant, Fast Food, Fine Dining, Brunch/Breakfast). Added L3 rendering as indented dot-prefixed items below each L2 with a detail count badge

v1.19.0

Category Infrastructure Overhaul (Phase 1) — Three independent hierarchies, L3 detail support, recurring bill categories

New

  • 1. New three-tier category taxonomy — replaced the monolithic 19-L1 ad-hoc hierarchy with three independent hierarchies: Ad-hoc Expenses (17 L1 / 100 L2 / 205 L3), Recurring Bills (10 L1 / 51 L2), and Income (8 L1 / 43 L2). All sourced from a comprehensive analysis of historical Google Sheet trackers
  • 2. L3 "Detail" dropdown on ad-hoc expense forms — when a selected L2 sub-category has L3 leaves (e.g., Groceries → Weekly Shop, Costco Run, Specialty/Ethnic, Bulk), a third optional dropdown appears in both the quick-add bar and the full form. Stored as subCategory2 on the expense record
  • 3. L3 dropdown on CSV import page — each import row now shows a Detail column with an L3 dropdown when the mapped L2 has L3 options, matching the ad-hoc form behavior
  • 4. Recurring bill category assignment — TemplateDetail now has L1/L2 cascading dropdowns driven by the new RECURRING_CATEGORY_HIERARCHY. TemplateRow shows a category pill (L1 > L2) inline. Categories persist via the existing category/subCategory fields on RecurringExpense

Improved

  • 5. Auto-migration for existing databases — Settings API GET handler detects old taxonomy (presence of "Food" L1, absence of "Food & Dining") and auto-upgrades categoryHierarchy, incomeCategoryHierarchy, and adds recurringCategoryHierarchy on first load. Existing expense data is preserved with original category strings
  • 6. Configurations page updated — "Expense Categories" renamed to "Ad-hoc Expense Categories", new read-only "Recurring Bill Categories" section added alongside the existing Income Categories section
  • 7. Trends page data-driven categories — L1 filter pills now show a union of categories from actual data plus the new hierarchy keys, so old categories ("Food", "Costco") still appear alongside new ones ("Food & Dining")
  • 8. Command palette category search expanded — palette now indexes recurring template categories in addition to ad-hoc categories for broader search coverage

v1.18.0

Gap Closure — fundingSource, Trends, Annualized, Tab-paste, Quarterly, Notes — 6 features shipped as v1.18.1–v1.18.6

New

  • 1. (v1.18.1) fundingSource reconciliation on Balance page — Events funded from savings no longer penalize the Money Remaining column. The balance API now splits specialTotal into specialFromSavings and specialFromIncome, only subtracting the latter from disposable income. The full Special column still shows the combined total for visibility
  • 2. (v1.18.2) Multi-category spending trends page — new /trends page with multi-select category pills (2–6 categories), Recharts LineChart showing per-category monthly spend over the last 12 months, auto-selects top 4 categories by spend, and a summary data table below the chart. Added to sidebar nav after Balance
  • 3. (v1.18.3) Per-category annualized projection card — new AnnualizedCategoryCard on the Dashboard showing YTD total, projected annual (YTD × 12/monthsElapsed), prior year total, and Δ% for the top 8 categories by spend. Color-coded delta column: red for >10% increase, green for >10% decrease
  • 5. (v1.18.5) Quarterly/Annual rollup toggle on Balance page — Monthly/Quarterly/Annual toggle buttons above the balance table. Quarterly groups 3 months per row (Q1–Q4), Annual groups a full year. All numeric columns are summed; % of Income is recomputed from summed totals. Page title simplified to "Balance Projection"
  • 6. (v1.18.6) BalanceTimeline tooltip notes surfacing — daily balance API now counts entries with notes (from recurring template notes, entry notes, or ad-hoc expense notes) per day. The BalanceTimeline tooltip shows a "📝 N notes" indicator when hovering on days with annotated entries. Monthly rollup sums note counts across the month

Improved

  • 4. (v1.18.4) Tab-separated paste support — the CSV import parser now auto-detects tab-delimited input (from Google Sheets paste) vs comma-delimited. If the first line contains tabs and no commas, the tokenizer uses tab as the delimiter. Rest of the mapping and dedup pipeline is unchanged

v1.17.0

Analytics, Waterfall, Weekly Pace, YoY — 8 features shipped as v1.17.1–v1.17.8

New

  • 1. (v1.17.1) Unusual transaction flagging — new src/lib/outliers.ts with flagOutliers() that computes the vendor/category median and flags any row exceeding 2× the median. Ad-hoc expense table shows a 🚩 emoji with a hover tooltip ("3.2× the median for Costco") on every flagged row. Groups with fewer than 3 rows are skipped to avoid false positives
  • 2. (v1.17.2) Subscription price-increase detector — new src/lib/price-changes.ts with detectPriceChanges() that compares the latest charge for each recurring template against the historical mode (most common prior amount). New PriceChangesCard on the Dashboard shows increases and decreases with previous → current amounts and % change, sorted by magnitude
  • 3. (v1.17.3) Currency exposure card — new CurrencyExposureCard on the Dashboard that sums all active USD-denominated recurring templates, shows monthly and annual USD totals, and computes an estimated CAD range using USD_TO_CAD ±5% FX volatility band (best / mid / worst case). Includes a per-template breakdown
  • 4. (v1.17.4) YoY category comparison on Balance page — collapsible "Year-over-Year Comparison" section below the main table. Fetches 24 months of data on demand, computes per-category totals for the selected year vs prior year, and shows a delta table with $ and % change columns. Expense increases are red, decreases green; income/remaining columns are inverted
  • 5. (v1.17.5) Monthly cash-flow waterfall chart — new WaterfallChart component using Recharts stacked bars with invisible bases to simulate a waterfall: Income → −Committed → −Ad-hoc → −Special → Net. Rendered on the Dashboard between the Pacing row and the Annual Income overview, labeled with the view month
  • 6. (v1.17.6) Weekly mini-view card — new WeeklyMiniCard on the Dashboard showing "This week: $X spent" with a daily pace and a % delta vs the prior full week (Mon–Sun). Combines both recurring entries and ad-hoc expenses. Placed alongside the Pacing card in the same grid row
  • 7. (v1.17.7) BalanceTimeline monthly rollup toggle — Daily/Monthly toggle buttons in the BalanceTimeline header. Monthly mode aggregates daily points into end-of-month rows with summed income/expense flows and end-of-month cumulative balances, using wider bars (48px vs 6px per point) for a zoomed-out annual view
  • 8. (v1.17.8) Polish sweep — new KeyboardShortcutsModal (⌘?) mounted in root layout listing all keyboard shortcuts. Sidebar hint updated from "Press ⌘K to search" to "⌘K search · ⌘? help". Sheet retirement deferred to v2.0.0

v1.16.0

Search, Receipts, What-if, Alerts, Pre-tax Parity — 6 features shipped as v1.16.1–v1.16.6

New

  • 1. (v1.16.1) Global ⌘K / Ctrl-K command palette — new CommandPalette component mounted in the root layout, fuzzy matcher in src/lib/search.ts-style scoring, searches across vendors (from oneTimeExpenses), category L1/L2 entries, recurring templates, events, accounts, annual obligations, and every top-level page. Arrow keys + Enter to navigate, Esc to dismiss, results grouped by category. Sidebar footer now shows a "Press ⌘K" hint
  • 2. (v1.16.2) Receipts + attachments — new Attachment type, optional attachments?: Attachment[] on both OneTimeExpense and ExpenseEntry. New /api/attachments route handles multipart upload (10 MB cap), streamed GET with path-traversal guard, and DELETE. Files land under data/receipts/YYYY/MM/<uuid>.<ext> and are gitignored. New /receipts management page with Missing / With receipts / All filters, per-row upload button, and cascading delete
  • 3. (v1.16.3) Pre-tax + total parity on entry forms — optional amountPreTax and taxAmount added to OneTimeExpense (ExpenseEntry already had amountPreTax). New src/lib/tax.ts with reconcileTax() — give it either field and it fills in the other using the Ontario HST_RATE constant. Both /api/expenses and /api/expense-entries POST + PUT now reconcile on every write so either entry direction stays consistent. Ad-hoc Quick-Add bar grew a Pre-tax input beside Total
  • 4. (v1.16.4) What-if income scenarios — new /income/scenarios page (client-only, zero DB writes). Baseline = trailing-12-month net paycheck income plus annualized committed expenses. Layer adjustments: salary change, new income stream, one-time bonus, or remove an existing employer stream (auto-derives the delta from history). Live-updates projected annual net + disposable delta vs baseline
  • 5. (v1.16.5) Large-bill alerts — buildCallouts() now accepts an optional upcomingBills array and a largeBillThreshold (defaults to $500). Any upcoming non-archived ledger entry hitting in the next 7 days above the threshold surfaces as a warning pill in the Dashboard callout strip ("mortgage payment in 3 days"). Dashboard wires the feed from the entries list pre-filtered by template.archived
  • 6. (v1.16.6) Template notes surfacing — HistoryForecastChart now renders template.notes as an italic caption above the bars (with a 📝 marker) when present, so the "why" lives next to the numbers. Dashboard Upcoming Bills rows show a small accent dot when a template or entry has notes and a native hover tooltip reveals the full text

v1.15.0

Depth — Accounts, Amortization, Annual Obligations, Seasonal, Money In, Reimbursements — 6 features shipped as v1.15.1–v1.15.6

New

  • 1. (v1.15.1) New Accounts module — Account + AccountSnapshot entities, /api/accounts + /api/accounts/snapshots CRUD routes, /accounts list page with Net Position / Assets / Liabilities summary, /accounts/[id] detail page with a balance-over-time line chart and full snapshot history. New Net Position card on the Dashboard rolls up latest snapshots across every non-archived account — credit cards and loans are stored as negative balances so the sum equals true net worth
  • 2. (v1.15.2) Mortgage amortization view — new optional principal / currentPrincipal / interestRate / termMonths / loanStartDate fields on RecurringExpense, new src/lib/amortization.ts pure helpers (monthlyPayment, buildSchedule, summarize), new /mortgage page listing every mortgage-type template with editable loan terms, summary cards (monthly payment, current balance, total interest, payoff date), balance-forecast chart, and a full payment-by-payment schedule table with principal/interest split
  • 3. (v1.15.3) Annual obligations entity — new AnnualObligation + AnnualObligationPayment types, /api/annual-obligations CRUD route, /annual-obligations list page with inline create form, even-installment schedule builder, mark-paid toggles, and overdue detection. New Dashboard Annual Commitments card surfaces the current year's total / paid / remaining with overdue badge. Built for property tax + insurance renewal installments
  • 4. (v1.15.4) Seasonal utility forecasting — new optional seasonalProfile?: number[12] field on RecurringExpense, new src/lib/seasonal.ts helpers (seasonalMultiplier, forecastSeasonal, deriveProfileFromHistory), new /seasonal-profiles editor page with per-month multiplier inputs and Hydro / Gas / Flat presets + Auto-derive from history. The Balance page projection now applies the multiplier to variable templates so a winter gas bill no longer equals a July one
  • 5. (v1.15.5) Other Money In as a first-class concept — new MoneyInType enum (paycheck / bonus / reimbursement / gift / refund / other) added as an optional moneyInType field on IncomeEntry. Income API round-trips the field. New src/lib/income-types.ts with an effectiveMoneyInType() helper that applies a legacy heuristic for rows that predate the field. New Dashboard "Other Money In" card sums non-paycheck rows for the view month and breaks them down by type
  • 6. (v1.15.6) Reimbursement tracking — new optional reimbursementStatus (none / pending / received) and reimbursedById fields on OneTimeExpense, /api/expenses POST/PUT now round-trip them. New src/lib/reimbursements.ts helpers, new /reimbursements management page with pending/received/candidates/all filters, inline status toggles (Clock / Check / X) and an Income dropdown to link reimbursed rows to the IncomeEntry that paid them back. New Dashboard Pending Reimbursements card surfaces the total outstanding and the oldest pending row

v1.14.0

Events & Special Expenses — 7 features shipped as v1.14.1–v1.14.7

New

  • 1. (v1.14.1) New Event entity + /api/events CRUD route — planned budget, status (planned/active/completed/cancelled), category, funding source, optional start/end dates. Seeded as an empty collection in budget.json so existing installs migrate automatically
  • 2. (v1.14.2) Optional eventId foreign key on OneTimeExpense, ExpenseEntry, and IncomeEntry — every ad-hoc row, recurring entry, and income row can now be tagged to an event without any schema migration
  • 3. (v1.14.3) Retroactive event tagging on /expenses/ad-hoc — multi-select checkboxes plus a sticky bulk action bar with a "Move to event…" dropdown, an Apply button, and a Clear-tag button. Essential for backfilling the 318-row v1.11 historical seed
  • 4. (v1.14.4) Dashboard "Include Special" toggle (default OFF) — when off, event-tagged rows are stripped from month totals, pies, callouts, pacing, vendor movers, and the drill drawer. A purple chip surfaces the excluded Special total so you still see the magnitude
  • 5. (v1.14.5) Funding source reconciliation modeled on Event — savings / cash_flow / loan / gift / mixed — surfaced on /events cards and /events/[id] detail header. Full savings-balance wiring lands with the Accounts module in v1.15.0
  • 6. (v1.14.6) New /events list + /events/[id] detail pages plus the Dashboard Active Events card (replaces the v1.9.0 Special Project placeholder) — planned-vs-actual bar, tagged transactions timeline, category breakdown, linked income (gifts/refunds), cascading delete, and a sidebar Events nav entry
  • 7. (v1.14.7) New "Special" column on the Balance page — event-tagged expenses are split from the regular Ad-hoc column and added into Total Payments, so Money Remaining stays truthful while month-view still reflects normal life

v1.13.0

Comprehension — Top Vendors, Risers/Fallers, Category Drill, Vendor Detail

New

  • New Top Vendors card on the Dashboard — top 10 merchants for the view month, proportional bars, MoM delta badge when |Δ| ≥ 5%, and each row links through to the vendor detail page
  • New Risers / Fallers / New Vendors card — three stacked sections highlighting the biggest month-over-month movers in the ad-hoc ledger, with top 3 each and click-through links
  • Clickable pie slices — clicking a slice on the "Spending by Category" or "Ad-hoc Spending" pies opens a right-side drawer with an L2 sub-category breakdown, a MoM delta banner, and every transaction for that category in the view month
  • New /vendors/[name] detail page — lifetime total, transaction count, average amount, average days between transactions (cadence signal), MoM delta card, monthly sparkline, and a full sortable transactions table. Accessible from every vendor link (Top Vendors, Risers/Fallers, drill drawer)

Improved

  • Pie chart tooltips on the combined Spending pie now show the month-over-month delta and percent change so you can read trend without drilling
  • New src/lib/analytics.ts — pure functions topVendors(), risers(), fallers(), newVendors(), vendorHistory(), categoryDrill() over DB slices. Reusable for future YoY and outlier work

v1.12.0

Truthful Balance + Pacing — Ad-hoc in /balance, Pacing Card, Callouts

New

  • Balance page now includes an Ad-hoc column summing oneTimeExpenses per month. Total Payments = Committed + Ad-hoc, so Money Remaining finally reflects reality after the v1.11 historical seed dropped 318 rows into the ledger
  • New "Other Money In" column on /balance — non-paystub income (bonus, reimbursement, severance, tax refund, gifts) rolls up separately from regular Salary/Regular Pay, and feeds into Total Earned
  • New Pacing card on the Dashboard — budget, spent, % used, days left, target $/day, actual $/day, projected month spend, On-track / Over-pace badge. Click the budget amount to set a per-month override; default is 90% of projected income
  • New Dashboard callout strip — up to 4 dynamic pills above the summary cards: pacing status, highest-spending month YTD, new vendors this month, ad-hoc month-over-month delta (only when |Δ| ≥ 15%)

Improved

  • Optional monthlyBudgets map on Settings (keyed by YYYY-MM) with /api/settings PUT support — no migration, defaults apply when unset
  • New computePacing() and buildCallouts() helpers in src/lib/calculations.ts — pure functions over DB slices so the same math can be reused in future pages/tests

v1.11.0

Entry Speed & Import — Quick-Add Bar, Vendor Memory, CSV Import

New

  • New sticky Quick-Add bar at the top of /expenses/ad-hoc — Date, Amount, Vendor, Category, Sub, Payment, Save in one row. Enter submits. Focus returns to the Vendor field after every save for rapid sequential entry
  • Vendor autocomplete with last-used memory — typing a known vendor into the Quick-Add bar now pre-fills Category, Sub-Category and Payment Method from your most recent entry for that merchant (only fields you haven't already typed into)
  • New /expenses/ad-hoc/import page — upload a CSV or paste CSV text directly. Auto-detects AMEX Canada and RBC Chequing column shapes, falls back to a generic Date/Description/Amount detector
  • Import duplicate detection — rows matching an existing expense by (date ±1 day, amount, vendor similarity > 0.85 via Dice coefficient) are flagged and pre-unchecked before commit
  • Bulk-paste multi-row entry — the Import page's Paste mode accepts any CSV text from a clipboard (bank statement, spreadsheet selection) and parses it through the same pipeline as file upload
  • Historical seed script at scripts/seed-from-sheet.ts — run locally against an XLSX or CSV export of the household expense-tracking spreadsheet's "Expense Tracking" tab to backfill years of history. Creates a timestamped DB backup before writing

Improved

  • Bulk commit via /api/expenses POST — the endpoint now accepts either a single expense object or an array, so CSV import + bulk-paste commit in a single round trip

v1.10.0

Ad-hoc Foundations — Sheet Taxonomy, Vendor, Payment Dropdown

New

  • CATEGORY_HIERARCHY rewritten to match the 18 L1 categories from the household expense-tracking spreadsheet (Food, Cash, Entertainment, Travel, Transportation, Transfer, Education, Home, Membership, Shopping, Medical, Bank Fees, Government, Costco, Personal Care, Wellness, Office, Personal)
  • New optional `vendor` field on OneTimeExpense — dedicated merchant input in the ad-hoc form plus a Vendor column in the table, with native datalist autocomplete from prior entries
  • Configurations page gains an Expense Categories editor — add, rename, and delete L1 and L2 categories inline, persisted to Settings
  • Planned roadmap populated — v1.10.0 through v2.0.0 features seeded as planned entries so the Features page reflects the full path to sheet retirement

Improved

  • Ad-hoc Expenses page now loads its category hierarchy from Settings (user-editable) with the constant as a seed fallback — the old hard-coded L1/L2 list is gone
  • Payment Method on the ad-hoc form is now a strongly-typed dropdown (AMEX / MASTERCARD / VISA / DEBIT / CASH / OTHER) instead of free-text
  • Dashboard Committed card now mirrors the /balance % color thresholds (red > 80, orange ≥ 60, yellow ≥ 40, green below) so the "telltale" matches across pages

Fixed

  • Features page backfilled — Features and Bug Fixes tables now include every release from v1.4.3 through v1.9.0 (previously only Release Notes were being updated)

v1.9.0

Dashboard Polish: Sticky Header, Timeline Y-Axis, Paid-By, Income Stream

New

  • Dashboard month switcher is now sticky at the top of the main pane — scroll through cards without losing the global filter
  • New paidBy field on RecurringExpense — rows now show "paid by → for whom" when the payer differs from the beneficiary (e.g. a design tool and a gym membership paid by one person but for the other)
  • Dashboard middle section reworked into two independent columns — left stacks Spending by Category, Ad-hoc Spending, Recurring Spending, and a Special Project placeholder; right stacks Upcoming & Past Bills, Subscription Renewals, and a new Income Stream card
  • New Ad-hoc Spending and Recurring Spending pie breakdowns alongside the combined Spending by Category view
  • Special Project card (In Progress) — placeholder for an upcoming feature that isolates expenses tied to a scoped project (renovation, trip, event)
  • New Income Stream card mirroring Upcoming Bills — Rolling/Month toggle, date picker, past paycheques + future dates, adaptive "Upcoming & Past Income" title

Improved

  • Subscription Renewals card: drops the redundant month suffix on current month; switches to the Repeat icon (away from the AI Sparkles icon); past months read as "Subscription Charges" with "Charged" date copy
  • Upcoming Bills rolling mode now includes already-paid bills earlier in the month — scroll up to see past bills, list auto-scrolls to the anchor date
  • Bills title adapts to context: "Upcoming & Past Bills" in Rolling, "Past Bills" on a past month in Month view, "Upcoming Bills" on current/future months
  • Upcoming Bills rows now show an owner badge alongside the payment icon

Fixed

  • Balance Timeline gains a fixed left Y-axis gutter that stays visible while the chart body scrolls horizontally
  • Owner data fixes: two templates re-pointed so the beneficiary and the payer are recorded separately

v1.8.0

Balance Timeline — Daily Scrollable Trend Chart

New

  • New Balance Timeline chart replaces the Monthly Balance Trend bar chart — daily granularity over a 3-year window (18 months back + 18 months forward)
  • YTD cumulative trend lines for Income and Expenses that reset each Jan 1, giving an always-current year-to-date picture
  • Red gradient deficit area appears whenever YTD expenses overtake YTD income within the year
  • Per-day stacked source bars — Alex Income, Chelsea Income, Recurring, Ad-hoc — overlaid on the trend lines
  • Horizontal scroll with drag-to-pan (trackpad, wheel, or click-drag) — default view centered on today with a red "Today" reference line
  • Summary strip above the chart: YTD Income, YTD Expenses, YTD Balance (green/red), and Deficit Days this year
  • New /api/balance/daily endpoint returns per-day rollups with server-computed YTD cumulative fields

Improved

  • Custom hover tooltip showing per-source day breakdown plus cumulative YTD totals and running balance

v1.7.0

Subscription Renewals Rework, Owner Icons, Trend Polish

New

  • Split into Monthly and Annual sections — annuals only appear in the month containing their renewal date
  • New OwnerBadge component (A/C/S/H colored chip) replaces "(Alex)"/"(Chelsea)" name suffixes across renewal rows
  • Structured free trial tracking on RecurringExpense (isTrial, trialEndDate, postTrialAmount) — a trialing template now shows a Free Trial badge plus its conversion date and post-trial price

Improved

  • Subscription Renewals card is now tied to the month switcher — past/current months show actual ledger charges (date + amount), future months show projected renewals with a Projected badge
  • Renamed templates to strip owner suffix — they now use owner icons instead
  • Monthly Balance Trend gains a red dashed "Today" reference line on the current month

Fixed

  • Fixed $0.00 amount bug — card now sources amounts from ExpenseEntry ledger first, falling back to template defaultAmount/amount
  • Monthly Balance Trend Y-axis fixed — no more clipped labels, whole dollars (no cents noise)

v1.6.0

Dashboard Time Travel & Upcoming Bills Controls

New

  • Dashboard month switcher — step through any month with prev/next arrows and a Today button; all monthly metrics (income, expenses, committed, pie, balance) retrack to the viewed month
  • Forecast badge on current and future months — header pill plus per-card indicators on Committed and Month Balance cards
  • Upcoming Bills gains a date picker — overwrite the anchor date to rewindow the rolling list from any day
  • Upcoming Bills Rolling/Month toggle — Month view shows every payment for the month from day 1, with checkmarks + strikethrough on past-paid entries

Improved

  • YTD window adapts to viewed month — past = Jan 1 → end of view month, current = Jan 1 → today, future = Jan 1 → first of view month
  • Committed card enhanced — Paid $X · $Y left split, "N/M bills" paid counter, forecast badge when viewing current/future months
  • New Month Balance card (replaces Forecasted Remaining) — past shows actual Balance Delta (red if negative), current/future shows Projected Balance with Forecast subtitle

Fixed

  • Fixed ISO date timezone rendering — weekly Mortgage entries now correctly display as Wednesday (was off-by-one-day in Toronto EDT)

v1.5.0

AI Subscriptions, Archive State & Renewal Tracker

New

  • New "AI Subscriptions" type with dedicated group on Recurring Expenses page
  • Added 11 subscription templates spanning AI tools, streaming, cloud storage, fitness and productivity
  • USD-priced subscriptions (SuperGrok, Perplexity Pro) display both USD and estimated CAD
  • Archive/cancel state on recurring templates — historical entries still count, hidden from main view
  • Dashboard Subscription Renewals tracker — upcoming renewal dates, amounts, notes
  • Recurring page: "Hide $0 this month" and "Show archived/cancelled" filter toggles

Improved

  • Two one-off services archived
  • Recurring page: sticky header + summary cards so they stay visible while scrolling

Fixed

  • Mortgage provider and property-tax issuer corrected, Auto Insurance name simplified

v1.4.3

Dashboard & Balance Fixes, Household Owner

New

  • New "household" owner classification for joint bills (mortgage, utilities, insurance, internet)

Improved

  • 17 recurring expense templates reassigned to accurate owners (household/alex/chelsea)
  • Recurring page gains Household filter chip; visible in both personal views
  • Dashboard Upcoming Bills now shows rest-of-month entries, extends into next month until 10 items, with type icons
  • Dashboard Monthly Balance Trend shows stacked Income (Alex + Chelsea) and stacked Expenses (Recurring + Ad-hoc)
  • Balance % of Income shows em dash when no income, color-coded tiers (red >80%, orange 60–80%, yellow 40–60%)
  • Balance table now has frozen header row and frozen Month column

Fixed

  • Dashboard Spending by Category rebuilt from the ledger — hides 0% slices, correctly counts Subscriptions & Memberships
  • Balance page Alex/Chelsea Earned columns fixed (were showing "Undefined")

v1.4.2

Expenses Restructure & Committed Dashboard

New

  • Sidebar Expenses section with Recurring and Ad-hoc sub-items
  • Dashboard "Committed This Month" summary card with % of income and 6-month mini trend
  • Balance API now reads past/current months from the ExpenseEntry ledger (actuals)

Improved

  • Legacy /fixed-expenses, /bills, /subscriptions routes now redirect to /expenses/recurring
  • OneTimeExpense CRUD page moved from /expenses to /expenses/ad-hoc
  • Balance API keeps template projection for future months; 17-column table preserved

v1.4.1

Recurring Expenses Page

New

  • New /expenses/recurring page — committed expenses grouped by type with drill-down
  • Per-item payment history table with inline add/edit/delete of entries
  • History + forecast bar chart (6 months past actuals + 6 months forward forecast)
  • Discount management UI for Telecom & Insurance (add/edit/remove)

Improved

  • Owner filter synced with sidebar view mode; hide-disabled toggle
  • Summary cards: This Month / Paid So Far / Remaining / Active Templates
  • Sidebar best-match active highlighting (no more double-highlight on nested routes)

v1.4.0

Committed Expenses Foundation

New

  • New ExpenseEntry ledger data model for per-payment tracking (history + forecast)
  • Excel import of historical payment data — 521 entries across 20 templates
  • RecurringExpense templates gain provider, defaultAmount, and discounts fields
  • Discount tracking for Telecom + Insurance (with start/end dates)
  • New /api/expense-entries CRUD endpoints

Improved

  • Foundation only — existing Fixed Expenses, Bills, and Subscriptions pages unchanged

v1.3.2

Release Notes & Polish

New

  • Release Notes tab in Features page with collapsible, sortable releases

Improved

  • Owner icons on year/month summary rows in All mode
  • Redesigned README as comprehensive About page

Fixed

  • Fixed income edits/deletions not persisting across server restarts
  • Removed speculative forecasted income entries from seed data

v1.3.1

Income Categories & Fixes

New

  • Hierarchical income category/subcategory dropdowns with "Custom..." option

Improved

  • SubCategory column added to income table, add form, and edit form
  • Income Categories section in Configurations page
  • Dashboard YTD Income card shows gross total in brackets

Fixed

  • Fixed Salary column showing $NaN for non-salary income entries

v1.3.0

Income Management Suite

New

  • Sort toggle: newest-first and oldest-first for all income entries
  • Earned vs forecast indicators on year and month rows
  • Inline edit and delete for any payment entry
  • Monthly income cards show earned so far vs expected remaining
  • Dashboard: YTD Income, Projected Annual, Forecasted Remaining cards with YoY comparison

v1.2.0

Collapsible Income View

New

  • Income page groups entries by Year and Month with collapsible rows showing subtotals
  • Three view modes: Years, Months, Payments with individual toggle support

v1.1.1

Income & Tracking Enhancements

Improved

  • Added gross pay column to income table and add-income form
  • Features/bugs pages now show conceived/implemented timestamps

Fixed

  • Fixed dashboard summing all historical income instead of current month only

v1.1.0

AI Configuration

New

  • API key management for OpenAI, Anthropic, and Gemini with masked display
  • Provider selection with automatic fallback if preferred provider fails

Improved

  • Updated model list: GPT-5.4 family, Claude Opus 4.6, per-provider preferences

Fixed

  • Fixed pie chart labels flashing on theme switch
  • Fixed browser tab showing default Next.js icon
  • Fixed income page owner filter case mismatch, synced with viewMode, added owner icons

v1.0.1

Theme-Aware Charts

New

  • Charts use Chelsea color scheme in Chelsea mode and blended palette in Duo mode

Fixed

  • Fixed .app launcher not opening due to incorrect relative path

v1.0.0

Initial Release

New

  • Dashboard with income/expense overview
  • Income tracking (Alex & Chelsea)
  • Fixed expense management
  • Variable bill tracking
  • Subscription management
  • One-time expense logging
  • Monthly balance projections
  • Alex/Chelsea/Duo user switching with distinct color themes
  • L1/L2/L3 expense category hierarchy
  • AI expense categorization
  • AI spending insights
  • Settings & configuration page
  • Features & bug tracking
  • README with version history
  • Seed data from Excel