Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Income" ADD COLUMN "confirmationMessageId" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "Income_confirmationMessageId_key" ON "Income"("confirmationMessageId");
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ model Income {
rawText String
confidence Float

// Link to the confirmation message (for reply/edit/undo handling)
confirmationMessageId String? @unique

// Groups transactions parsed from one message, for batch undo.
batchId String?

Expand Down
14 changes: 11 additions & 3 deletions src/application/interfaces/IMessagingPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,30 @@ export interface InlineButton {
// row). A single-row keyboard is just [[...]].
export type InlineButtonRows = InlineButton[][];

export interface SendInteractiveOptions {
// Thread this message as a reply under an existing message (Telegram:
// reply_to_message_id) — e.g. an "Are you sure?" prompt under a transaction.
replyToMessageId?: string | undefined;
}

export interface IMessagingPlatform {
sendMessage(payload: SendMessagePayload): Promise<string>;
sendTypingIndicator(platformUserId: string): Promise<void>;
sendInteractiveMessage(
to: string,
body: string,
rows: InlineButtonRows,
opts?: SendInteractiveOptions,
): Promise<string>;
// Edit an existing message's text + keyboard in place (for multi-select
// toggles). No-op if the platform can't edit.
// toggles and in-place confirm resolution — pass rows: [] to clear buttons).
editInteractiveMessage?(
to: string,
messageId: string,
body: string,
rows: InlineButtonRows,
): Promise<void>;
// Optional: ack a button press (Telegram: answerCallbackQuery)
acknowledgeInteraction?(interactionId: string): Promise<void>;
// Optional: ack a button press (Telegram: answerCallbackQuery). An optional
// `text` shows a small toast on the tapped button.
acknowledgeInteraction?(interactionId: string, text?: string): Promise<void>;
}
23 changes: 13 additions & 10 deletions src/application/use-cases/ProcessIncomingMessage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ describe("ProcessIncomingMessage (budget flow)", () => {
create: vi.fn().mockResolvedValue({ id: "inc1" }),
sumForMonth: vi.fn().mockResolvedValue(0),
findLastByUserId: vi.fn().mockResolvedValue(null),
findByConfirmationMessageId: vi.fn().mockResolvedValue(null),
updateConfirmationMessageId: vi.fn().mockResolvedValue(undefined),
softDelete: vi.fn().mockResolvedValue(undefined),
};
recurringRuleRepository = {
Expand Down Expand Up @@ -167,7 +169,7 @@ describe("ProcessIncomingMessage (budget flow)", () => {
categoryId: "c1",
}),
);
const body = messageService.sendMessage.mock.calls[0][0].body;
const body = messageService.sendInteractiveMessage.mock.calls[0][1];
expect(body).toContain("Wants · Food");
expect(body).toContain("Food:"); // per-category line
expect(body).toContain("Wants:"); // per-bucket line
Expand All @@ -176,7 +178,7 @@ describe("ProcessIncomingMessage (budget flow)", () => {
expect(body).toContain("15,000");
expect(expenseRepository.updateConfirmationMessageId).toHaveBeenCalledWith(
"e1",
"msg-id-123",
"msg-id-456",
);
});

Expand Down Expand Up @@ -225,7 +227,7 @@ describe("ProcessIncomingMessage (budget flow)", () => {
parseLogId: "plog1",
}),
);
const body = messageService.sendMessage.mock.calls[0][0].body;
const body = messageService.sendInteractiveMessage.mock.calls[0][1];
expect(body).toContain("Wants:");
expect(body).toContain("left of");
expect(body).toContain("13,500"); // 15000 - 1500
Expand Down Expand Up @@ -272,23 +274,24 @@ describe("ProcessIncomingMessage (budget flow)", () => {
);
});

it("undoes the last expense on the plain 'undo' command", async () => {
it("asks to confirm before undoing on the plain 'undo' command", async () => {
expenseRepository.findLastByUserId.mockResolvedValue({
id: "e1",
amount: 220,
bucket: "WANTS",
categoryId: "c1",
note: "lunch",
batchId: null,
});
expenseRepository.sumByBucketForMonth.mockResolvedValue(0);

await useCase.execute({ platformUserId: "123", textMessage: "undo" });

expect(aiParser.parseText).not.toHaveBeenCalled();
expect(expenseRepository.softDelete).toHaveBeenCalledWith("e1");
expect(messageService.sendMessage.mock.calls[0][0].body).toContain(
"Removed: ₹220 Food",
);
// Confirms first — no immediate delete.
expect(expenseRepository.softDelete).not.toHaveBeenCalled();
const [, body, rows] = messageService.sendInteractiveMessage.mock.calls[0];
expect(body).toContain("Undo this?");
expect(rows[0][0].id).toBe("txn:del:e:e1:y");
});

it("records income and replies with the refreshed budget", async () => {
Expand All @@ -307,7 +310,7 @@ describe("ProcessIncomingMessage (budget flow)", () => {
expect(incomeRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ amount: 5000, userId: "u1" }),
);
expect(messageService.sendMessage.mock.calls[0][0].body).toContain(
expect(messageService.sendInteractiveMessage.mock.calls[0][1]).toContain(
"Income this cycle: ₹55,000",
);
});
Expand Down
40 changes: 40 additions & 0 deletions src/application/use-cases/ProcessIncomingMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { IncomeProcessor } from "./processors/IncomeProcessor";
import { RecurringSetupProcessor } from "./processors/RecurringSetupProcessor";
import { QueryProcessor } from "./processors/QueryProcessor";
import { FallbackProcessor } from "./processors/FallbackProcessor";
import { TransactionActionProcessor } from "./processors/TransactionActionProcessor";
import { TransactionReplyProcessor } from "./processors/TransactionReplyProcessor";
import { resolveByConfirmationMessage } from "./transactionActions";

export interface ProcessIncomingMessageInput {
platformUserId: string;
Expand All @@ -43,11 +46,15 @@ export interface ProcessIncomingMessageInput {
// The message_id of the inline-keyboard message, when this is a button press —
// lets processors edit that message in place (e.g. multi-select toggles).
callbackMessageId?: string | undefined;
// The callback_query id, so a handler can ack the tap with a toast.
callbackQueryId?: string | undefined;
}

export interface ProcessIncomingMessageOutput {
response: string;
parsed: ParsedData;
// Optional toast to show on the tapped inline button (callbacks only).
toast?: string | undefined;
}

export class ProcessIncomingMessageUseCase {
Expand All @@ -71,6 +78,14 @@ export class ProcessIncomingMessageUseCase {
private readonly runTransaction: RunInTransaction,
) {
this.preParseProcessors = [
new TransactionActionProcessor(
expenseRepository,
incomeRepository,
categoryRepository,
budgetConfigRepository,
parseLogRepository,
messageService,
),
new ConfirmBucketProcessor(
parseLogRepository,
expenseRepository,
Expand Down Expand Up @@ -116,6 +131,14 @@ export class ProcessIncomingMessageUseCase {
),
];
this.postParseProcessors = [
new TransactionReplyProcessor(
expenseRepository,
incomeRepository,
categoryRepository,
budgetConfigRepository,
parseLogRepository,
messageService,
),
new StatusProcessor(
expenseRepository,
budgetConfigRepository,
Expand Down Expand Up @@ -183,12 +206,29 @@ export class ProcessIncomingMessageUseCase {
content: h.content,
}));

// If the user replied to a transaction's confirmation message, resolve which
// transaction so the reply/edit processors can gate on it synchronously.
const replyTarget = payload.replyToMessageId
? await resolveByConfirmationMessage(
{
expenseRepository: this.expenseRepository,
incomeRepository: this.incomeRepository,
categoryRepository: this.categoryRepository,
budgetConfigRepository: this.budgetConfigRepository,
},
user.id,
payload.replyToMessageId,
)
: null;

const context: ProcessContext = {
user,
platformUserId: payload.platformUserId,
textMessage: payload.textMessage,
replyToMessageId: payload.replyToMessageId,
callbackMessageId: payload.callbackMessageId,
callbackQueryId: payload.callbackQueryId,
replyTarget: replyTarget ?? undefined,
conversationHistory: history,
};

Expand Down
78 changes: 55 additions & 23 deletions src/application/use-cases/expenseFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ICategoryRepository } from "../../domain/repositories/ICategoryReposito
import { IBudgetConfigRepository } from "../../domain/repositories/IBudgetConfigRepository";
import { IIncomeRepository } from "../../domain/repositories/IIncomeRepository";
import { IMessagingPlatform } from "../interfaces/IMessagingPlatform";
import { txnCb } from "./txnCallback";
import {
BUCKET_META,
bucketBudget,
Expand Down Expand Up @@ -94,6 +95,41 @@ export function buildBucketBudgetLine(
return `${meta.emoji} ${meta.label}: ${formatMoney(remaining)} left of ${formatMoney(budget)} · ${formatMoney(safeDaily)}/day safe`;
}

// Resolve a category name to a leaf id + label for a user, creating it if new.
// Expenses only ever attach to leaf categories — never a group row. If the name
// resolves to a group, it stays uncategorized (still lands in the right bucket).
// Returns the category's own bucket too, so an edit can re-derive the bucket
// from a changed category (create keeps its caller-supplied bucket).
export async function resolveExpenseCategory(
categoryRepository: ICategoryRepository,
userId: string,
bucket: Bucket,
name?: string | undefined,
): Promise<{
categoryId?: string | undefined;
categoryLabel: string;
bucket: Bucket;
}> {
let categoryId: string | undefined;
let categoryLabel = name ?? "General";
let resolvedBucket = bucket;
if (name) {
const existing = await categoryRepository.findByNameForUser(userId, name);
if (existing && !existing.isGroup) {
categoryId = existing.id;
categoryLabel = existing.name;
resolvedBucket = existing.bucket;
} else if (existing && existing.isGroup) {
resolvedBucket = existing.bucket; // group → uncategorized, adopt its bucket
} else {
const created = await categoryRepository.create({ userId, name, bucket });
categoryId = created.id;
categoryLabel = created.name;
}
}
return { categoryId, categoryLabel, bucket: resolvedBucket };
}

// Creates the expense and returns it plus the resolved category label. No
// messaging — callers decide how to reply (single confirmation vs batch summary).
export async function recordExpense(
Expand All @@ -102,31 +138,19 @@ export async function recordExpense(
): Promise<{ expense: Expense; categoryLabel: string }> {
const { user, amount, bucket } = args;

// Resolve the category: use a known id, else find-or-create by name + bucket.
// Expenses only ever attach to leaf categories — never a group row. If the
// name resolves to a group, the expense stays uncategorized (it still lands in
// the right bucket and shows up in Needs Review). We can't create a same-name
// leaf either: (userId, name) is unique.
// Use a known leaf id when the caller already resolved one; otherwise
// find-or-create by name (bucket stays the caller-supplied one).
let categoryId = args.categoryId;
let categoryLabel = args.categoryName ?? "General";
if (!categoryId && args.categoryName) {
const existing = await deps.categoryRepository.findByNameForUser(
const resolved = await resolveExpenseCategory(
deps.categoryRepository,
user.id,
bucket,
args.categoryName,
);
if (existing && !existing.isGroup) {
categoryId = existing.id;
categoryLabel = existing.name;
} else if (!existing) {
const created = await deps.categoryRepository.create({
userId: user.id,
name: args.categoryName,
bucket,
});
categoryId = created.id;
categoryLabel = created.name;
}
// else: name matches a group → leave uncategorized.
categoryId = resolved.categoryId;
categoryLabel = resolved.categoryLabel;
}

const expense = await deps.expenseRepository.create({
Expand Down Expand Up @@ -207,10 +231,18 @@ export async function recordExpenseAndReply(
.filter(Boolean)
.join("\n");

const messageId = await deps.messageService.sendMessage({
to: args.platformUserId,
body: response,
});
// Send with quick-action buttons so the user can edit/delete without knowing
// the reply trick; store the message id so replies can target this expense.
const messageId = await deps.messageService.sendInteractiveMessage(
args.platformUserId,
response,
[
[
{ id: txnCb.hintedit("expense", expense.id), title: "✏️ Edit" },
{ id: txnCb.askdel("expense", expense.id), title: "🗑 Delete" },
],
],
);
if (messageId) {
await deps.expenseRepository.updateConfirmationMessageId(
expense.id,
Expand Down
20 changes: 11 additions & 9 deletions src/application/use-cases/processors/BatchProcessor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("BatchProcessor", () => {
};
messageService = {
sendMessage: vi.fn().mockResolvedValue("summary-msg"),
sendInteractiveMessage: vi.fn().mockResolvedValue("followup-msg"),
sendInteractiveMessage: vi.fn().mockResolvedValue("summary-msg"),
};
processor = new BatchProcessor(
expenseRepository,
Expand Down Expand Up @@ -114,16 +114,19 @@ describe("BatchProcessor", () => {
expect(new Set(batchIds).size).toBe(1);
expect(batchIds[0]).toBeTruthy();

// Exactly one summary, no ambiguity follow-up.
expect(messageService.sendMessage).toHaveBeenCalledTimes(1);
expect(messageService.sendInteractiveMessage).not.toHaveBeenCalled();
// Exactly one summary (interactive, with a delete-all button); no follow-up.
expect(messageService.sendInteractiveMessage).toHaveBeenCalledTimes(1);

// Each recorded expense item carries a compact bucket sub-line.
const summary = messageService.sendMessage.mock.calls[0][0].body;
const summary = messageService.sendInteractiveMessage.mock.calls[0][1];
expect(summary).toContain("· Wants:");
expect(summary).toContain("· Needs:");
expect(summary).toContain("left");

// Summary carries a Delete-all quick action.
const summaryRows = messageService.sendInteractiveMessage.mock.calls[0][2];
expect(summaryRows[0][0].id).toMatch(/^txn:askdelbatch:/);

// Representative row linked to the summary message.
expect(expenseRepository.updateConfirmationMessageId).toHaveBeenCalledWith(
"e1",
Expand Down Expand Up @@ -159,10 +162,9 @@ describe("BatchProcessor", () => {
// ParseLog carries the batchId so confirmed items rejoin the batch.
expect(parseLogRepository.create.mock.calls[0][0].batchId).toBeTruthy();

// One summary + one grouped follow-up with a bkt: button row per item.
expect(messageService.sendMessage).toHaveBeenCalledTimes(1);
expect(messageService.sendInteractiveMessage).toHaveBeenCalledTimes(1);
const rows = messageService.sendInteractiveMessage.mock.calls[0][2];
// Summary (interactive) + one grouped follow-up with a bkt: button row per item.
expect(messageService.sendInteractiveMessage).toHaveBeenCalledTimes(2);
const rows = messageService.sendInteractiveMessage.mock.calls[1][2];
expect(rows).toHaveLength(2);
expect(rows[0][0].id).toMatch(/^bkt:log1:NEEDS$/);
});
Expand Down
14 changes: 10 additions & 4 deletions src/application/use-cases/processors/BatchProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
buildExpenseLine,
buildBucketBudgetLine,
} from "../expenseFlow";
import { txnCb } from "../txnCallback";
import {
BUCKET_META,
bucketBudget,
Expand Down Expand Up @@ -214,10 +215,15 @@ export class BatchProcessor implements MessageProcessor {
parts.join("\n") ||
'Hmm, I couldn\'t catch those. Try "chai 30, auto 80".';

const summaryMsgId = await this.messageService.sendMessage({
to: platformUserId,
body: summaryBody,
});
// Delete-all button when anything was logged (confirm-gated on tap).
const summaryRows: InlineButtonRows = firstExpenseId
? [[{ id: txnCb.askdelbatch(batchId), title: "🗑 Delete all" }]]
: [];
const summaryMsgId = await this.messageService.sendInteractiveMessage(
platformUserId,
summaryBody,
summaryRows,
);
// Link one representative row to the summary so replying to it undoes the
// whole batch (batchId does the grouping — see UndoProcessor).
if (summaryMsgId && firstExpenseId) {
Expand Down
Loading
Loading