Add offline merchant category suggestions

This commit is contained in:
2026-03-23 13:28:00 -04:00
parent 12c72ddcad
commit 696d393fca
11 changed files with 352 additions and 31 deletions

View File

@@ -12,7 +12,7 @@ export default function AddExpensePage() {
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
<p className="text-lg leading-8 text-stone-600">
This first slice focuses on fast local entry. Each saved expense appears immediately in your running history.
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
</p>
</header>

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { suggestCategoryForMerchant } from "@/lib/category-suggestion";
const categorySuggestionSchema = z.object({
merchantName: z.string().trim().min(1, "Merchant name is required.").max(80, "Keep merchant names under 80 characters."),
});
export async function POST(request: Request) {
const payload = await request.json();
const parsed = categorySuggestionSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid merchant name." },
{ status: 400 },
);
}
const suggestion = await suggestCategoryForMerchant(parsed.data.merchantName);
return NextResponse.json(suggestion);
}

View File

@@ -5,6 +5,14 @@ import { useEffect, useMemo, useState, type FormEvent } from "react";
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
import { formatCurrencyFromCents } from "@/lib/money";
type SuggestionResponse = {
category: CategoryValue | null;
message: string;
merchantName: string;
requiresConfirmation: boolean;
source: "rule" | "model" | "unavailable";
};
type ExpenseRecord = {
id: string;
title: string;
@@ -24,14 +32,22 @@ type Props = {
export function ExpenseWorkspace({ categoryOptions }: Props) {
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
const [formState, setFormState] = useState({
const [formState, setFormState] = useState<{
title: string;
amount: string;
date: string;
category: CategoryValue;
}>({
title: "",
amount: "",
date: new Date().toISOString().slice(0, 10),
category: categoryOptions[0]?.value ?? "",
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [suggestionMessage, setSuggestionMessage] = useState<string | null>(null);
const [needsSuggestionConfirmation, setNeedsSuggestionConfirmation] = useState(false);
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
useEffect(() => {
async function loadExpenses() {
@@ -48,8 +64,46 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
[expenses],
);
async function handleMerchantSuggestion() {
const merchantName = formState.title.trim();
if (!merchantName || merchantName === lastSuggestedMerchant) {
return;
}
const response = await fetch("/categories/suggest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ merchantName }),
});
const payload = (await response.json().catch(() => null)) as SuggestionResponse | { error?: string } | null;
setLastSuggestedMerchant(merchantName);
if (!response.ok) {
setSuggestionMessage(payload && "error" in payload ? payload.error ?? "Could not suggest a category." : "Could not suggest a category.");
return;
}
const suggestion = payload as SuggestionResponse;
setSuggestionMessage(suggestion.message);
if (suggestion.category) {
const suggestedCategory = suggestion.category;
setFormState((current) => ({ ...current, category: suggestedCategory }));
}
setNeedsSuggestionConfirmation(suggestion.requiresConfirmation);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (needsSuggestionConfirmation) {
setError("Confirm or change the suggested category before saving.");
return;
}
setBusy(true);
setError(null);
@@ -71,6 +125,9 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setExpenses((current) => [payload.expense, ...current]);
setFormState((current) => ({ ...current, title: "", amount: "" }));
setSuggestionMessage(null);
setNeedsSuggestionConfirmation(false);
setLastSuggestedMerchant("");
}
async function handleDelete(id: string) {
@@ -109,7 +166,14 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
<input
required
value={formState.title}
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
onBlur={() => void handleMerchantSuggestion()}
onChange={(event) => {
const title = event.target.value;
setFormState((current) => ({ ...current, title }));
setLastSuggestedMerchant("");
setNeedsSuggestionConfirmation(false);
setSuggestionMessage(null);
}}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
placeholder="Groceries, rent, train pass..."
/>
@@ -142,7 +206,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
Category
<select
value={formState.category}
onChange={(event) => setFormState((current) => ({ ...current, category: event.target.value }))}
onChange={(event) => {
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
setNeedsSuggestionConfirmation(false);
}}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
>
{categoryOptions.map((option) => (
@@ -153,6 +220,19 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
</select>
</label>
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
<p>{suggestionMessage ?? "Merchant rules auto-fill known shops. Unknown shops use local AI when available."}</p>
{needsSuggestionConfirmation ? (
<button
type="button"
onClick={() => setNeedsSuggestionConfirmation(false)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
>
Confirm suggestion
</button>
) : null}
</div>
<div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-rose-700">{error}</p>
<button

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getMerchantRuleCategory, suggestCategoryForMerchant } from "@/lib/category-suggestion";
describe("getMerchantRuleCategory", () => {
it("matches known merchants deterministically", () => {
expect(getMerchantRuleCategory("Amazon Marketplace")).toBe("SHOPPING");
expect(getMerchantRuleCategory("Uber Trip")).toBe("TRANSPORT");
});
});
describe("suggestCategoryForMerchant", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses the local model for unknown merchants", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ response: JSON.stringify({ category: "FOOD" }) }),
} as Response);
const suggestion = await suggestCategoryForMerchant("Blue Tokai");
expect(suggestion.category).toBe("FOOD");
expect(suggestion.source).toBe("model");
expect(suggestion.requiresConfirmation).toBe(true);
});
it("falls back cleanly when the local model is unavailable", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
const suggestion = await suggestCategoryForMerchant("Unknown Merchant");
expect(suggestion.category).toBeNull();
expect(suggestion.source).toBe("unavailable");
});
});

View File

@@ -0,0 +1,119 @@
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
type SuggestionSource = "rule" | "model" | "unavailable";
export type CategorySuggestion = {
category: CategoryValue | null;
message: string;
merchantName: string;
requiresConfirmation: boolean;
source: SuggestionSource;
};
type MerchantRule = {
category: CategoryValue;
patterns: RegExp[];
};
const MERCHANT_RULES: MerchantRule[] = [
{ category: "SHOPPING", patterns: [/amazon/i, /flipkart/i, /myntra/i] },
{ category: "FOOD", patterns: [/swiggy/i, /zomato/i, /starbucks/i, /instamart/i, /blinkit/i] },
{ category: "TRANSPORT", patterns: [/uber/i, /ola/i, /rapido/i, /metro/i] },
{ category: "BILLS", patterns: [/airtel/i, /jio/i, /electricity/i, /water bill/i] },
{ category: "HEALTH", patterns: [/apollo/i, /pharmacy/i, /clinic/i, /hospital/i] },
{ category: "ENTERTAINMENT", patterns: [/netflix/i, /spotify/i, /bookmyshow/i] },
{ category: "RENT", patterns: [/rent/i, /landlord/i, /lease/i] },
];
export function normalizeMerchantName(value: string) {
return value.trim().replace(/\s+/g, " ");
}
export function getMerchantRuleCategory(merchantName: string): CategoryValue | null {
const normalized = normalizeMerchantName(merchantName);
for (const rule of MERCHANT_RULES) {
if (rule.patterns.some((pattern) => pattern.test(normalized))) {
return rule.category;
}
}
return null;
}
function parseSuggestedCategory(raw: unknown): CategoryValue | null {
if (typeof raw !== "string") {
return null;
}
const normalized = raw.trim().toUpperCase();
return CATEGORY_VALUES.includes(normalized as CategoryValue) ? (normalized as CategoryValue) : null;
}
export async function suggestCategoryForMerchant(merchantName: string): Promise<CategorySuggestion> {
const normalized = normalizeMerchantName(merchantName);
if (!normalized) {
return {
category: null,
message: "Enter a merchant name to get a suggestion.",
merchantName: normalized,
requiresConfirmation: false,
source: "unavailable",
};
}
const matchedCategory = getMerchantRuleCategory(normalized);
if (matchedCategory) {
return {
category: matchedCategory,
message: "Known merchant matched locally. Category auto-filled.",
merchantName: normalized,
requiresConfirmation: false,
source: "rule",
};
}
try {
const response = await fetch(`${process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL ?? "qwen2.5:7b",
format: "json",
stream: false,
prompt:
"You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " +
normalized,
}),
});
if (!response.ok) {
throw new Error(`Ollama request failed with ${response.status}`);
}
const payload = (await response.json()) as { response?: string };
const parsed = payload.response ? JSON.parse(payload.response) : null;
const category = parseSuggestedCategory(parsed?.category);
if (!category) {
throw new Error("Model did not return a valid category.");
}
return {
category,
message: "Local AI suggested a category. Review before saving.",
merchantName: normalized,
requiresConfirmation: true,
source: "model",
};
} catch {
return {
category: null,
message: "Local AI suggestion is unavailable right now. Choose a category manually.",
merchantName: normalized,
requiresConfirmation: false,
source: "unavailable",
};
}
}