Add offline merchant category suggestions
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
23
src/app/categories/suggest/route.ts
Normal file
23
src/app/categories/suggest/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
38
src/lib/category-suggestion.test.ts
Normal file
38
src/lib/category-suggestion.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
119
src/lib/category-suggestion.ts
Normal file
119
src/lib/category-suggestion.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user