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

@@ -1,6 +1,6 @@
## Context
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and `OpenAI`, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and a fully offline local LLM runtime, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, merchant-assisted categorization, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
## Goals / Non-Goals
@@ -8,12 +8,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
- Build a single deployable `Next.js` app with UI views and server routes in one codebase.
- Persist expenses, paychecks, and generated monthly insights in a local SQLite database managed by Prisma.
- Centralize monthly aggregation logic so dashboard reads and AI generation use the same numbers.
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls `OpenAI`.
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls a fully offline local inference runtime.
- Make v1 testable with deterministic validation, aggregation, and safe fallback behavior for sparse data.
- Add privacy-preserving merchant category suggestion with deterministic merchant mappings before model inference.
**Non-Goals:**
- Authentication, multi-user support, bank sync, receipt scanning, background jobs, or email delivery.
- Automatic categorization, editing data through AI, or free-form custom categories in v1.
- Fully automatic uncapped categorization without user review for ambiguous merchants, editing data through AI, or free-form custom categories in v1.
- Complex financial forecasting beyond simple next-month guidance derived from recent activity.
## Decisions
@@ -34,16 +35,25 @@ The repository starts with a product plan and OpenSpec configuration but no appl
- Rationale: dashboard totals, paycheck coverage, category breakdowns, and AI snapshots must stay consistent across endpoints.
- Alternative considered: separate logic per route. Rejected because it risks drift between dashboard and insight generation.
### Use `Ollama` with a local Qwen-class instruct model
- Rationale: privacy is a primary product requirement, and the target machine can comfortably run a recent local model for lightweight categorization and summary generation.
- Alternative considered: hosted `OpenAI`. Rejected because it violates the privacy-first goal for personal financial data.
### Add an AI service boundary with structured prompt input and fallback responses
- Rationale: the app needs provider isolation, predictable prompt shape, and safe messaging when data is too sparse for useful advice.
- Alternative considered: calling `OpenAI` directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
- Rationale: the app needs runtime isolation, predictable prompt shape, and safe messaging when local inference is unavailable or data is too sparse for useful advice.
- Alternative considered: calling the local model directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
### Use merchant rules first and local-model fallback second for category suggestion
- Rationale: most repeated merchants can be categorized deterministically and faster than model inference, while unknown merchants still benefit from local AI assistance.
- Alternative considered: model-only categorization. Rejected because it is slower, less predictable, and unnecessary for common merchants.
## Risks / Trade-offs
- [Local timezone handling differs by machine] -> Normalize month calculations around stored local-date strings and test month edges explicitly.
- [SQLite limits concurrency] -> Acceptable for single-user local-first v1; no mitigation beyond keeping writes simple.
- [AI output quality varies with sparse or noisy data] -> Add minimum-data fallback logic and keep prompts grounded in structured aggregates.
- [OpenAI dependency requires API key management] -> Read configuration from environment variables and keep failure messages explicit in the UI/API.
- [Local model may be unavailable or not yet pulled] -> Detect runtime/model readiness and return explicit offline setup guidance in the UI/API.
- [Merchant names can be ambiguous] -> Use auto-fill only for known deterministic mappings and require user confirmation for fallback suggestions.
## Migration Plan
@@ -51,13 +61,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
2. Add the Prisma schema, create the initial SQLite migration, and generate the client.
3. Implement CRUD routes and UI forms for expenses and paychecks.
4. Implement dashboard aggregation and month filtering.
5. Add the AI insight service and persistence for generated monthly insights.
5. Add the offline AI service, merchant-category suggestion flow, and persistence for generated monthly insights.
6. Run automated tests, then exercise the main flows in the browser.
Rollback is straightforward in early development: revert the code change and reset the local SQLite database if schema changes become invalid.
## Open Questions
- Which `OpenAI` model should be the initial default for monthly insight generation?
- Which exact local Qwen model tag should be the initial default in `Ollama`?
- Should generated monthly insights overwrite prior insights for the same month or create a historical trail of regenerated summaries?
- Do we want soft confirmation in the UI before deleting expenses or paychecks, or is immediate deletion acceptable for v1?

View File

