From 7c7d74668581308c9d3a1f7094ad5a9e48465e3f Mon Sep 17 00:00:00 2001 From: SADIK Date: Sun, 5 Jul 2026 09:35:15 +0530 Subject: [PATCH] feat(telegram): confirmed edit/delete/undo on transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply to a transaction (or tap the new [โœ๏ธ Edit]/[๐Ÿ—‘ Delete] buttons) to edit or delete it behind a Yes/No confirmation; `undo` now confirms too instead of deleting silently. Works for both expenses and income. - New txn: callback namespace (dependency-free grammar in txnCallback.ts) and a transactionActions.ts flow module that centralizes delete/restore/edit + the confirm prompts and keyboards. - TransactionReplyProcessor (post-parse) routes a reply to a delete or edit confirm using the already-parsed reply; TransactionActionProcessor (pre-parse) handles every txn: tap, resolving the prompt in place with a toast and an [โ†ฉ๏ธ Undo] restore button. - UndoProcessor now resolves the latest entry and confirms; the delete + budget summary moved into transactionActions (shared with reply/undo). - Income parity: add Income.confirmationMessageId (migration) + repo methods; capture/store the confirmation message id in IncomeProcessor. Expense/income repos gain update() + restore()/restoreByBatchId(). Edit re-derives the bucket via resolveExpenseCategory extracted from recordExpense. - Messaging: sendInteractiveMessage gains reply threading; acknowledgeInteraction gains toast text; the controller acks after handling to surface the toast. Migration prisma/migrations/*_add_income_confirmation_message_id created offline (not applied) โ€” run migrate deploy in the target environment. Tests: added TransactionReply/TransactionAction specs, rewrote UndoProcessor for the confirm-first behavior; 129 unit tests pass. build + lint green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 5 + prisma/schema.prisma | 3 + .../interfaces/IMessagingPlatform.ts | 14 +- .../use-cases/ProcessIncomingMessage.spec.ts | 23 +- .../use-cases/ProcessIncomingMessage.ts | 40 ++ src/application/use-cases/expenseFlow.ts | 78 ++-- .../processors/BatchProcessor.spec.ts | 20 +- .../use-cases/processors/BatchProcessor.ts | 14 +- .../processors/ConfirmBucketProcessor.spec.ts | 7 +- .../processors/ExpenseProcessor.spec.ts | 9 +- .../processors/IncomeProcessor.spec.ts | 14 +- .../use-cases/processors/IncomeProcessor.ts | 25 +- .../use-cases/processors/MessageProcessor.ts | 8 + .../TransactionActionProcessor.spec.ts | 150 ++++++++ .../processors/TransactionActionProcessor.ts | 224 ++++++++++++ .../TransactionReplyProcessor.spec.ts | 136 +++++++ .../processors/TransactionReplyProcessor.ts | 148 ++++++++ .../processors/UndoProcessor.spec.ts | 98 ++--- .../use-cases/processors/UndoProcessor.ts | 210 +++-------- .../use-cases/transactionActions.ts | 346 ++++++++++++++++++ src/application/use-cases/txnCallback.ts | 97 +++++ src/data/messaging/TelegramMessageService.ts | 16 +- .../repositories/PrismaExpenseRepository.ts | 27 ++ .../repositories/PrismaIncomeRepository.ts | 49 +++ src/domain/repositories/IExpenseRepository.ts | 13 + src/domain/repositories/IIncomeRepository.ts | 19 + .../repositories/IParseLogRepository.ts | 4 +- .../controllers/TelegramWebhookController.ts | 34 +- 28 files changed, 1530 insertions(+), 301 deletions(-) create mode 100644 prisma/migrations/20260705120000_add_income_confirmation_message_id/migration.sql create mode 100644 src/application/use-cases/processors/TransactionActionProcessor.spec.ts create mode 100644 src/application/use-cases/processors/TransactionActionProcessor.ts create mode 100644 src/application/use-cases/processors/TransactionReplyProcessor.spec.ts create mode 100644 src/application/use-cases/processors/TransactionReplyProcessor.ts create mode 100644 src/application/use-cases/transactionActions.ts create mode 100644 src/application/use-cases/txnCallback.ts diff --git a/prisma/migrations/20260705120000_add_income_confirmation_message_id/migration.sql b/prisma/migrations/20260705120000_add_income_confirmation_message_id/migration.sql new file mode 100644 index 0000000..27aa10d --- /dev/null +++ b/prisma/migrations/20260705120000_add_income_confirmation_message_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Income" ADD COLUMN "confirmationMessageId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Income_confirmationMessageId_key" ON "Income"("confirmationMessageId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e99932..ddef300 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/application/interfaces/IMessagingPlatform.ts b/src/application/interfaces/IMessagingPlatform.ts index 992b11b..919654d 100644 --- a/src/application/interfaces/IMessagingPlatform.ts +++ b/src/application/interfaces/IMessagingPlatform.ts @@ -12,6 +12,12 @@ 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; sendTypingIndicator(platformUserId: string): Promise; @@ -19,15 +25,17 @@ export interface IMessagingPlatform { to: string, body: string, rows: InlineButtonRows, + opts?: SendInteractiveOptions, ): Promise; // 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; - // Optional: ack a button press (Telegram: answerCallbackQuery) - acknowledgeInteraction?(interactionId: string): Promise; + // Optional: ack a button press (Telegram: answerCallbackQuery). An optional + // `text` shows a small toast on the tapped button. + acknowledgeInteraction?(interactionId: string, text?: string): Promise; } diff --git a/src/application/use-cases/ProcessIncomingMessage.spec.ts b/src/application/use-cases/ProcessIncomingMessage.spec.ts index d44384b..7e842b4 100644 --- a/src/application/use-cases/ProcessIncomingMessage.spec.ts +++ b/src/application/use-cases/ProcessIncomingMessage.spec.ts @@ -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 = { @@ -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 @@ -176,7 +178,7 @@ describe("ProcessIncomingMessage (budget flow)", () => { expect(body).toContain("15,000"); expect(expenseRepository.updateConfirmationMessageId).toHaveBeenCalledWith( "e1", - "msg-id-123", + "msg-id-456", ); }); @@ -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 @@ -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 () => { @@ -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", ); }); diff --git a/src/application/use-cases/ProcessIncomingMessage.ts b/src/application/use-cases/ProcessIncomingMessage.ts index 79b1a66..a8c78d7 100644 --- a/src/application/use-cases/ProcessIncomingMessage.ts +++ b/src/application/use-cases/ProcessIncomingMessage.ts @@ -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; @@ -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 { @@ -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, @@ -116,6 +131,14 @@ export class ProcessIncomingMessageUseCase { ), ]; this.postParseProcessors = [ + new TransactionReplyProcessor( + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + parseLogRepository, + messageService, + ), new StatusProcessor( expenseRepository, budgetConfigRepository, @@ -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, }; diff --git a/src/application/use-cases/expenseFlow.ts b/src/application/use-cases/expenseFlow.ts index da13762..f726e4a 100644 --- a/src/application/use-cases/expenseFlow.ts +++ b/src/application/use-cases/expenseFlow.ts @@ -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, @@ -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( @@ -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({ @@ -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, diff --git a/src/application/use-cases/processors/BatchProcessor.spec.ts b/src/application/use-cases/processors/BatchProcessor.spec.ts index e076ece..2b8051e 100644 --- a/src/application/use-cases/processors/BatchProcessor.spec.ts +++ b/src/application/use-cases/processors/BatchProcessor.spec.ts @@ -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, @@ -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", @@ -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$/); }); diff --git a/src/application/use-cases/processors/BatchProcessor.ts b/src/application/use-cases/processors/BatchProcessor.ts index ab74778..c100398 100644 --- a/src/application/use-cases/processors/BatchProcessor.ts +++ b/src/application/use-cases/processors/BatchProcessor.ts @@ -18,6 +18,7 @@ import { buildExpenseLine, buildBucketBudgetLine, } from "../expenseFlow"; +import { txnCb } from "../txnCallback"; import { BUCKET_META, bucketBudget, @@ -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) { diff --git a/src/application/use-cases/processors/ConfirmBucketProcessor.spec.ts b/src/application/use-cases/processors/ConfirmBucketProcessor.spec.ts index e9ed598..93f261e 100644 --- a/src/application/use-cases/processors/ConfirmBucketProcessor.spec.ts +++ b/src/application/use-cases/processors/ConfirmBucketProcessor.spec.ts @@ -47,7 +47,10 @@ describe("ConfirmBucketProcessor", () => { .mockResolvedValue({ needsPct: 50, wantsPct: 30, savingsPct: 20 }), }; incomeRepository = { sumForMonth: vi.fn().mockResolvedValue(0) }; - messageService = { sendMessage: vi.fn().mockResolvedValue("m1") }; + messageService = { + sendMessage: vi.fn().mockResolvedValue("m1"), + sendInteractiveMessage: vi.fn().mockResolvedValue("m2"), + }; processor = new ConfirmBucketProcessor( parseLogRepository, expenseRepository, @@ -82,7 +85,7 @@ describe("ConfirmBucketProcessor", () => { }), ); // Per-category budget line is included (chai has a โ‚น1,000 cap, โ‚น30 spent). - const body = messageService.sendMessage.mock.calls[0][0].body; + const body = messageService.sendInteractiveMessage.mock.calls[0][1]; expect(body).toContain("chai:"); expect(body).toContain("left of"); }); diff --git a/src/application/use-cases/processors/ExpenseProcessor.spec.ts b/src/application/use-cases/processors/ExpenseProcessor.spec.ts index c568a17..974fe21 100644 --- a/src/application/use-cases/processors/ExpenseProcessor.spec.ts +++ b/src/application/use-cases/processors/ExpenseProcessor.spec.ts @@ -83,7 +83,14 @@ describe("ExpenseProcessor", () => { expect(expenseRepository.create).toHaveBeenCalledWith( expect.objectContaining({ userId: "u1", amount: 30, bucket: "WANTS" }), ); - expect(messageService.sendMessage.mock.calls[0][0].body).toContain("โ‚น30"); + // Confirmation now carries Edit/Delete quick-action buttons. + expect(messageService.sendInteractiveMessage.mock.calls[0][1]).toContain( + "โ‚น30", + ); + expect(expenseRepository.updateConfirmationMessageId).toHaveBeenCalledWith( + "e1", + "m2", + ); expect(parseLogRepository.create).not.toHaveBeenCalled(); }); diff --git a/src/application/use-cases/processors/IncomeProcessor.spec.ts b/src/application/use-cases/processors/IncomeProcessor.spec.ts index c93df66..e1e18c0 100644 --- a/src/application/use-cases/processors/IncomeProcessor.spec.ts +++ b/src/application/use-cases/processors/IncomeProcessor.spec.ts @@ -21,7 +21,13 @@ describe("IncomeProcessor", () => { .fn() .mockResolvedValue({ needsPct: 50, wantsPct: 30, savingsPct: 20 }), }; - messageService = { sendMessage: vi.fn().mockResolvedValue("m1") }; + incomeRepository.updateConfirmationMessageId = vi + .fn() + .mockResolvedValue(undefined); + messageService = { + sendMessage: vi.fn().mockResolvedValue("m1"), + sendInteractiveMessage: vi.fn().mockResolvedValue("m2"), + }; processor = new IncomeProcessor( incomeRepository, budgetConfigRepository, @@ -62,13 +68,17 @@ describe("IncomeProcessor", () => { source: "freelance", }), ); - const body = messageService.sendMessage.mock.calls[0][0].body; + const body = messageService.sendInteractiveMessage.mock.calls[0][1]; expect(body).toContain("Income โ‚น5,000"); expect(body).toContain("Income this cycle: โ‚น55,000"); expect(body).toContain("Budget on โ‚น55,000"); expect(body).toContain("Needs โ‚น27,500"); // 55000 * 50% expect(body).toContain("Wants โ‚น16,500"); // 55000 * 30% expect(body).toContain("Savings โ‚น11,000"); // 55000 * 20% + expect(incomeRepository.updateConfirmationMessageId).toHaveBeenCalledWith( + "inc1", + "m2", + ); }); it("asks again when the amount is missing", async () => { diff --git a/src/application/use-cases/processors/IncomeProcessor.ts b/src/application/use-cases/processors/IncomeProcessor.ts index b0d1775..24e3552 100644 --- a/src/application/use-cases/processors/IncomeProcessor.ts +++ b/src/application/use-cases/processors/IncomeProcessor.ts @@ -6,6 +6,7 @@ import { import { IIncomeRepository } from "../../../domain/repositories/IIncomeRepository"; import { IBudgetConfigRepository } from "../../../domain/repositories/IBudgetConfigRepository"; import { IMessagingPlatform } from "../../interfaces/IMessagingPlatform"; +import { txnCb } from "../txnCallback"; import { BUCKET_META, bucketBudget, @@ -52,7 +53,7 @@ export class IncomeProcessor implements MessageProcessor { return { response, parsed }; } - await this.incomeRepository.create({ + const income = await this.incomeRepository.create({ userId: user.id, amount, rawText: textMessage, @@ -79,10 +80,24 @@ export class IncomeProcessor implements MessageProcessor { ๐Ÿ’ต Income this cycle: ${formatMoney(monthIncome)} Budget on ${formatMoney(effective)} โ†’ ${BUCKET_META.NEEDS.emoji} Needs ${formatMoney(bucketBudget(effective, config, "NEEDS"))} ยท ${BUCKET_META.WANTS.emoji} Wants ${formatMoney(bucketBudget(effective, config, "WANTS"))} ยท ${BUCKET_META.SAVINGS.emoji} Savings ${formatMoney(bucketBudget(effective, config, "SAVINGS"))}`; - await this.messageService.sendMessage({ - to: platformUserId, - body: response, - }); + // Send with quick-action buttons + store the message id so the user can + // reply to (or tap) this confirmation to edit/delete the income later. + const messageId = await this.messageService.sendInteractiveMessage( + platformUserId, + response, + [ + [ + { id: txnCb.hintedit("income", income.id), title: "โœ๏ธ Edit" }, + { id: txnCb.askdel("income", income.id), title: "๐Ÿ—‘ Delete" }, + ], + ], + ); + if (messageId) { + await this.incomeRepository.updateConfirmationMessageId( + income.id, + messageId, + ); + } return { response, parsed }; } } diff --git a/src/application/use-cases/processors/MessageProcessor.ts b/src/application/use-cases/processors/MessageProcessor.ts index 6f67b2b..75b7734 100644 --- a/src/application/use-cases/processors/MessageProcessor.ts +++ b/src/application/use-cases/processors/MessageProcessor.ts @@ -1,6 +1,7 @@ import { User } from "@prisma/client"; import { ParsedData, ParsedBatch } from "../../../domain/entities/ParsedData"; import { ConversationTurn } from "../../../domain/services/IAiParser"; +import { TransactionRef } from "../transactionActions"; export interface ProcessContext { user: User; @@ -12,12 +13,19 @@ export interface ProcessContext { parsedBatch?: ParsedBatch | undefined; replyToMessageId?: string | undefined; callbackMessageId?: string | undefined; + // The callback_query id (button press) โ€” lets a handler ack with a toast. + callbackQueryId?: string | undefined; + // Resolved when the user replied to a transaction confirmation โ€” the reply/edit + // processors gate on this (canHandle can't do async lookups). + replyTarget?: TransactionRef | undefined; conversationHistory?: ConversationTurn[] | undefined; } export interface ProcessOutput { response: string; parsed: ParsedData; + // Optional short toast shown on the tapped inline button. + toast?: string | undefined; } export interface MessageProcessor { diff --git a/src/application/use-cases/processors/TransactionActionProcessor.spec.ts b/src/application/use-cases/processors/TransactionActionProcessor.spec.ts new file mode 100644 index 0000000..a460eee --- /dev/null +++ b/src/application/use-cases/processors/TransactionActionProcessor.spec.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TransactionActionProcessor } from "./TransactionActionProcessor"; + +const user = { id: "u1", telegramId: "123", payday: 1, monthlyIncome: 50000 }; +const expenseRow = { + id: "e1", + userId: "u1", + amount: 200, + bucket: "WANTS", + categoryId: "c1", + note: "chai", + batchId: null, +}; + +describe("TransactionActionProcessor", () => { + let expenseRepository: any; + let incomeRepository: any; + let categoryRepository: any; + let budgetConfigRepository: any; + let parseLogRepository: any; + let messageService: any; + let processor: TransactionActionProcessor; + + beforeEach(() => { + vi.clearAllMocks(); + expenseRepository = { + findById: vi.fn().mockResolvedValue(expenseRow), + softDelete: vi.fn().mockResolvedValue(undefined), + restore: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + sumByBucketForMonth: vi.fn().mockResolvedValue(0), + }; + incomeRepository = { sumForMonth: vi.fn().mockResolvedValue(0) }; + categoryRepository = { + findById: vi + .fn() + .mockResolvedValue({ id: "c1", name: "Food", bucket: "WANTS" }), + findByNameForUser: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "c2", name: "coffee" }), + }; + budgetConfigRepository = { + findByUserId: vi + .fn() + .mockResolvedValue({ needsPct: 50, wantsPct: 30, savingsPct: 20 }), + }; + parseLogRepository = { findById: vi.fn().mockResolvedValue(null) }; + messageService = { + sendMessage: vi.fn().mockResolvedValue("m1"), + sendInteractiveMessage: vi.fn().mockResolvedValue("m2"), + editInteractiveMessage: vi.fn().mockResolvedValue(undefined), + }; + processor = new TransactionActionProcessor( + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + parseLogRepository, + messageService, + ); + }); + + it("handles only txn: callbacks", () => { + expect(processor.canHandle({ textMessage: "txn:del:e:e1:y" } as any)).toBe( + true, + ); + expect(processor.canHandle({ textMessage: "bkt:log1:WANTS" } as any)).toBe( + false, + ); + }); + + it("deletes on a confirmed delete and offers restore", async () => { + const out = await processor.process({ + user, + platformUserId: "123", + textMessage: "txn:del:e:e1:y", + callbackMessageId: "p1", + } as any); + + expect(expenseRepository.softDelete).toHaveBeenCalledWith("e1"); + expect(out.toast).toBe("Deleted โœ”"); + const [, , , rows] = messageService.editInteractiveMessage.mock.calls[0]; + expect(rows[0][0].id).toBe("txn:restore:e:e1"); + }); + + it("keeps the entry when delete is declined", async () => { + const out = await processor.process({ + user, + platformUserId: "123", + textMessage: "txn:del:e:e1:n", + callbackMessageId: "p1", + } as any); + + expect(expenseRepository.softDelete).not.toHaveBeenCalled(); + expect(out.toast).toBe("Kept"); + }); + + it("applies a staged edit on confirm", async () => { + parseLogRepository.findById.mockResolvedValue({ + id: "log1", + parsed: { + action: "txn-edit", + kind: "expense", + targetId: "e1", + amount: 250, + }, + }); + + const out = await processor.process({ + user, + platformUserId: "123", + textMessage: "txn:edit:log1:y", + callbackMessageId: "p1", + } as any); + + expect(expenseRepository.update).toHaveBeenCalledWith( + "e1", + expect.objectContaining({ amount: 250 }), + ); + expect(out.toast).toBe("Updated โœ”"); + }); + + it("restores a soft-deleted entry", async () => { + const out = await processor.process({ + user, + platformUserId: "123", + textMessage: "txn:restore:e:e1", + callbackMessageId: "p1", + } as any); + + expect(expenseRepository.restore).toHaveBeenCalledWith("e1"); + expect(out.toast).toBe("Restored โœ”"); + }); + + it("shows an expired notice when the staged edit is gone", async () => { + parseLogRepository.findById.mockResolvedValue(null); + + const out = await processor.process({ + user, + platformUserId: "123", + textMessage: "txn:edit:log1:y", + callbackMessageId: "p1", + } as any); + + expect(expenseRepository.update).not.toHaveBeenCalled(); + expect(out.toast).toBe("Expired"); + expect(messageService.sendMessage.mock.calls[0][0].body).toContain( + "expired", + ); + }); +}); diff --git a/src/application/use-cases/processors/TransactionActionProcessor.ts b/src/application/use-cases/processors/TransactionActionProcessor.ts new file mode 100644 index 0000000..3819b2a --- /dev/null +++ b/src/application/use-cases/processors/TransactionActionProcessor.ts @@ -0,0 +1,224 @@ +import { + MessageProcessor, + ProcessContext, + ProcessOutput, +} from "./MessageProcessor"; +import { IExpenseRepository } from "../../../domain/repositories/IExpenseRepository"; +import { IIncomeRepository } from "../../../domain/repositories/IIncomeRepository"; +import { ICategoryRepository } from "../../../domain/repositories/ICategoryRepository"; +import { IBudgetConfigRepository } from "../../../domain/repositories/IBudgetConfigRepository"; +import { IParseLogRepository } from "../../../domain/repositories/IParseLogRepository"; +import { + IMessagingPlatform, + InlineButtonRows, +} from "../../interfaces/IMessagingPlatform"; +import { isTxnCallback, parseTxnCallback, txnCb } from "../txnCallback"; +import { + TxnActionDeps, + PendingEditPayload, + applyExpenseEdit, + applyIncomeEdit, + deleteBatch, + deleteTransaction, + describeTxn, + restoreBatch, + restoreTransaction, + resolveById, + deleteConfirmKeyboard, + restoreKeyboard, +} from "../transactionActions"; + +const EXPIRED = "That entry expired โ€” please try again."; + +// Handles every `txn:` inline-button callback: the delete/edit confirmations, +// the [๐Ÿ—‘ Delete]/[โœ๏ธ Edit] quick-action buttons, and the restore taps. Runs as a +// pre-parse processor so it short-circuits before the AI parser. +export class TransactionActionProcessor implements MessageProcessor { + private readonly deps: TxnActionDeps; + + constructor( + expenseRepository: IExpenseRepository, + incomeRepository: IIncomeRepository, + categoryRepository: ICategoryRepository, + budgetConfigRepository: IBudgetConfigRepository, + private readonly parseLogRepository: IParseLogRepository, + private readonly messageService: IMessagingPlatform, + ) { + this.deps = { + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + }; + } + + canHandle(context: ProcessContext): boolean { + return isTxnCallback(context.textMessage); + } + + async process(context: ProcessContext): Promise { + const cb = parseTxnCallback(context.textMessage); + if (!cb) return this.expire(context); + const { user } = context; + + switch (cb.action) { + // [๐Ÿ—‘ Delete] quick-action โ†’ show the delete confirmation. + case "askdel": { + const ref = await resolveById(this.deps, user.id, cb.kind, cb.id); + if (!ref) return this.expire(context); + const body = `๐Ÿ—‘ Delete this?\n${await describeTxn(this.deps, ref)}`; + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + deleteConfirmKeyboard(ref), + { replyToMessageId: context.callbackMessageId }, + ); + return { response: body, parsed: UNKNOWN }; + } + + // [๐Ÿ—‘ Delete all] quick-action on a batch summary โ†’ confirm deleting all. + case "askdelbatch": { + const [expenses, incomes] = await Promise.all([ + this.deps.expenseRepository.findByBatchId(cb.batchId, user.id), + this.deps.incomeRepository.findByBatchId(cb.batchId, user.id), + ]); + const count = expenses.length + incomes.length; + if (count === 0) return this.expire(context); + const body = `๐Ÿ—‘ Delete all ${count} ${count === 1 ? "entry" : "entries"} from that message?`; + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + [ + [ + { + id: txnCb.delbatch(cb.batchId, true), + title: "โœ… Yes, delete all", + }, + { id: txnCb.delbatch(cb.batchId, false), title: "โŒ Keep" }, + ], + ], + { replyToMessageId: context.callbackMessageId }, + ); + return { response: body, parsed: UNKNOWN }; + } + + // [โœ๏ธ Edit] quick-action โ†’ nudge to reply with the change. + case "hintedit": { + const body = + "โœ๏ธ Reply to the transaction with the change โ€” e.g. `250`, `groceries`, or `250 groceries`."; + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + [], + { replyToMessageId: context.callbackMessageId }, + ); + return { response: body, parsed: UNKNOWN, toast: "Reply to edit" }; + } + + // Delete confirmation result (single). Also used by the undo prompt. + case "del": { + if (!cb.yes) return this.resolvePrompt(context, "โŒ Kept.", "Kept"); + const ref = await resolveById(this.deps, user.id, cb.kind, cb.id); + if (!ref) return this.expire(context); + const summary = await deleteTransaction(this.deps, user, ref); + return this.resolvePrompt( + context, + summary, + "Deleted โœ”", + restoreKeyboard(ref), + ); + } + + // Delete confirmation result (batch). + case "delbatch": { + if (!cb.yes) return this.resolvePrompt(context, "โŒ Kept.", "Kept"); + const summary = await deleteBatch(this.deps, user, cb.batchId); + return this.resolvePrompt(context, summary, "Deleted โœ”", [ + [{ id: txnCb.restorebatch(cb.batchId), title: "โ†ฉ๏ธ Undo" }], + ]); + } + + // Edit confirmation result. Changes are staged in a ParseLog row. + case "edit": { + if (!cb.yes) + return this.resolvePrompt(context, "โŒ Cancelled.", "Cancelled"); + const log = await this.parseLogRepository.findById(cb.logId); + if (!log) return this.expire(context); + const payload = log.parsed as unknown as PendingEditPayload; + if (payload?.action !== "txn-edit") return this.expire(context); + const ref = await resolveById( + this.deps, + user.id, + payload.kind, + payload.targetId, + ); + if (!ref) return this.expire(context); + + const summary = + ref.kind === "expense" + ? await applyExpenseEdit(this.deps, user, ref.row, { + amount: payload.amount, + categoryName: payload.categoryName, + note: payload.note, + bucket: payload.bucket, + }) + : await applyIncomeEdit(this.deps, ref.row, { + amount: payload.amount, + note: payload.note, + source: payload.source, + }); + return this.resolvePrompt(context, summary, "Updated โœ”"); + } + + case "restore": { + const ref = await resolveById(this.deps, user.id, cb.kind, cb.id); + if (!ref) return this.expire(context); + const summary = await restoreTransaction(this.deps, ref); + return this.resolvePrompt(context, summary, "Restored โœ”"); + } + + case "restorebatch": { + const summary = await restoreBatch(this.deps, user, cb.batchId); + return this.resolvePrompt(context, summary, "Restored โœ”"); + } + } + } + + // Edit the prompt message in place (removing its buttons, or swapping in a + // restore button), and toast the tap. Falls back to a fresh message. + private async resolvePrompt( + context: ProcessContext, + body: string, + toast: string, + rows: InlineButtonRows = [], + ): Promise { + if ( + context.callbackMessageId && + this.messageService.editInteractiveMessage + ) { + await this.messageService.editInteractiveMessage( + context.platformUserId, + context.callbackMessageId, + body, + rows, + ); + } else { + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + rows, + ); + } + return { response: body, parsed: UNKNOWN, toast }; + } + + private async expire(context: ProcessContext): Promise { + await this.messageService.sendMessage({ + to: context.platformUserId, + body: EXPIRED, + }); + return { response: EXPIRED, parsed: UNKNOWN, toast: "Expired" }; + } +} + +const UNKNOWN = { intent: "UNKNOWN" as const, confidence: 1 }; diff --git a/src/application/use-cases/processors/TransactionReplyProcessor.spec.ts b/src/application/use-cases/processors/TransactionReplyProcessor.spec.ts new file mode 100644 index 0000000..0798d5b --- /dev/null +++ b/src/application/use-cases/processors/TransactionReplyProcessor.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TransactionReplyProcessor } from "./TransactionReplyProcessor"; + +const user = { id: "u1", telegramId: "123", payday: 1, monthlyIncome: 50000 }; +const expenseRef = { + kind: "expense" as const, + row: { + id: "e1", + userId: "u1", + amount: 200, + bucket: "WANTS", + categoryId: "c1", + note: "chai", + batchId: null, + }, +}; + +describe("TransactionReplyProcessor", () => { + let expenseRepository: any; + let incomeRepository: any; + let categoryRepository: any; + let budgetConfigRepository: any; + let parseLogRepository: any; + let messageService: any; + let processor: TransactionReplyProcessor; + + beforeEach(() => { + vi.clearAllMocks(); + expenseRepository = {}; + incomeRepository = {}; + categoryRepository = { + findById: vi.fn().mockResolvedValue({ id: "c1", name: "Food" }), + }; + budgetConfigRepository = {}; + parseLogRepository = { + create: vi.fn().mockResolvedValue({ id: "log1" }), + }; + messageService = { + sendMessage: vi.fn().mockResolvedValue("m1"), + sendInteractiveMessage: vi.fn().mockResolvedValue("m2"), + }; + processor = new TransactionReplyProcessor( + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + parseLogRepository, + messageService, + ); + }); + + it("handles only replies to a resolved transaction", () => { + expect( + processor.canHandle({ textMessage: "x", replyTarget: expenseRef } as any), + ).toBe(true); + expect(processor.canHandle({ textMessage: "x" } as any)).toBe(false); + }); + + it("routes a delete-word reply to a delete confirmation", async () => { + await processor.process({ + user, + platformUserId: "123", + textMessage: "delete", + parsed: { intent: "UNKNOWN", confidence: 0.5 }, + replyTarget: expenseRef, + replyToMessageId: "m9", + } as any); + + const [, body, rows] = messageService.sendInteractiveMessage.mock.calls[0]; + expect(body).toContain("Delete this?"); + expect(rows[0][0].id).toBe("txn:del:e:e1:y"); + expect(parseLogRepository.create).not.toHaveBeenCalled(); + }); + + it("stages an edit and asks to confirm before applying", async () => { + await processor.process({ + user, + platformUserId: "123", + textMessage: "250 coffee", + parsed: { + intent: "EXPENSE", + amount: 250, + category: "coffee", + note: "coffee", + confidence: 0.9, + }, + replyTarget: expenseRef, + replyToMessageId: "m9", + } as any); + + const staged = parseLogRepository.create.mock.calls[0][0].parsed; + expect(staged).toMatchObject({ + action: "txn-edit", + kind: "expense", + targetId: "e1", + amount: 250, + categoryName: "coffee", + }); + const [, body, rows] = messageService.sendInteractiveMessage.mock.calls[0]; + expect(body).toContain("Update this?"); + expect(rows[0][0].id).toBe("txn:edit:log1:y"); + }); + + it("refuses to edit a batched entry", async () => { + await processor.process({ + user, + platformUserId: "123", + textMessage: "250", + parsed: { intent: "EXPENSE", amount: 250, confidence: 0.9 }, + replyTarget: { + kind: "expense", + row: { ...expenseRef.row, batchId: "b1" }, + }, + } as any); + + expect(parseLogRepository.create).not.toHaveBeenCalled(); + expect(messageService.sendMessage.mock.calls[0][0].body).toContain( + "multi-entry", + ); + }); + + it("nudges when the reply has neither a change nor a delete word", async () => { + await processor.process({ + user, + platformUserId: "123", + textMessage: "thanks", + parsed: { intent: "UNKNOWN", confidence: 0.5 }, + replyTarget: expenseRef, + } as any); + + expect(messageService.sendInteractiveMessage).not.toHaveBeenCalled(); + expect(messageService.sendMessage.mock.calls[0][0].body).toContain( + "Reply with a new amount", + ); + }); +}); diff --git a/src/application/use-cases/processors/TransactionReplyProcessor.ts b/src/application/use-cases/processors/TransactionReplyProcessor.ts new file mode 100644 index 0000000..b9962b9 --- /dev/null +++ b/src/application/use-cases/processors/TransactionReplyProcessor.ts @@ -0,0 +1,148 @@ +import { Bucket } from "@prisma/client"; +import { + MessageProcessor, + ProcessContext, + ProcessOutput, +} from "./MessageProcessor"; +import { IExpenseRepository } from "../../../domain/repositories/IExpenseRepository"; +import { IIncomeRepository } from "../../../domain/repositories/IIncomeRepository"; +import { ICategoryRepository } from "../../../domain/repositories/ICategoryRepository"; +import { IBudgetConfigRepository } from "../../../domain/repositories/IBudgetConfigRepository"; +import { IParseLogRepository } from "../../../domain/repositories/IParseLogRepository"; +import { IMessagingPlatform } from "../../interfaces/IMessagingPlatform"; +import { + TxnActionDeps, + PendingEditPayload, + describeTxn, + deleteConfirmKeyboard, + editConfirmKeyboard, +} from "../transactionActions"; +import { BUCKET_META, formatMoney, sanitizeMd } from "../budgetMath"; + +const DELETE_WORDS = /\b(delete|remove|undo|scrap|cancel|wrong|mistake|del)\b/; +const MAX_EXPENSE = 10_000_000; +const MAX_INCOME = 1_000_000_000; +const UNKNOWN = { intent: "UNKNOWN" as const, confidence: 1 }; + +// Handles a reply to a transaction's confirmation message. Routes to a delete or +// an edit confirmation prompt (never acts directly). Gated on context.replyTarget, +// which the orchestrator resolves from the replied-to message id. Runs first in +// the post-parse list so a correction like "250 groceries" is treated as an edit, +// not logged as a new expense. +export class TransactionReplyProcessor implements MessageProcessor { + private readonly deps: TxnActionDeps; + + constructor( + expenseRepository: IExpenseRepository, + incomeRepository: IIncomeRepository, + categoryRepository: ICategoryRepository, + budgetConfigRepository: IBudgetConfigRepository, + private readonly parseLogRepository: IParseLogRepository, + private readonly messageService: IMessagingPlatform, + ) { + this.deps = { + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + }; + } + + canHandle(context: ProcessContext): boolean { + return !!context.replyTarget; + } + + async process(context: ProcessContext): Promise { + const ref = context.replyTarget!; + const parsed = context.parsed; + const text = context.textMessage.toLowerCase(); + + // โ”€โ”€ Delete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (DELETE_WORDS.test(text) || parsed?.intent === "UNDO") { + const body = `๐Ÿ—‘ Delete this?\n${await describeTxn(this.deps, ref)}`; + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + deleteConfirmKeyboard(ref), + { replyToMessageId: context.replyToMessageId }, + ); + return { response: body, parsed: UNKNOWN }; + } + + // โ”€โ”€ Edit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const max = ref.kind === "expense" ? MAX_EXPENSE : MAX_INCOME; + const amount = + typeof parsed?.amount === "number" && + parsed.amount > 0 && + parsed.amount <= max + ? parsed.amount + : undefined; + const categoryName = ref.kind === "expense" ? parsed?.category : undefined; + const note = parsed?.note; + const bucket = + ref.kind === "expense" + ? (parsed?.bucket as Bucket | undefined) + : undefined; + + const hasEdit = + amount != null || !!categoryName || note != null || !!bucket; + if (!hasEdit) { + return this.reply( + context, + "Reply with a new amount or category to edit (e.g. `250` or `groceries`), or `delete` to remove.", + ); + } + + // Batched entries can't be individually edited (which one?). + if (ref.row.batchId) { + return this.reply( + context, + "That was part of a multi-entry message. Reply `delete` to remove them, then re-add the corrected one.", + ); + } + + // Stage the change, then confirm before applying. + const payload: PendingEditPayload = { + action: "txn-edit", + kind: ref.kind, + targetId: ref.row.id, + ...(amount != null && { amount }), + ...(categoryName && { categoryName }), + ...(note != null && { note }), + ...(bucket && { bucket }), + ...(ref.kind === "income" && note != null && { source: note }), + }; + const log = await this.parseLogRepository.create({ + rawText: `txn-edit:${ref.kind}:${ref.row.id}`, + parsed: payload as unknown as Record, + confidence: 1, + userId: context.user.id, + }); + + const changeParts: string[] = []; + if (amount != null) changeParts.push(`amount ${formatMoney(amount)}`); + if (categoryName) changeParts.push(`category ${sanitizeMd(categoryName)}`); + if (bucket) changeParts.push(`bucket ${BUCKET_META[bucket].label}`); + if (note != null) changeParts.push(`note "${sanitizeMd(note)}"`); + + const body = `โœ๏ธ Update this?\n${await describeTxn(this.deps, ref)}\nโ†’ ${changeParts.join(", ")}`; + await this.messageService.sendInteractiveMessage( + context.platformUserId, + body, + editConfirmKeyboard(log.id), + { replyToMessageId: context.replyToMessageId }, + ); + return { response: body, parsed: UNKNOWN }; + } + + private async reply( + context: ProcessContext, + body: string, + ): Promise { + await this.messageService.sendMessage({ + to: context.platformUserId, + body, + }); + return { response: body, parsed: UNKNOWN }; + } +} diff --git a/src/application/use-cases/processors/UndoProcessor.spec.ts b/src/application/use-cases/processors/UndoProcessor.spec.ts index c307f21..9e6164a 100644 --- a/src/application/use-cases/processors/UndoProcessor.spec.ts +++ b/src/application/use-cases/processors/UndoProcessor.spec.ts @@ -1,15 +1,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { UndoProcessor } from "./UndoProcessor"; -const user = { id: "u1", telegramId: "123", monthlyIncome: 50000 }; +const user = { id: "u1", telegramId: "123", payday: 1, monthlyIncome: 50000 }; const lastExpense = { id: "e1", amount: 220, bucket: "WANTS", categoryId: "c1", note: "lunch", + batchId: null, + date: new Date(), }; +// UndoProcessor now CONFIRMS instead of deleting: it sends a Yes/No prompt whose +// buttons carry the shared txn:del/txn:delbatch callbacks. The actual delete is +// covered by TransactionActionProcessor.spec. describe("UndoProcessor", () => { let expenseRepository: any; let categoryRepository: any; @@ -22,11 +27,7 @@ describe("UndoProcessor", () => { vi.clearAllMocks(); expenseRepository = { findLastByUserId: vi.fn().mockResolvedValue(lastExpense), - findByConfirmationMessageId: vi.fn().mockResolvedValue(null), softDelete: vi.fn().mockResolvedValue(undefined), - findByBatchId: vi.fn().mockResolvedValue([]), - softDeleteByBatchId: vi.fn().mockResolvedValue(undefined), - sumByBucketForMonth: vi.fn().mockResolvedValue(0), // after delete }; categoryRepository = { findById: vi @@ -38,12 +39,12 @@ describe("UndoProcessor", () => { .fn() .mockResolvedValue({ needsPct: 50, wantsPct: 30, savingsPct: 20 }), }; - messageService = { sendMessage: vi.fn().mockResolvedValue("m1") }; incomeRepository = { - sumForMonth: vi.fn().mockResolvedValue(0), findLastByUserId: vi.fn().mockResolvedValue(null), - findByBatchId: vi.fn().mockResolvedValue([]), - softDeleteByBatchId: vi.fn().mockResolvedValue(undefined), + }; + messageService = { + sendMessage: vi.fn().mockResolvedValue("m1"), + sendInteractiveMessage: vi.fn().mockResolvedValue("m2"), }; processor = new UndoProcessor( expenseRepository, @@ -54,7 +55,7 @@ describe("UndoProcessor", () => { ); }); - it("matches 'undo'/'/undo' and the UNDO intent", () => { + it("matches 'undo'/'/undo' and the UNDO intent, but not replies", () => { const base = { user, platformUserId: "123" }; expect(processor.canHandle({ ...base, textMessage: "undo" } as any)).toBe( true, @@ -69,56 +70,41 @@ describe("UndoProcessor", () => { parsed: { intent: "UNDO", confidence: 0.9 }, } as any), ).toBe(true); + // A reply to a transaction is handled by TransactionReplyProcessor. + expect( + processor.canHandle({ + ...base, + textMessage: "undo", + replyTarget: { kind: "expense", row: lastExpense }, + } as any), + ).toBe(false); expect( processor.canHandle({ ...base, textMessage: "lunch 30" } as any), ).toBe(false); }); - it("removes the last expense and shows restored budget", async () => { + it("confirms before removing the latest expense (does not delete)", async () => { await processor.process({ user, platformUserId: "123", textMessage: "undo", } as any); - expect(expenseRepository.softDelete).toHaveBeenCalledWith("e1"); - const body = messageService.sendMessage.mock.calls[0][0].body; - expect(body).toContain("Removed: โ‚น220 Food"); - expect(body).toContain("Wants left this month: โ‚น15,000"); - }); - - it("targets the replied-to expense when a confirmation reply is undone", async () => { - expenseRepository.findByConfirmationMessageId.mockResolvedValue({ - ...lastExpense, - id: "e9", - }); - - await processor.process({ - user, - platformUserId: "123", - textMessage: "undo", - replyToMessageId: "msg-9", - } as any); - - expect(expenseRepository.findByConfirmationMessageId).toHaveBeenCalledWith( - "msg-9", - "u1", - ); - expect(expenseRepository.softDelete).toHaveBeenCalledWith("e9"); + // No deletion yet โ€” only a confirmation prompt. + expect(expenseRepository.softDelete).not.toHaveBeenCalled(); + const [, body, rows] = messageService.sendInteractiveMessage.mock.calls[0]; + expect(body).toContain("Undo this?"); + expect(body).toContain("Food"); + // The Yes button carries the shared delete callback for this expense. + expect(rows[0][0].id).toBe("txn:del:e:e1:y"); + expect(rows[0][1].id).toBe("txn:del:e:e1:n"); }); - it("undoes the whole batch when the last entry has a batchId", async () => { + it("uses the batch delete callback when the latest entry is batched", async () => { expenseRepository.findLastByUserId.mockResolvedValue({ ...lastExpense, batchId: "b1", }); - expenseRepository.findByBatchId.mockResolvedValue([ - { id: "e1", amount: 30, bucket: "WANTS" }, - { id: "e2", amount: 80, bucket: "NEEDS" }, - ]); - incomeRepository.findByBatchId.mockResolvedValue([ - { id: "i1", amount: 50000 }, - ]); await processor.process({ user, @@ -126,20 +112,11 @@ describe("UndoProcessor", () => { textMessage: "undo", } as any); - expect(expenseRepository.softDeleteByBatchId).toHaveBeenCalledWith( - "b1", - "u1", - ); - expect(incomeRepository.softDeleteByBatchId).toHaveBeenCalledWith( - "b1", - "u1", - ); - expect(expenseRepository.softDelete).not.toHaveBeenCalled(); - const body = messageService.sendMessage.mock.calls[0][0].body; - expect(body).toContain("Removed 3 entries"); + const rows = messageService.sendInteractiveMessage.mock.calls[0][2]; + expect(rows[0][0].id).toBe("txn:delbatch:b1:y"); }); - it("undoes a single income when it is the most recent entry", async () => { + it("confirms the latest income when it is the most recent entry", async () => { expenseRepository.findLastByUserId.mockResolvedValue(null); incomeRepository.findLastByUserId.mockResolvedValue({ id: "i9", @@ -148,7 +125,6 @@ describe("UndoProcessor", () => { date: new Date(), batchId: null, }); - incomeRepository.softDelete = vi.fn().mockResolvedValue(undefined); await processor.process({ user, @@ -156,13 +132,11 @@ describe("UndoProcessor", () => { textMessage: "undo", } as any); - expect(incomeRepository.softDelete).toHaveBeenCalledWith("i9"); - expect(messageService.sendMessage.mock.calls[0][0].body).toContain( - "Removed income: โ‚น5,000", - ); + const rows = messageService.sendInteractiveMessage.mock.calls[0][2]; + expect(rows[0][0].id).toBe("txn:del:i:i9:y"); }); - it("says there is nothing to undo when no expense exists", async () => { + it("says there is nothing to undo when no entry exists", async () => { expenseRepository.findLastByUserId.mockResolvedValue(null); await processor.process({ @@ -171,7 +145,7 @@ describe("UndoProcessor", () => { textMessage: "undo", } as any); - expect(expenseRepository.softDelete).not.toHaveBeenCalled(); + expect(messageService.sendInteractiveMessage).not.toHaveBeenCalled(); expect(messageService.sendMessage.mock.calls[0][0].body).toContain( "Nothing to undo", ); diff --git a/src/application/use-cases/processors/UndoProcessor.ts b/src/application/use-cases/processors/UndoProcessor.ts index ac26e8b..aa9c876 100644 --- a/src/application/use-cases/processors/UndoProcessor.ts +++ b/src/application/use-cases/processors/UndoProcessor.ts @@ -1,4 +1,3 @@ -import { Bucket, Expense, Income } from "@prisma/client"; import { MessageProcessor, ProcessContext, @@ -10,34 +9,37 @@ import { IBudgetConfigRepository } from "../../../domain/repositories/IBudgetCon import { IIncomeRepository } from "../../../domain/repositories/IIncomeRepository"; import { IMessagingPlatform } from "../../interfaces/IMessagingPlatform"; import { - BUCKET_META, - bucketBudget, - currentBudgetPeriod, - effectiveMonthlyIncome, - formatMoney, - sanitizeMd, -} from "../budgetMath"; - -const DEFAULT_SPLIT = { needsPct: 50, wantsPct: 30, savingsPct: 20 }; - -// The entry an undo anchors on, tagged by table so we know how to delete it. -type AnchorEntry = - | { kind: "expense"; row: Expense; batchId: string | null } - | { kind: "income"; row: Income; batchId: string | null }; - -// Removes an expense and restores the budget. Targets the replied-to expense -// when the user replies to its confirmation, otherwise the most recent one. -// Triggers on plain "undo"/"/undo" (pre-AI) or the UNDO intent (post-AI). + TxnActionDeps, + TransactionRef, + describeTxn, + undoConfirmKeyboard, +} from "../transactionActions"; + +// Asks to confirm before removing an entry. Triggers on plain "undo"/"/undo" +// (pre-AI) or the UNDO intent (post-AI) โ€” but NOT on replies, which the +// TransactionReplyProcessor handles against the specific replied-to transaction. +// The actual delete happens in TransactionActionProcessor when the user taps Yes +// (the confirm keyboard emits the shared txn:del/txn:delbatch callbacks). export class UndoProcessor implements MessageProcessor { + private readonly deps: TxnActionDeps; + constructor( - private readonly expenseRepository: IExpenseRepository, - private readonly categoryRepository: ICategoryRepository, - private readonly budgetConfigRepository: IBudgetConfigRepository, - private readonly incomeRepository: IIncomeRepository, + expenseRepository: IExpenseRepository, + categoryRepository: ICategoryRepository, + budgetConfigRepository: IBudgetConfigRepository, + incomeRepository: IIncomeRepository, private readonly messageService: IMessagingPlatform, - ) {} + ) { + this.deps = { + expenseRepository, + incomeRepository, + categoryRepository, + budgetConfigRepository, + }; + } canHandle(context: ProcessContext): boolean { + if (context.replyTarget) return false; const normalized = context.textMessage .trim() .toLowerCase() @@ -47,152 +49,34 @@ export class UndoProcessor implements MessageProcessor { } async process(context: ProcessContext): Promise { - const { user, platformUserId, replyToMessageId } = context; - - // Resolve what to undo. A replied-to confirmation targets that expense; - // otherwise the most recent entry across expenses and income. - const repliedExpense = replyToMessageId - ? await this.expenseRepository.findByConfirmationMessageId( - replyToMessageId, - user.id, - ) - : null; - - const anchor: AnchorEntry | null = repliedExpense - ? { - kind: "expense", - row: repliedExpense, - batchId: repliedExpense.batchId, - } - : await this.pickLatestEntry(user.id); - - if (!anchor) { - return this.reply( - platformUserId, - "Nothing to undo yet โ€” log a spend first.", - ); - } - - // A batchId means this entry came from a multi-transaction message: undo - // the whole batch. Otherwise remove just the one entry (today's behavior). - if (anchor.batchId) { - return this.undoBatch(context, anchor.batchId); - } - return anchor.kind === "expense" - ? this.undoSingleExpense(context, anchor.row) - : this.undoSingleIncome(context, anchor.row); - } - - // The most recent non-deleted entry across expenses + income, tagged by kind. - private async pickLatestEntry(userId: string): Promise { - const expense = await this.expenseRepository.findLastByUserId(userId); - const income = await this.incomeRepository.findLastByUserId(userId); - if (!expense && !income) return null; - if (expense && (!income || expense.date >= income.date)) { - return { kind: "expense", row: expense, batchId: expense.batchId }; - } - return { kind: "income", row: income!, batchId: income!.batchId }; - } - - private async undoSingleExpense( - context: ProcessContext, - target: Expense, - ): Promise { - const { user, platformUserId } = context; - await this.expenseRepository.softDelete(target.id); - - const remaining = await this.bucketRemaining(user, target.bucket); - const label = await this.describe(target.categoryId, target.note); - const meta = BUCKET_META[target.bucket]; - const body = `โ†ฉ๏ธ Removed: ${formatMoney(Number(target.amount))} ${label}. ${meta.label} left this month: ${formatMoney(remaining)}.`; - return this.reply(platformUserId, body); - } - - private async undoSingleIncome( - context: ProcessContext, - target: Income, - ): Promise { const { platformUserId } = context; - await this.incomeRepository.softDelete(target.id); - const label = target.note ? ` (${sanitizeMd(target.note)})` : ""; - const body = `โ†ฉ๏ธ Removed income: ${formatMoney(Number(target.amount))}${label}.`; - return this.reply(platformUserId, body); - } - - private async undoBatch( - context: ProcessContext, - batchId: string, - ): Promise { - const { user, platformUserId } = context; - const expenses = await this.expenseRepository.findByBatchId( - batchId, - user.id, - ); - const incomes = await this.incomeRepository.findByBatchId(batchId, user.id); - - await this.expenseRepository.softDeleteByBatchId(batchId, user.id); - await this.incomeRepository.softDeleteByBatchId(batchId, user.id); - - const count = expenses.length + incomes.length; - const total = - expenses.reduce((s, e) => s + Number(e.amount), 0) + - incomes.reduce((s, i) => s + Number(i.amount), 0); - - const lines: string[] = [ - `โ†ฉ๏ธ Removed ${count} ${count === 1 ? "entry" : "entries"} (${formatMoney(total)} total).`, - ]; - // Refresh each affected bucket's remaining budget. - const buckets = [...new Set(expenses.map((e) => e.bucket))]; - for (const bucket of buckets) { - const remaining = await this.bucketRemaining(user, bucket); - const meta = BUCKET_META[bucket]; - lines.push( - `${meta.emoji} ${meta.label} left this month: ${formatMoney(remaining)}.`, - ); + const ref = await this.pickLatestEntry(context.user.id); + if (!ref) { + const body = "Nothing to undo yet โ€” log a spend first."; + await this.messageService.sendMessage({ to: platformUserId, body }); + return { response: body, parsed: { intent: "UNDO", confidence: 1 } }; } - return this.reply(platformUserId, lines.join("\n")); - } - - // Remaining budget for a bucket this cycle (sum already excludes deleted rows). - private async bucketRemaining( - user: ProcessContext["user"], - bucket: Bucket, - ): Promise { - const { start, end } = currentBudgetPeriod(user.payday); - const spent = await this.expenseRepository.sumByBucketForMonth( - user.id, - bucket, - start, - end, - ); - const config = - (await this.budgetConfigRepository.findByUserId(user.id)) ?? - DEFAULT_SPLIT; - const income = effectiveMonthlyIncome( - Number(user.monthlyIncome ?? 0), - await this.incomeRepository.sumForMonth(user.id, start, end), + const body = `โ†ฉ๏ธ Undo this?\n${await describeTxn(this.deps, ref)}`; + await this.messageService.sendInteractiveMessage( + platformUserId, + body, + undoConfirmKeyboard(ref), ); - return bucketBudget(income, config, bucket) - spent; + return { response: body, parsed: { intent: "UNDO", confidence: 1 } }; } - private async describe( - categoryId: string | null, - note: string | null, - ): Promise { - if (categoryId) { - const category = await this.categoryRepository.findById(categoryId); - if (category) return sanitizeMd(category.name); + // The most recent non-deleted entry across expenses + income. + private async pickLatestEntry( + userId: string, + ): Promise { + const expense = await this.deps.expenseRepository.findLastByUserId(userId); + const income = await this.deps.incomeRepository.findLastByUserId(userId); + if (!expense && !income) return null; + if (expense && (!income || expense.date >= income.date)) { + return { kind: "expense", row: expense }; } - return note ? sanitizeMd(note) : "expense"; - } - - private async reply( - platformUserId: string, - body: string, - ): Promise { - await this.messageService.sendMessage({ to: platformUserId, body }); - return { response: body, parsed: { intent: "UNDO", confidence: 1 } }; + return { kind: "income", row: income! }; } } diff --git a/src/application/use-cases/transactionActions.ts b/src/application/use-cases/transactionActions.ts new file mode 100644 index 0000000..baa380d --- /dev/null +++ b/src/application/use-cases/transactionActions.ts @@ -0,0 +1,346 @@ +import { Bucket, Expense, Income, User } from "@prisma/client"; +import { IExpenseRepository } from "../../domain/repositories/IExpenseRepository"; +import { IIncomeRepository } from "../../domain/repositories/IIncomeRepository"; +import { ICategoryRepository } from "../../domain/repositories/ICategoryRepository"; +import { IBudgetConfigRepository } from "../../domain/repositories/IBudgetConfigRepository"; +import { InlineButtonRows } from "../interfaces/IMessagingPlatform"; +import { resolveExpenseCategory } from "./expenseFlow"; +import { TxnKind, txnCb } from "./txnCallback"; +import { + BUCKET_META, + bucketBudget, + currentBudgetPeriod, + effectiveMonthlyIncome, + formatMoney, + sanitizeMd, +} from "./budgetMath"; + +const DEFAULT_SPLIT = { needsPct: 50, wantsPct: 30, savingsPct: 20 }; + +export interface TxnActionDeps { + expenseRepository: IExpenseRepository; + incomeRepository: IIncomeRepository; + categoryRepository: ICategoryRepository; + budgetConfigRepository: IBudgetConfigRepository; +} + +// A resolved transaction the user is acting on, tagged by table. +export type TransactionRef = + | { kind: "expense"; row: Expense } + | { kind: "income"; row: Income }; + +// The edit staged in a ParseLog row between the confirm prompt and the tap. +export interface PendingEditPayload { + action: "txn-edit"; + kind: TxnKind; + targetId: string; + amount?: number; + categoryName?: string; + note?: string | null; + bucket?: Bucket; + source?: string; +} + +// โ”€โ”€ Resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function resolveByConfirmationMessage( + deps: TxnActionDeps, + userId: string, + messageId: string, +): Promise { + const expense = await deps.expenseRepository.findByConfirmationMessageId( + messageId, + userId, + ); + if (expense) return { kind: "expense", row: expense }; + const income = await deps.incomeRepository.findByConfirmationMessageId( + messageId, + userId, + ); + if (income) return { kind: "income", row: income }; + return null; +} + +// Resolve by kind + id (used by callbacks). Returns the row even when +// soft-deleted, so restore can find it; ownership is enforced. +export async function resolveById( + deps: TxnActionDeps, + userId: string, + kind: TxnKind, + id: string, +): Promise { + if (kind === "expense") { + const row = await deps.expenseRepository.findById(id); + return row && row.userId === userId ? { kind: "expense", row } : null; + } + const row = await deps.incomeRepository.findById(id); + return row && row.userId === userId ? { kind: "income", row } : null; +} + +// โ”€โ”€ Descriptions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// Compact one-line description, e.g. "โ‚น200 ยท Wants ยท Chai" or "income โ‚น50,000 ยท salary". +export async function describeTxn( + deps: TxnActionDeps, + ref: TransactionRef, +): Promise { + if (ref.kind === "expense") { + const e = ref.row; + const label = await expenseLabel(deps, e); + return `${formatMoney(Number(e.amount))} ยท ${BUCKET_META[e.bucket].label} ยท ${sanitizeMd(label)}`; + } + const i = ref.row; + const label = i.note ?? i.source ?? "income"; + return `income ${formatMoney(Number(i.amount))} ยท ${sanitizeMd(label)}`; +} + +async function expenseLabel(deps: TxnActionDeps, e: Expense): Promise { + if (e.categoryId) { + const cat = await deps.categoryRepository.findById(e.categoryId); + if (cat) return cat.name; + } + return e.note ?? "expense"; +} + +// โ”€โ”€ Delete / restore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function deleteTransaction( + deps: TxnActionDeps, + user: User, + ref: TransactionRef, +): Promise { + if (ref.row.batchId) return deleteBatch(deps, user, ref.row.batchId); + if (ref.kind === "expense") { + await deps.expenseRepository.softDelete(ref.row.id); + const remaining = await bucketRemaining(deps, user, ref.row.bucket); + const label = await expenseLabel(deps, ref.row); + const meta = BUCKET_META[ref.row.bucket]; + return `๐Ÿ—‘ Removed ${formatMoney(Number(ref.row.amount))} ${sanitizeMd(label)}. ${meta.label} left this month: ${formatMoney(remaining)}.`; + } + await deps.incomeRepository.softDelete(ref.row.id); + const label = ref.row.note ? ` (${sanitizeMd(ref.row.note)})` : ""; + return `๐Ÿ—‘ Removed income ${formatMoney(Number(ref.row.amount))}${label}.`; +} + +export async function deleteBatch( + deps: TxnActionDeps, + user: User, + batchId: string, +): Promise { + const expenses = await deps.expenseRepository.findByBatchId(batchId, user.id); + const incomes = await deps.incomeRepository.findByBatchId(batchId, user.id); + await deps.expenseRepository.softDeleteByBatchId(batchId, user.id); + await deps.incomeRepository.softDeleteByBatchId(batchId, user.id); + + const count = expenses.length + incomes.length; + const total = + expenses.reduce((s, e) => s + Number(e.amount), 0) + + incomes.reduce((s, i) => s + Number(i.amount), 0); + const lines = [ + `๐Ÿ—‘ Removed ${count} ${count === 1 ? "entry" : "entries"} (${formatMoney(total)} total).`, + ]; + for (const bucket of [...new Set(expenses.map((e) => e.bucket))]) { + const remaining = await bucketRemaining(deps, user, bucket); + const meta = BUCKET_META[bucket]; + lines.push( + `${meta.emoji} ${meta.label} left this month: ${formatMoney(remaining)}.`, + ); + } + return lines.join("\n"); +} + +export async function restoreTransaction( + deps: TxnActionDeps, + ref: TransactionRef, +): Promise { + if (ref.kind === "expense") { + await deps.expenseRepository.restore(ref.row.id); + const label = await expenseLabel(deps, ref.row); + return `โœ… Restored ${formatMoney(Number(ref.row.amount))} ${sanitizeMd(label)}.`; + } + await deps.incomeRepository.restore(ref.row.id); + const label = ref.row.note ? ` (${sanitizeMd(ref.row.note)})` : ""; + return `โœ… Restored income ${formatMoney(Number(ref.row.amount))}${label}.`; +} + +export async function restoreBatch( + deps: TxnActionDeps, + user: User, + batchId: string, +): Promise { + await deps.expenseRepository.restoreByBatchId(batchId, user.id); + await deps.incomeRepository.restoreByBatchId(batchId, user.id); + const count = + (await deps.expenseRepository.findByBatchId(batchId, user.id)).length + + (await deps.incomeRepository.findByBatchId(batchId, user.id)).length; + return `โœ… Restored ${count} ${count === 1 ? "entry" : "entries"}.`; +} + +// โ”€โ”€ Edit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface ExpenseEditChanges { + amount?: number | undefined; + categoryName?: string | undefined; + note?: string | null | undefined; + bucket?: Bucket | undefined; +} + +export async function applyExpenseEdit( + deps: TxnActionDeps, + user: User, + expense: Expense, + changes: ExpenseEditChanges, +): Promise { + const data: { + amount?: number; + bucket?: Bucket; + categoryId?: string | null; + note?: string | null; + } = {}; + let bucket = expense.bucket; + let categoryLabel: string; + + if (changes.categoryName) { + const resolved = await resolveExpenseCategory( + deps.categoryRepository, + user.id, + changes.bucket ?? expense.bucket, + changes.categoryName, + ); + data.categoryId = resolved.categoryId ?? null; + bucket = resolved.bucket; + data.bucket = bucket; + categoryLabel = resolved.categoryLabel; + } else { + if (changes.bucket) { + bucket = changes.bucket; + data.bucket = bucket; + } + categoryLabel = await expenseLabel(deps, expense); + } + + if (changes.amount != null) data.amount = changes.amount; + if (changes.note !== undefined) data.note = changes.note; + + await deps.expenseRepository.update(expense.id, data); + + const amount = changes.amount ?? Number(expense.amount); + const remaining = await bucketRemaining(deps, user, bucket); + const meta = BUCKET_META[bucket]; + return `โœ… Updated โ†’ ${formatMoney(amount)} ${meta.label} ยท ${sanitizeMd(categoryLabel)}\n${meta.label} left this month: ${formatMoney(remaining)}`; +} + +export interface IncomeEditChanges { + amount?: number | undefined; + note?: string | null | undefined; + source?: string | null | undefined; +} + +export async function applyIncomeEdit( + deps: TxnActionDeps, + income: Income, + changes: IncomeEditChanges, +): Promise { + const data: { + amount?: number; + source?: string | null; + note?: string | null; + } = {}; + if (changes.amount != null) data.amount = changes.amount; + // Note doubles as source, mirroring how income is first recorded. + if (changes.note !== undefined) { + data.note = changes.note; + data.source = changes.source ?? changes.note; + } else if (changes.source !== undefined) { + data.source = changes.source; + } + + await deps.incomeRepository.update(income.id, data); + + const amount = changes.amount ?? Number(income.amount); + const noteVal = changes.note !== undefined ? changes.note : income.note; + const label = noteVal ? ` (${sanitizeMd(noteVal)})` : ""; + return `โœ… Updated income โ†’ ${formatMoney(amount)}${label}`; +} + +// โ”€โ”€ Keyboards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function deleteConfirmKeyboard(ref: TransactionRef): InlineButtonRows { + const b = ref.row.batchId; + return [ + [ + { + id: b ? txnCb.delbatch(b, true) : txnCb.del(ref.kind, ref.row.id, true), + title: "โœ… Yes, delete", + }, + { + id: b + ? txnCb.delbatch(b, false) + : txnCb.del(ref.kind, ref.row.id, false), + title: "โŒ Keep", + }, + ], + ]; +} + +export function undoConfirmKeyboard(ref: TransactionRef): InlineButtonRows { + const b = ref.row.batchId; + return [ + [ + { + id: b ? txnCb.delbatch(b, true) : txnCb.del(ref.kind, ref.row.id, true), + title: "โœ… Yes", + }, + { + id: b + ? txnCb.delbatch(b, false) + : txnCb.del(ref.kind, ref.row.id, false), + title: "โŒ No", + }, + ], + ]; +} + +export function editConfirmKeyboard(logId: string): InlineButtonRows { + return [ + [ + { id: txnCb.edit(logId, true), title: "โœ… Yes" }, + { id: txnCb.edit(logId, false), title: "โŒ Cancel" }, + ], + ]; +} + +export function restoreKeyboard(ref: TransactionRef): InlineButtonRows { + const b = ref.row.batchId; + return [ + [ + { + id: b ? txnCb.restorebatch(b) : txnCb.restore(ref.kind, ref.row.id), + title: "โ†ฉ๏ธ Undo", + }, + ], + ]; +} + +// โ”€โ”€ Budget helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function bucketRemaining( + deps: TxnActionDeps, + user: User, + bucket: Bucket, +): Promise { + const { start, end } = currentBudgetPeriod(user.payday); + const spent = await deps.expenseRepository.sumByBucketForMonth( + user.id, + bucket, + start, + end, + ); + const config = + (await deps.budgetConfigRepository.findByUserId(user.id)) ?? DEFAULT_SPLIT; + const income = effectiveMonthlyIncome( + Number(user.monthlyIncome ?? 0), + await deps.incomeRepository.sumForMonth(user.id, start, end), + ); + return bucketBudget(income, config, bucket) - spent; +} diff --git a/src/application/use-cases/txnCallback.ts b/src/application/use-cases/txnCallback.ts new file mode 100644 index 0000000..f8ef370 --- /dev/null +++ b/src/application/use-cases/txnCallback.ts @@ -0,0 +1,97 @@ +// callback_data grammar for transaction actions (namespace "txn:"). +// Kept dependency-free so both expenseFlow (button builders) and +// transactionActions/processors (parser) can share it without a cycle. +// +// txn:askdel:: [๐Ÿ—‘ Delete] button โ†’ show delete confirm +// txn:hintedit:: [โœ๏ธ Edit] button โ†’ nudge to reply +// txn:del::: delete confirm result (also used by undo) +// txn:delbatch:: batch delete confirm result +// txn:edit:: edit confirm result (changes staged in ParseLog) +// txn:restore:: undo a single delete +// txn:restorebatch: undo a batch delete +// +// `k` is a 1-char kind code ("e"=expense, "i"=income) so ids stay well under +// Telegram's 64-byte callback_data limit. cuids/uuids never contain ":". + +export type TxnKind = "expense" | "income"; + +const KIND_CODE: Record = { expense: "e", income: "i" }; +const CODE_KIND: Record = { e: "expense", i: "income" }; + +export type TxnCallback = + | { action: "askdel"; kind: TxnKind; id: string } + | { action: "askdelbatch"; batchId: string } + | { action: "hintedit"; kind: TxnKind; id: string } + | { action: "del"; kind: TxnKind; id: string; yes: boolean } + | { action: "delbatch"; batchId: string; yes: boolean } + | { action: "edit"; logId: string; yes: boolean } + | { action: "restore"; kind: TxnKind; id: string } + | { action: "restorebatch"; batchId: string }; + +export const txnCb = { + askdel: (kind: TxnKind, id: string) => `txn:askdel:${KIND_CODE[kind]}:${id}`, + askdelbatch: (batchId: string) => `txn:askdelbatch:${batchId}`, + hintedit: (kind: TxnKind, id: string) => + `txn:hintedit:${KIND_CODE[kind]}:${id}`, + del: (kind: TxnKind, id: string, yes: boolean) => + `txn:del:${KIND_CODE[kind]}:${id}:${yes ? "y" : "n"}`, + delbatch: (batchId: string, yes: boolean) => + `txn:delbatch:${batchId}:${yes ? "y" : "n"}`, + edit: (logId: string, yes: boolean) => `txn:edit:${logId}:${yes ? "y" : "n"}`, + restore: (kind: TxnKind, id: string) => + `txn:restore:${KIND_CODE[kind]}:${id}`, + restorebatch: (batchId: string) => `txn:restorebatch:${batchId}`, +}; + +export function isTxnCallback(data: string): boolean { + return data.startsWith("txn:"); +} + +export function parseTxnCallback(data: string): TxnCallback | null { + if (!data.startsWith("txn:")) return null; + const p = data.split(":"); + const action = p[1]; + switch (action) { + case "askdel": + case "hintedit": { + const kind = CODE_KIND[p[2] ?? ""]; + const id = p[3]; + if (!kind || !id) return null; + return { action, kind, id }; + } + case "askdelbatch": { + const batchId = p[2]; + if (!batchId) return null; + return { action, batchId }; + } + case "del": { + const kind = CODE_KIND[p[2] ?? ""]; + const id = p[3]; + if (!kind || !id) return null; + return { action, kind, id, yes: p[4] === "y" }; + } + case "delbatch": { + const batchId = p[2]; + if (!batchId) return null; + return { action, batchId, yes: p[3] === "y" }; + } + case "edit": { + const logId = p[2]; + if (!logId) return null; + return { action, logId, yes: p[3] === "y" }; + } + case "restore": { + const kind = CODE_KIND[p[2] ?? ""]; + const id = p[3]; + if (!kind || !id) return null; + return { action, kind, id }; + } + case "restorebatch": { + const batchId = p[2]; + if (!batchId) return null; + return { action, batchId }; + } + default: + return null; + } +} diff --git a/src/data/messaging/TelegramMessageService.ts b/src/data/messaging/TelegramMessageService.ts index c387848..7b45200 100644 --- a/src/data/messaging/TelegramMessageService.ts +++ b/src/data/messaging/TelegramMessageService.ts @@ -2,6 +2,7 @@ import { IMessagingPlatform, SendMessagePayload, InlineButtonRows, + SendInteractiveOptions, } from "../../application/interfaces/IMessagingPlatform"; import { env } from "../../config/env"; @@ -42,6 +43,7 @@ export class TelegramMessageService implements IMessagingPlatform { to: string, body: string, rows: InlineButtonRows, + opts?: SendInteractiveOptions, ): Promise { const inline_keyboard = rows.map((row) => row.map((b) => ({ text: b.title, callback_data: b.id })), @@ -54,6 +56,10 @@ export class TelegramMessageService implements IMessagingPlatform { text: body, parse_mode: "Markdown", reply_markup: { inline_keyboard }, + ...(opts?.replyToMessageId && { + reply_to_message_id: Number(opts.replyToMessageId), + allow_sending_without_reply: true, + }), }), }); if (!res.ok) { @@ -93,11 +99,17 @@ export class TelegramMessageService implements IMessagingPlatform { } } - async acknowledgeInteraction(callbackQueryId: string): Promise { + async acknowledgeInteraction( + callbackQueryId: string, + text?: string, + ): Promise { await fetch(`${this.base}/answerCallbackQuery`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ callback_query_id: callbackQueryId }), + body: JSON.stringify({ + callback_query_id: callbackQueryId, + ...(text && { text }), + }), }).catch(() => {}); } } diff --git a/src/data/repositories/PrismaExpenseRepository.ts b/src/data/repositories/PrismaExpenseRepository.ts index b5238ec..7a95212 100644 --- a/src/data/repositories/PrismaExpenseRepository.ts +++ b/src/data/repositories/PrismaExpenseRepository.ts @@ -1,6 +1,7 @@ import { Bucket, Expense, Prisma, PrismaClient } from "@prisma/client"; import { CreateExpenseDTO, + UpdateExpenseDTO, IExpenseRepository, RecentExpenseFilter, RecentExpenseRow, @@ -57,6 +58,18 @@ export class PrismaExpenseRepository implements IExpenseRepository { }); } + async update(id: string, data: UpdateExpenseDTO): Promise { + await this.prisma.expense.update({ + where: { id }, + data: { + ...(data.amount !== undefined && { amount: data.amount }), + ...(data.bucket !== undefined && { bucket: data.bucket }), + ...(data.categoryId !== undefined && { categoryId: data.categoryId }), + ...(data.note !== undefined && { note: data.note }), + }, + }); + } + async findByBatchId(batchId: string, userId: string): Promise { return this.prisma.expense.findMany({ where: { batchId, userId, isDeleted: false }, @@ -70,6 +83,20 @@ export class PrismaExpenseRepository implements IExpenseRepository { }); } + async restore(id: string): Promise { + await this.prisma.expense.update({ + where: { id }, + data: { isDeleted: false, deletedAt: null }, + }); + } + + async restoreByBatchId(batchId: string, userId: string): Promise { + await this.prisma.expense.updateMany({ + where: { batchId, userId, isDeleted: true }, + data: { isDeleted: false, deletedAt: null }, + }); + } + async sumByBucketForMonth( userId: string, bucket: Bucket, diff --git a/src/data/repositories/PrismaIncomeRepository.ts b/src/data/repositories/PrismaIncomeRepository.ts index b5ca666..2792d43 100644 --- a/src/data/repositories/PrismaIncomeRepository.ts +++ b/src/data/repositories/PrismaIncomeRepository.ts @@ -1,6 +1,7 @@ import { Income, PrismaClient } from "@prisma/client"; import { CreateIncomeDTO, + UpdateIncomeDTO, IIncomeRepository, } from "../../domain/repositories/IIncomeRepository"; import { TxClient } from "../../domain/repositories/UnitOfWork"; @@ -38,6 +39,10 @@ export class PrismaIncomeRepository implements IIncomeRepository { return Number(result._sum.amount ?? 0); } + async findById(id: string): Promise { + return this.prisma.income.findUnique({ where: { id } }); + } + async findLastByUserId(userId: string): Promise { return this.prisma.income.findFirst({ where: { userId, isDeleted: false }, @@ -45,6 +50,36 @@ export class PrismaIncomeRepository implements IIncomeRepository { }); } + async findByConfirmationMessageId( + messageId: string, + userId: string, + ): Promise { + return this.prisma.income.findFirst({ + where: { confirmationMessageId: messageId, userId, isDeleted: false }, + }); + } + + async updateConfirmationMessageId( + id: string, + messageId: string, + ): Promise { + await this.prisma.income.update({ + where: { id }, + data: { confirmationMessageId: messageId }, + }); + } + + async update(id: string, data: UpdateIncomeDTO): Promise { + await this.prisma.income.update({ + where: { id }, + data: { + ...(data.amount !== undefined && { amount: data.amount }), + ...(data.source !== undefined && { source: data.source }), + ...(data.note !== undefined && { note: data.note }), + }, + }); + } + async findByBatchId(batchId: string, userId: string): Promise { return this.prisma.income.findMany({ where: { batchId, userId, isDeleted: false }, @@ -64,4 +99,18 @@ export class PrismaIncomeRepository implements IIncomeRepository { data: { isDeleted: true, deletedAt: new Date() }, }); } + + async restore(id: string): Promise { + await this.prisma.income.update({ + where: { id }, + data: { isDeleted: false, deletedAt: null }, + }); + } + + async restoreByBatchId(batchId: string, userId: string): Promise { + await this.prisma.income.updateMany({ + where: { batchId, userId, isDeleted: true }, + data: { isDeleted: false, deletedAt: null }, + }); + } } diff --git a/src/domain/repositories/IExpenseRepository.ts b/src/domain/repositories/IExpenseRepository.ts index 109264d..560201c 100644 --- a/src/domain/repositories/IExpenseRepository.ts +++ b/src/domain/repositories/IExpenseRepository.ts @@ -14,6 +14,14 @@ export interface CreateExpenseDTO { batchId?: string | undefined; } +// Partial edit of an existing expense (amount/bucket/category/note). +export interface UpdateExpenseDTO { + amount?: number | undefined; + bucket?: Bucket | undefined; + categoryId?: string | null | undefined; + note?: string | null | undefined; +} + export interface IExpenseRepository { create(data: CreateExpenseDTO, tx?: TxClient): Promise; findById(id: string): Promise; @@ -28,10 +36,15 @@ export interface IExpenseRepository { expenseId: string, messageId: string, ): Promise; + // Partial edit of a single expense (amount/bucket/category/note). + update(id: string, data: UpdateExpenseDTO): Promise; // All non-deleted expenses sharing a batchId (transactions from one message). findByBatchId(batchId: string, userId: string): Promise; // Soft-delete every expense in a batch. softDeleteByBatchId(batchId: string, userId: string): Promise; + // Undo a soft-delete (single + whole batch). + restore(id: string): Promise; + restoreByBatchId(batchId: string, userId: string): Promise; // Sum of non-deleted expense amounts for a bucket within [monthStart, monthEnd). sumByBucketForMonth( userId: string, diff --git a/src/domain/repositories/IIncomeRepository.ts b/src/domain/repositories/IIncomeRepository.ts index eed13e7..1072238 100644 --- a/src/domain/repositories/IIncomeRepository.ts +++ b/src/domain/repositories/IIncomeRepository.ts @@ -11,6 +11,13 @@ export interface CreateIncomeDTO { batchId?: string | undefined; } +// Partial edit of an existing income (amount/source/note). +export interface UpdateIncomeDTO { + amount?: number | undefined; + source?: string | null | undefined; + note?: string | null | undefined; +} + export interface IIncomeRepository { create(data: CreateIncomeDTO, tx?: TxClient): Promise; // Sum of non-deleted income within [monthStart, monthEnd). @@ -19,10 +26,22 @@ export interface IIncomeRepository { monthStart: Date, monthEnd: Date, ): Promise; + findById(id: string): Promise; findLastByUserId(userId: string): Promise; + // Resolve the income behind a confirmation message the user replied to. + findByConfirmationMessageId( + messageId: string, + userId: string, + ): Promise; + updateConfirmationMessageId(id: string, messageId: string): Promise; + // Partial edit of a single income (amount/source/note). + update(id: string, data: UpdateIncomeDTO): Promise; // All non-deleted income sharing a batchId (transactions from one message). findByBatchId(batchId: string, userId: string): Promise; // Soft-delete every income in a batch. softDeleteByBatchId(batchId: string, userId: string): Promise; softDelete(id: string): Promise; + // Undo a soft-delete (single + whole batch). + restore(id: string): Promise; + restoreByBatchId(batchId: string, userId: string): Promise; } diff --git a/src/domain/repositories/IParseLogRepository.ts b/src/domain/repositories/IParseLogRepository.ts index e69d4a8..638f39f 100644 --- a/src/domain/repositories/IParseLogRepository.ts +++ b/src/domain/repositories/IParseLogRepository.ts @@ -3,7 +3,9 @@ import { ParsedData } from "../entities/ParsedData"; export interface CreateParseLogDTO { rawText: string; - parsed: ParsedData; + // Usually a ParsedData, but also serves as the pending-action staging store + // (e.g. a staged transaction edit), so any JSON-serializable object is allowed. + parsed: ParsedData | Record; confidence: number; userId?: string | undefined; batchId?: string | undefined; diff --git a/src/presentation/controllers/TelegramWebhookController.ts b/src/presentation/controllers/TelegramWebhookController.ts index f2267e9..fb02973 100644 --- a/src/presentation/controllers/TelegramWebhookController.ts +++ b/src/presentation/controllers/TelegramWebhookController.ts @@ -183,22 +183,28 @@ export class TelegramWebhookController { return; } - // Ack the button press immediately so Telegram stops showing the spinner - await this.messageService.acknowledgeInteraction(cq.id); await this.messageService.sendTypingIndicator(platformUserId); - const result = await this.processIncomingMessage.execute({ - platformUserId, - platformUsername: cq.from.username ?? cq.from.first_name, - textMessage: cq.data ?? "", - callbackMessageId: cq.message?.message_id - ? String(cq.message.message_id) - : undefined, - }); - - res - .status(200) - .json({ success: true, data: { response: result.response } }); + // Ack AFTER handling so the toast can reflect the outcome. The `finally` + // guarantees the spinner always stops, even if processing throws. + let toast: string | undefined; + try { + const result = await this.processIncomingMessage.execute({ + platformUserId, + platformUsername: cq.from.username ?? cq.from.first_name, + textMessage: cq.data ?? "", + callbackMessageId: cq.message?.message_id + ? String(cq.message.message_id) + : undefined, + callbackQueryId: cq.id, + }); + toast = result.toast; + res + .status(200) + .json({ success: true, data: { response: result.response } }); + } finally { + await this.messageService.acknowledgeInteraction(cq.id, toast); + } return; }