@@ -1,12 +1,13 @@
## Why
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries and AI-generated guidance.
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries, private offline AI assistance, and no dependency on hosted model providers.
## What Changes
- Add a local-first web app for tracking expenses and biweekly paychecks without authentication.
- Add dashboard capabilities for month-to-date totals, category breakdowns, cash flow, and spending comparisons.
- Add manual AI insight generation for a selected month using structured aggregates and transaction samples.
- Add fully offline AI insight generation for a selected month using structured aggregates and transaction samples.
- Add merchant-name-based category suggestion using deterministic rules plus local-model fallback.
- Add local persistence, validation, and API routes for expenses, paychecks, dashboard data, and insight generation.
## Capabilities
@@ -15,7 +16,8 @@ The project currently has a product plan but no runnable application, spec artif
- `expense-tracking`: Record, list, and delete categorized expenses for a given date.
- `paycheck-tracking`: Record, list, and delete paycheck entries based on actual pay dates.
- `monthly-dashboard`: View month-specific spending, income, and derived financial summaries.
- `monthly-insights`: Generate read-only AI insights from monthly financial activity.
- `monthly-insights`: Generate private offline AI insights from monthly financial activity.
- `category-suggestion`: Suggest expense categories from merchant/shop names without cloud calls.
### Modified Capabilities
- None.
@@ -24,5 +26,5 @@ The project currently has a product plan but no runnable application, spec artif
- Affected code: new `Next.js` application, server routes, UI views, Prisma schema, and AI integration service.
- APIs: `POST/GET/DELETE` routes for expenses and paychecks, `GET /dashboard`, and `POST /insights/generate`.
- Dependencies: `Next.js`, `Prisma`, `SQLite`, and `OpenAI` SDK.
- Dependencies: `Next.js`, `Prisma`, `SQLite`, `Ollama`, and a local Qwen-class instruct model.
- Systems: local machine timezone handling for month boundaries and persisted local database storage.

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: System suggests categories from merchant names
The system SHALL support merchant-name-based category suggestion for expense entry while keeping all suggestion logic fully offline.
#### Scenario: Known merchant resolves from deterministic rules
- **WHEN** the user enters a merchant or shop name that matches a known merchant rule
- **THEN** the system assigns the mapped category without needing model inference
#### Scenario: Unknown merchant falls back to local model
- **WHEN** the user enters a merchant or shop name that does not match a known merchant rule
- **THEN** the system asks the local AI service for a category suggestion and returns the suggested category
### Requirement: Ambiguous suggestions remain user-controlled
The system SHALL keep the final saved category under user control for ambiguous or model-generated suggestions.
#### Scenario: User confirms model suggestion before save
- **WHEN** the category suggestion comes from model inference instead of a deterministic rule
- **THEN** the user can review and confirm or change the category before the expense is saved
#### Scenario: No cloud fallback is used
- **WHEN** the local suggestion service is unavailable
- **THEN** the system continues to allow manual category selection and does not send merchant data to a hosted provider

View File

@@ -1,11 +1,11 @@
## ADDED Requirements
### Requirement: User can generate monthly AI insights on demand
The system SHALL allow the user to manually generate AI insights for any month with existing or sparse data by sending structured monthly context to the configured `OpenAI` provider.
The system SHALL allow the user to manually generate monthly AI insights for any month with existing or sparse data by sending structured monthly context to a fully offline local inference runtime.
#### Scenario: Insights are generated for a month with data
- **WHEN** the user requests insight generation for a month with recorded activity
- **THEN** the system sends monthly aggregates plus transaction samples to the AI service and returns a rendered narrative summary with structured supporting totals
- **THEN** the system sends monthly aggregates plus transaction samples to the local AI service and returns a rendered narrative summary with structured supporting totals
#### Scenario: Prior month insights can be generated
- **WHEN** the user requests insight generation for a previous month that has recorded data
@@ -21,3 +21,10 @@ The system SHALL keep AI insight generation read-only and return a safe fallback
#### Scenario: AI does not mutate financial records
- **WHEN** the system generates or stores monthly insights
- **THEN** no expense or paycheck records are created, updated, or deleted as part of that request
### Requirement: Insight generation remains private and resilient offline
The system SHALL keep monthly insight generation fully offline and provide a clear fallback response when the local model runtime or selected model is unavailable.
#### Scenario: Local runtime is unavailable
- **WHEN** the user requests monthly insights while the local AI runtime is not running or the configured model is unavailable
- **THEN** the system returns a clear setup or availability message instead of attempting a cloud fallback

View File

@@ -22,10 +22,17 @@
- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
- [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
- [ ] 4.3 Implement the `OpenAI` insight service with structured monthly snapshot input and sparse-month fallback logic.
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records.
- [ ] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic.
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
## 5. Verification
## 5. Offline categorization
- [ ] 5.1 Add automated tests for validation, persistence, dashboard aggregates, and insight fallback behavior.
- [ ] 5.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, and insight generation.
- [x] 5.1 Implement deterministic merchant-to-category mapping for known merchants.
- [x] 5.2 Implement a local-model category suggestion endpoint for unknown merchants.
- [x] 5.3 Update the expense entry flow to auto-fill known merchants and require confirmation for model-generated suggestions.
- [x] 5.4 Add local runtime availability handling so category suggestion falls back to manual selection without cloud calls.
## 6. Verification
- [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules.
- [ ] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation.

26
plan.md
View File

@@ -1,9 +1,9 @@
# Monthly Expense Tracker With AI Insights
## Summary
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance.
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance while keeping all AI features fully offline.
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, and an in-app AI summary rather than email or exports.
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, a fully offline local LLM for private AI features, and an in-app AI summary rather than email or exports.
## Implementation Changes
- App shape:
@@ -16,29 +16,33 @@ The first version is optimized for fast daily entry and a dashboard-first review
- Categories:
- Ship with fixed starter categories such as `Rent`, `Food`, `Transport`, `Bills`, `Shopping`, `Health`, `Entertainment`, `Misc`.
- Store category as a controlled value so monthly summaries can group reliably.
- Support merchant-name-based category suggestion: apply deterministic merchant rules first, then use the local LLM only for unknown merchants.
- Treat AI categorization as assistive: known merchants may auto-fill a category, but unknown-merchant suggestions should be confirmed before save.
- Dashboard behavior:
- Show current month totals for expenses, category breakdown, paycheck total, and net cash flow.
- Include month-to-date charts and simple comparisons like highest category, largest single expense, average daily spend, and spend vs paycheck coverage.
- Provide a `Generate Insights` action that works any time during the month, not only at month-end.
- AI insight generation:
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to the AI model.
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to a fully offline local model.
- Ask the model to return:
- spending pattern summary
- unusual categories or spikes
- paycheck-to-spend timing observations
- practical next-month suggestions
- Keep AI read-only in v1: it does not edit data or auto-categorize entries.
- Use AI for merchant-category suggestion as well as monthly summaries, but keep the final saved category under user control for ambiguous merchants.
- Storage and architecture:
- Use a simple embedded database for local-first persistence, preferably SQLite.
- Implement the app with `Next.js` for the web UI and server routes.
- Use `Prisma` for the data layer and migrations.
- Keep the AI integration behind a small service boundary so the model/provider can be swapped later without changing UI code.
- Use `OpenAI` for insight generation in v1.
- Use `Ollama` with a local Qwen-class instruct model for offline inference in v1.
- Keep the app functional when the local model is unavailable by returning a clear fallback message instead of failing silently.
- Public interfaces / APIs:
- `POST /expenses`, `GET /expenses`, `DELETE /expenses/:id`
- `POST /paychecks`, `GET /paychecks`, `DELETE /paychecks/:id`
- `GET /dashboard?month=YYYY-MM`
- `POST /insights/generate?month=YYYY-MM`
- `POST /categories/suggest` with merchant/shop name input for local category suggestion
- Insight response should include structured fields for totals and a rendered narrative summary for the dashboard.
## Implementation Checklist
@@ -48,9 +52,10 @@ The first version is optimized for fast daily entry and a dashboard-first review
- [ ] Implement expense CRUD routes and forms.
- [ ] Implement paycheck CRUD routes and forms.
- [ ] Build dashboard aggregation logic for totals, categories, cash flow, and comparisons.
- [ ] Add the insight generation service boundary and `OpenAI` integration.
- [ ] Add the insight generation service boundary and offline `Ollama` integration.
- [ ] Add merchant-name category suggestion using merchant rules first and local-model fallback second.
- [ ] Render AI insight output in the dashboard with fallback behavior for sparse months.
- [ ] Add tests for validation, aggregates, persistence, and insight generation.
- [ ] Add tests for validation, aggregates, persistence, local-model fallback behavior, and category suggestion.
- [ ] Verify all month-boundary behavior using local timezone dates.
## Test Plan
@@ -67,6 +72,11 @@ The first version is optimized for fast daily entry and a dashboard-first review
- AI request uses aggregated monthly inputs plus transaction samples.
- Manual generation works for current month and prior months with existing data.
- Empty or near-empty months return a safe fallback message instead of low-quality advice.
- App returns a clear fallback message when `Ollama` or the local model is unavailable.
- Category suggestion:
- Known merchants resolve deterministically to the expected category.
- Unknown merchants fall back to the local model and return a suggested category.
- Ambiguous suggestions require user confirmation before save.
- Persistence:
- Data remains available after app restart.
- Deleting an expense or paycheck updates dashboard and future insight results correctly.
@@ -79,3 +89,5 @@ The first version is optimized for fast daily entry and a dashboard-first review
- Fixed starter categories are sufficient for v1; custom categories can be added later.
- Income is modeled as discrete biweekly paychecks because that materially affects next-month guidance and intra-month cash-flow interpretation.
- Month and paycheck boundaries use the local machine timezone.
- Privacy matters more than hosted-model quality for this app, so AI features should stay fully offline.
- A recent local Qwen instruct model running through `Ollama` is the default model family for v1.

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",
};
}
}