From 92f2b7f9db8f6a19b11daf24233038ea9644ea8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sat, 17 Jan 2026 16:33:17 -0300 Subject: [PATCH 1/9] feat(uploads): add CSV import support - Add waiting_for_csv_answers status and fileType column to uploads - Add createCsvUpload, analyzeCsv, submitCsvAnswers tRPC mutations - Create csv-breakdown and extract-transactions-csv trigger tasks - Add CsvConfigDialog for dynamic question form - Modify nav-main to accept .csv files and open dialog - Add Answer button for uploads awaiting CSV answers - Reuse existing categorization pipeline after extraction Closes #118 --- drizzle/0033_wooden_the_captain.sql | 3 + drizzle/meta/0033_snapshot.json | 1518 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/components/logged-in/nav-main.tsx | 80 +- .../logged-in/uploads/csv-config-dialog.tsx | 304 ++++ .../logged-in/uploads/status-badge.tsx | 2 + .../logged-in/uploads/upload-item.tsx | 9 + src/constants/uploads.ts | 1 + src/db/schema.ts | 26 + src/server/routers/uploads.ts | 292 +++- src/trigger/ai/csv-breakdown.ts | 114 ++ src/trigger/ai/extract-transactions-csv.ts | 280 +++ 12 files changed, 2627 insertions(+), 9 deletions(-) create mode 100644 drizzle/0033_wooden_the_captain.sql create mode 100644 drizzle/meta/0033_snapshot.json create mode 100644 src/components/logged-in/uploads/csv-config-dialog.tsx create mode 100644 src/trigger/ai/csv-breakdown.ts create mode 100644 src/trigger/ai/extract-transactions-csv.ts diff --git a/drizzle/0033_wooden_the_captain.sql b/drizzle/0033_wooden_the_captain.sql new file mode 100644 index 0000000..261540d --- /dev/null +++ b/drizzle/0033_wooden_the_captain.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."file_type" AS ENUM('pdf', 'csv');--> statement-breakpoint +ALTER TYPE "public"."upload_status" ADD VALUE 'waiting_for_csv_answers';--> statement-breakpoint +ALTER TABLE "upload" ADD COLUMN "file_type" "file_type" DEFAULT 'pdf' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0033_snapshot.json b/drizzle/meta/0033_snapshot.json new file mode 100644 index 0000000..7425321 --- /dev/null +++ b/drizzle/meta/0033_snapshot.json @@ -0,0 +1,1518 @@ +{ + "id": "73cca70d-f2fb-42e5-a4bf-e35d9e762d02", + "prevId": "2c388760-7bb9-4c0b-8ea1-28f0547f8ce8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget": { + "name": "budget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_tenant_id_idx": { + "name": "budget_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_deleted_idx": { + "name": "budget_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_tenant_id_user_tenant_tenant_id_fk": { + "name": "budget_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "budget", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_category": { + "name": "budget_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "budget_id": { + "name": "budget_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_category_budget_id_idx": { + "name": "budget_category_budget_id_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_category_id_idx": { + "name": "budget_category_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_unique_idx": { + "name": "budget_category_unique_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_category_budget_id_budget_id_fk": { + "name": "budget_category_budget_id_budget_id_fk", + "tableFrom": "budget_category", + "tableTo": "budget", + "columnsFrom": ["budget_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_category_category_id_category_id_fk": { + "name": "budget_category_category_id_category_id_fk", + "tableFrom": "budget_category", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categorization_rule": { + "name": "categorization_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logic_operator": { + "name": "logic_operator", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'and'" + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categorization_rule_tenant_id_idx": { + "name": "categorization_rule_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_priority_idx": { + "name": "categorization_rule_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_deleted_idx": { + "name": "categorization_rule_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categorization_rule_tenant_id_user_tenant_tenant_id_fk": { + "name": "categorization_rule_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "categorization_rule", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "category_tenant_id_idx": { + "name": "category_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "category_deleted_idx": { + "name": "category_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "category_tenant_id_user_tenant_tenant_id_fk": { + "name": "category_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "category", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "merchant_name": { + "name": "merchant_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transaction_tenant_id_idx": { + "name": "transaction_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_upload_id_idx": { + "name": "transaction_upload_id_idx", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_category_id_idx": { + "name": "transaction_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_confidence_idx": { + "name": "transaction_confidence_idx", + "columns": [ + { + "expression": "confidence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_date_idx": { + "name": "transaction_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_deleted_idx": { + "name": "transaction_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_merchant_name_idx": { + "name": "transaction_merchant_name_idx", + "columns": [ + { + "expression": "merchant_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_fingerprint_idx": { + "name": "transaction_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_upload_id_upload_id_fk": { + "name": "transaction_upload_id_upload_id_fk", + "tableFrom": "transaction", + "tableTo": "upload", + "columnsFrom": ["upload_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_tenant_id_user_tenant_tenant_id_fk": { + "name": "transaction_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "transaction", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_category_id_category_id_fk": { + "name": "transaction_category_id_category_id_fk", + "tableFrom": "transaction", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload": { + "name": "upload", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pdf'" + }, + "status": { + "name": "status", + "type": "upload_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failed_reason": { + "name": "failed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pdf_deleted": { + "name": "pdf_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_tenant_id_idx": { + "name": "upload_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "upload_deleted_idx": { + "name": "upload_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_tenant_id_user_tenant_tenant_id_fk": { + "name": "upload_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "upload", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_type": { + "name": "work_type", + "type": "work_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "primary_use": { + "name": "primary_use", + "type": "primary_use", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_user_id_fk": { + "name": "user_profile_user_id_user_id_fk", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_unique": { + "name": "user_profile_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tenant": { + "name": "user_tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id_hash": { + "name": "user_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id_encrypted": { + "name": "user_id_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dek_encrypted": { + "name": "dek_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_tenant_tenant_id_idx": { + "name": "user_tenant_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tenant_user_id_hash_idx": { + "name": "user_tenant_user_id_hash_idx", + "columns": [ + { + "expression": "user_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_tenant_tenant_id_unique": { + "name": "user_tenant_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + }, + "user_tenant_user_id_hash_unique": { + "name": "user_tenant_user_id_hash_unique", + "nullsNotDistinct": false, + "columns": ["user_id_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_access": { + "name": "granted_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "waitlist_email_idx": { + "name": "waitlist_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_granted_access_idx": { + "name": "waitlist_granted_access_idx", + "columns": [ + { + "expression": "granted_access", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_type": { + "name": "file_type", + "schema": "public", + "values": ["pdf", "csv"] + }, + "public.primary_use": { + "name": "primary_use", + "schema": "public", + "values": ["personal", "business", "both"] + }, + "public.upload_status": { + "name": "upload_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed", + "cancelled", + "waiting_for_password", + "waiting_for_csv_answers" + ] + }, + "public.work_type": { + "name": "work_type", + "schema": "public", + "values": [ + "employed", + "self_employed", + "business_owner", + "student", + "retired", + "unemployed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1add646..286018a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1768518111955, "tag": "0032_mean_bruce_banner", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1768676140372, + "tag": "0033_wooden_the_captain", + "breakpoints": true } ] } diff --git a/src/components/logged-in/nav-main.tsx b/src/components/logged-in/nav-main.tsx index c63864c..c67a882 100644 --- a/src/components/logged-in/nav-main.tsx +++ b/src/components/logged-in/nav-main.tsx @@ -19,6 +19,11 @@ import { trpc } from "@/lib/trpc/client"; import { uploadToSignedUrlAction } from "@/server/actions/uploads"; import type { SignedUploadUrl } from "@/server/routers/uploads"; import { Kbd } from "../ui/kbd"; +import { CsvConfigDialog } from "./uploads/csv-config-dialog"; + +function isCsvFile(file: File): boolean { + return file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv"); +} const IMPORT_BANK_STATEMENT_SHORTCUT = "I"; @@ -37,6 +42,10 @@ export function NavMain({ }: NavMainProps) { const [files, setFiles] = useState(null); const [isUploading, setIsUploading] = useState(false); + const [csvDialogConfig, setCsvDialogConfig] = useState<{ + uploadId: string; + fileName: string; + } | null>(null); const pathname = usePathname(); const router = useRouter(); const internalFileInputRef = useRef(null); @@ -45,6 +54,28 @@ export function NavMain({ useHotkeys(IMPORT_BANK_STATEMENT_SHORTCUT, () => handleImportClick()); + const { mutate: createCsvUpload } = trpc.uploads.createCsvUpload.useMutation({ + onSuccess: (data, variables) => { + toast.success("CSV uploaded", { + id: "upload-bank-statement", + description: "Please provide some additional information.", + }); + setIsUploading(false); + setCsvDialogConfig({ + uploadId: data.uploadId, + fileName: variables.fileName, + }); + setFiles(null); + }, + onError: (error) => { + setIsUploading(false); + toast.error(error.message, { + id: "upload-bank-statement", + description: null, + }); + }, + }); + const { mutate: createSignedUploadUrls } = trpc.uploads.createSignedUploadUrls.useMutation({ onMutate: () => { @@ -63,13 +94,35 @@ export function NavMain({ }, onSuccess: async ({ uploadUrls }) => { const successfulUploads = await uploadToSignedUrls(uploadUrls); - processUploads({ - files: successfulUploads.map((upload) => ({ - fileSize: upload.file.size, - fileName: upload.file.name, - filePath: upload.signedUrlConfig.path, - })), - }); + + const csvUploads = successfulUploads.filter((u) => isCsvFile(u.file)); + const pdfUploads = successfulUploads.filter((u) => !isCsvFile(u.file)); + + if (pdfUploads.length > 0) { + processUploads({ + files: pdfUploads.map((upload) => ({ + fileSize: upload.file.size, + fileName: upload.file.name, + filePath: upload.signedUrlConfig.path, + })), + }); + } + + if (csvUploads.length > 0) { + const firstCsv = csvUploads[0]; + createCsvUpload({ + fileName: firstCsv.file.name, + filePath: firstCsv.signedUrlConfig.path, + fileSize: firstCsv.file.size, + }); + + if (csvUploads.length > 1) { + toast.info("Only one CSV can be processed at a time", { + description: "Additional CSV files were skipped.", + }); + } + } + setFiles(null); }, }); @@ -164,7 +217,7 @@ export function NavMain({ + {csvDialogConfig && ( + { + setCsvDialogConfig(null); + router.push("/uploads"); + }} + uploadId={csvDialogConfig.uploadId} + /> + )} + + + + + + ); +} diff --git a/src/components/logged-in/uploads/status-badge.tsx b/src/components/logged-in/uploads/status-badge.tsx index 149f432..c0d32f7 100644 --- a/src/components/logged-in/uploads/status-badge.tsx +++ b/src/components/logged-in/uploads/status-badge.tsx @@ -20,6 +20,7 @@ const statusLabel: Record = { failed: "Failed", cancelled: "Cancelled", waiting_for_password: "Waiting for password", + waiting_for_csv_answers: "Awaiting answers", }; const statusIcon: Record = { @@ -29,6 +30,7 @@ const statusIcon: Record = { failed:
, cancelled:
, waiting_for_password:
, + waiting_for_csv_answers:
, }; export function StatusBadge({ status, failedReason }: StatusBadgeProps) { diff --git a/src/components/logged-in/uploads/upload-item.tsx b/src/components/logged-in/uploads/upload-item.tsx index 850b495..2093ee4 100644 --- a/src/components/logged-in/uploads/upload-item.tsx +++ b/src/components/logged-in/uploads/upload-item.tsx @@ -20,6 +20,7 @@ import { useInvalidateUploads } from "@/hooks/use-uploads"; import { trpc } from "@/lib/trpc/client"; import { formatBytes } from "@/lib/utils"; import { DoubleConfirmationAlertDialog } from "../double-confirmation-alert-dialog"; +import { CsvConfigDialog } from "./csv-config-dialog"; import { FileName } from "./file-name"; import { PasswordDialog } from "./password-dialog"; import { StatusBadge } from "./status-badge"; @@ -134,6 +135,14 @@ export function UploadItem({ )} + {upload.status === "waiting_for_csv_answers" && ( + + + + )} + {CANCELLABLE_STATUSES.includes(upload.status) && ( ; + inferredMapping?: { + dateColumn?: string; + merchantColumn?: string; + amountColumn?: string; + descriptionColumn?: string; + }; +} + export interface UploadMetadata { documentType?: string | null; bankName?: string | null; @@ -165,6 +189,7 @@ export interface UploadMetadata { value: string; }[] | null; + csvConfig?: CsvConfig; } export type Upload = typeof upload.$inferSelect; @@ -179,6 +204,7 @@ export const upload = pgTable( fileName: text("file_name").notNull(), filePath: text("file_path").notNull(), fileSize: integer("file_size").notNull(), + fileType: fileTypeEnum("file_type").notNull().default("pdf"), status: uploadStatusEnum("status").notNull().default("queued"), encryptedPassword: text("encrypted_password"), failedReason: text("failed_reason"), diff --git a/src/server/routers/uploads.ts b/src/server/routers/uploads.ts index c768d91..1c21375 100644 --- a/src/server/routers/uploads.ts +++ b/src/server/routers/uploads.ts @@ -1,16 +1,121 @@ import { randomUUID } from "node:crypto"; import { tasks } from "@trigger.dev/sdk/v3"; import { TRPCError } from "@trpc/server"; +import { generateObject } from "ai"; import { and, desc, eq, ilike, inArray } from "drizzle-orm"; import { z } from "zod"; import { CANCELLABLE_STATUSES, DELETABLE_STATUSES } from "@/constants/uploads"; import { db } from "@/db"; -import { transaction, upload } from "@/db/schema"; +import { type CsvQuestion, transaction, upload } from "@/db/schema"; import { encryptPassword } from "@/lib/crypto"; import { createClient } from "@/lib/supabase/server"; import { paginationSchema } from "@/schemas/pagination"; import { protectedProcedure, router } from "../trpc"; +const csvQuestionSchema = z.object({ + id: z.string(), + type: z.enum(["text", "select", "date", "boolean"]), + label: z.string(), + description: z.string().optional(), + options: z.array(z.string()).optional(), + required: z.boolean(), + defaultValue: z.string().optional(), +}); + +const csvAnalysisResultSchema = z.object({ + questions: z.array(csvQuestionSchema), + inferredMapping: z.object({ + dateColumn: z.string().optional(), + merchantColumn: z.string().optional(), + amountColumn: z.string().optional(), + descriptionColumn: z.string().optional(), + }), +}); + +function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === "," && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + result.push(current.trim()); + return result; +} + +const LINE_BREAK_REGEX = /\r?\n/; + +function parseCsvContent( + content: string, + maxRows = 50 +): { headers: string[]; rows: string[][] } { + const lines = content + .split(LINE_BREAK_REGEX) + .filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return { headers: [], rows: [] }; + } + + const headers = parseCsvLine(lines[0]); + const rows = lines.slice(1, maxRows + 1).map((line) => parseCsvLine(line)); + + return { headers, rows }; +} + +function buildCsvAnalysisPrompt(headers: string[], sampleRows: string[][]) { + const rowsText = sampleRows + .slice(0, 20) + .map((row, i) => `${i + 1}. ${row.join(" | ")}`) + .join("\n"); + + return `Analyze this CSV bank statement export. + +## CSV Headers +${headers.join(", ")} + +## Sample Rows (first 20) +${rowsText} + +## YOUR TASK +1. INFER COLUMN MAPPING: Identify which columns contain: + - Date (and likely format) + - Merchant/Description + - Amount (or separate credit/debit columns) + - Optional: description/memo column + +2. GENERATE QUESTIONS: Create minimal questions for context you cannot infer. + Only ask what's truly needed for accurate extraction. + + Consider asking about: + - Bank name (helps identify merchant patterns) + - Currency (critical if not in CSV) + - Account type (checking/savings/credit card) + - Date format (only if ambiguous between MM/DD and DD/MM) + - Sign convention (only if unclear whether positive = expense or income) + + Rules for questions: + - Use "text" type for open-ended answers (bank name, currency) + - Use "select" type when there are clear options (account type, date format) + - Use "boolean" type for yes/no questions (sign convention) + - Keep questions concise but clear + - Only ask 2-4 questions maximum + - Don't ask about things you can clearly infer from the data`; +} + async function getExistingUpload(id: string, tenantId: string) { const [existingUpload] = await db .select({ @@ -457,4 +562,189 @@ export const uploadsRouter = router({ latestUploadDate: latestUpload?.createdAt ?? null, }; }), + createCsvUpload: protectedProcedure + .input( + z.object({ + fileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + }) + ) + .mutation(async ({ input, ctx }) => { + const [newUpload] = await ctx.db + .insert(upload) + .values({ + tenantId: ctx.tenant.tenantId, + fileName: input.fileName, + filePath: input.filePath, + fileSize: input.fileSize, + fileType: "csv", + status: "waiting_for_csv_answers", + }) + .returning({ id: upload.id }); + + return { uploadId: newUpload.id }; + }), + analyzeCsv: protectedProcedure + .input(z.object({ uploadId: z.string().uuid() })) + .mutation(async ({ input, ctx }) => { + const [existingUpload] = await ctx.db + .select({ + id: upload.id, + filePath: upload.filePath, + status: upload.status, + metadata: upload.metadata, + deleted: upload.deleted, + }) + .from(upload) + .where( + and( + eq(upload.id, input.uploadId), + eq(upload.tenantId, ctx.tenant.tenantId) + ) + ); + + if (!existingUpload || existingUpload.deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Upload not found.", + }); + } + + if (existingUpload.status !== "waiting_for_csv_answers") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Upload is not waiting for CSV answers.", + }); + } + + if (existingUpload.metadata?.csvConfig?.questions) { + return { + questions: existingUpload.metadata.csvConfig + .questions as CsvQuestion[], + preview: { + headers: [] as string[], + sampleRows: [] as string[][], + }, + inferredMapping: existingUpload.metadata.csvConfig.inferredMapping, + }; + } + + const supabase = await createClient({ admin: true }); + const { data: fileData, error: downloadError } = await supabase.storage + .from("bank-statements") + .download(existingUpload.filePath); + + if (downloadError || !fileData) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to download CSV: ${downloadError?.message}`, + }); + } + + const csvContent = await fileData.text(); + const { headers, rows } = parseCsvContent(csvContent, 50); + + if (headers.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "CSV file appears to be empty or invalid.", + }); + } + + const analysisResult = await generateObject({ + model: "anthropic/claude-haiku-4.5", + mode: "json", + schemaName: "csv-analysis", + schemaDescription: + "Analysis result for a CSV bank statement with questions and column mapping.", + schema: csvAnalysisResultSchema, + messages: [ + { + role: "user", + content: buildCsvAnalysisPrompt(headers, rows), + }, + ], + }); + + const questions = analysisResult.object.questions; + const inferredMapping = analysisResult.object.inferredMapping; + + await ctx.db + .update(upload) + .set({ + metadata: { + ...existingUpload.metadata, + csvConfig: { + questions, + inferredMapping, + }, + }, + }) + .where(eq(upload.id, input.uploadId)); + + return { + questions, + preview: { + headers, + sampleRows: rows.slice(0, 5), + }, + inferredMapping, + }; + }), + submitCsvAnswers: protectedProcedure + .input( + z.object({ + uploadId: z.string().uuid(), + answers: z.record(z.string(), z.string()), + }) + ) + .mutation(async ({ input, ctx }) => { + const [existingUpload] = await ctx.db + .select({ + id: upload.id, + status: upload.status, + metadata: upload.metadata, + deleted: upload.deleted, + }) + .from(upload) + .where( + and( + eq(upload.id, input.uploadId), + eq(upload.tenantId, ctx.tenant.tenantId) + ) + ); + + if (!existingUpload || existingUpload.deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Upload not found.", + }); + } + + if (existingUpload.status !== "waiting_for_csv_answers") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Upload is not waiting for CSV answers.", + }); + } + + await ctx.db + .update(upload) + .set({ + status: "queued", + metadata: { + ...existingUpload.metadata, + csvConfig: { + ...existingUpload.metadata?.csvConfig, + answers: input.answers, + }, + }, + }) + .where(eq(upload.id, input.uploadId)); + + await tasks.trigger("csv-breakdown", { uploadId: input.uploadId }); + + return { success: true }; + }), }); diff --git a/src/trigger/ai/csv-breakdown.ts b/src/trigger/ai/csv-breakdown.ts new file mode 100644 index 0000000..36b0bab --- /dev/null +++ b/src/trigger/ai/csv-breakdown.ts @@ -0,0 +1,114 @@ +import { logger, task } from "@trigger.dev/sdk/v3"; +import { and, eq, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { upload, user } from "@/db/schema"; +import { env } from "@/env"; +import { getUserIdFromTenant } from "@/lib/tenant"; +import { sendUploadFailedTask } from "@/trigger/emails/send-upload-failed"; +import { categorizeAndImportTransactionsTask } from "./categorize-and-import-transactions"; +import { extractTransactionsCsvTask } from "./extract-transactions-csv"; + +export const csvBreakdownTask = task({ + id: "csv-breakdown", + run: async (payload: { uploadId: string }, { ctx }) => { + logger.info(`Processing CSV upload ${payload.uploadId}...`, { + payload, + ctx, + }); + + const [existingUpload] = await db + .select({ + id: upload.id, + fileName: upload.fileName, + filePath: upload.filePath, + tenantId: upload.tenantId, + metadata: upload.metadata, + }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + if (!existingUpload) { + logger.error(`Upload ${payload.uploadId} not found`); + return { success: false, reason: "Upload not found" }; + } + + const userId = await getUserIdFromTenant(existingUpload.tenantId); + if (!userId) { + logger.error("Tenant not found for upload"); + return { success: false, reason: "Tenant not found" }; + } + + const [uploadUser] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)); + + if (!uploadUser) { + logger.error("User not found for upload"); + return { success: false, reason: "User not found" }; + } + + await db + .update(upload) + .set({ status: "processing" }) + .where(eq(upload.id, payload.uploadId)); + + logger.info(`Extracting transactions from CSV ${payload.uploadId}...`); + const extractionResult = await extractTransactionsCsvTask + .triggerAndWait({ uploadId: payload.uploadId }) + .unwrap(); + + logger.info( + `Extracted ${extractionResult.transactions.length} transactions. Sending to categorize and import task...` + ); + await categorizeAndImportTransactionsTask.trigger({ + uploadId: payload.uploadId, + tenantId: extractionResult.tenantId, + transactions: extractionResult.transactions, + statementCurrency: extractionResult.statementCurrency, + openingBalance: extractionResult.openingBalance, + closingBalance: extractionResult.closingBalance, + }); + + return { success: true }; + }, + catchError: async ({ ctx, error, payload }) => { + logger.error(`Run ${ctx.run.id} failed`, { payload, error }); + + const [failedUpload] = await db + .select({ fileName: upload.fileName, tenantId: upload.tenantId }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + await db + .update(upload) + .set({ + status: "failed", + failedReason: + "I'm sorry, I had a hard time processing your CSV file. Please try again later.", + }) + .where( + and(eq(upload.id, payload.uploadId), ne(upload.status, "cancelled")) + ); + + if (failedUpload) { + const userId = await getUserIdFromTenant(failedUpload.tenantId); + if (userId) { + const [uploadUser] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)); + + if (uploadUser) { + await sendUploadFailedTask.trigger({ + to: uploadUser.email, + fileName: failedUpload.fileName, + uploadsLink: `${env.NEXT_PUBLIC_APP_URL}/uploads`, + }); + } + } + } + + return { skipRetrying: true }; + }, +}); diff --git a/src/trigger/ai/extract-transactions-csv.ts b/src/trigger/ai/extract-transactions-csv.ts new file mode 100644 index 0000000..b4e7cca --- /dev/null +++ b/src/trigger/ai/extract-transactions-csv.ts @@ -0,0 +1,280 @@ +import { AbortTaskRunError, logger, retry, task } from "@trigger.dev/sdk/v3"; +import { generateObject } from "ai"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { upload } from "@/db/schema"; +import { createLambdaClient } from "@/lib/supabase/server"; + +const extractedTransactionSchema = z.object({ + date: z + .string() + .describe( + 'Transaction date in ISO format YYYY-MM-DD (e.g., "2025-01-15"). Parse dates carefully from the CSV.' + ), + merchantName: z + .string() + .describe( + 'Merchant name EXACTLY as shown in CSV. Keep prefixes like "Ifd*", "Pag*", "MP*". Only remove: installment suffixes (1/3, PARC 01/12) and payment method indicators at the end.' + ), + description: z + .string() + .optional() + .describe( + 'Additional transaction details if present (e.g., "Online purchase", "ATM withdrawal", installment info "2/6").' + ), + amount: z + .number() + .describe( + "Amount in CENTS as integer. POSITIVE for money OUT (purchases, payments, withdrawals, fees). NEGATIVE for money IN (deposits, refunds, cashback, interest received). Example: $10.50 expense = 1050, $25.00 refund = -2500." + ), + currency: z + .string() + .describe( + 'ISO 4217 currency code (e.g., "USD", "BRL", "EUR"). Use the statement\'s primary currency.' + ), +}); + +const extractionResultSchema = z.object({ + transactions: z.array(extractedTransactionSchema), + statementCurrency: z + .string() + .describe("Primary currency of the statement (ISO 4217 code)."), + openingBalance: z + .number() + .optional() + .describe("Opening/previous balance in cents if clearly visible."), + closingBalance: z + .number() + .optional() + .describe("Closing/ending balance in cents if clearly visible."), +}); + +function buildSystemPrompt( + answers: Record, + inferredMapping?: { + dateColumn?: string; + merchantColumn?: string; + amountColumn?: string; + descriptionColumn?: string; + } +) { + const userContext = Object.entries(answers) + .map(([key, value]) => `- ${key}: ${value}`) + .join("\n"); + + const mappingContext = inferredMapping + ? ` +## INFERRED COLUMN MAPPING +- Date column: ${inferredMapping.dateColumn ?? "Not specified"} +- Merchant/Description column: ${inferredMapping.merchantColumn ?? "Not specified"} +- Amount column: ${inferredMapping.amountColumn ?? "Not specified"} +- Description/Memo column: ${inferredMapping.descriptionColumn ?? "Not specified"}` + : ""; + + return `You are an expert financial document analyst specializing in extracting transactions from CSV bank statement exports. + +## USER-PROVIDED CONTEXT +${userContext} +${mappingContext} + +## YOUR TASK +Analyze the provided CSV data and extract ALL transactions into a structured format with perfect accuracy. + +## CRITICAL RULES + +### Transaction Identification +- Extract EVERY transaction row from the CSV +- Skip header rows and summary rows +- Watch for blank rows or separator rows - skip them +- Do NOT skip any actual transactions - completeness is critical + +### Date Parsing +- Convert all dates to ISO format: YYYY-MM-DD +- Handle various formats: "Jan 15", "15/01/2025", "01-15-25", "2025-01-15", etc. +- Use the user's date format context if provided +- If year is missing, infer from context + +### Merchant Name (KEEP AS-IS) +Keep the merchant name EXACTLY as shown. Preserve all prefixes and identifiers. + +KEEP prefixes like: +- "Ifd*", "Pag*", "MP*", "PAG*", "SQ*", etc. + +ONLY REMOVE: +- Installment suffixes at the end (1/3, 2/6, PARC 01/12) +- Payment method indicators at the very end + +### Amount Sign Convention (IMPORTANT!) +Based on the user's context or your analysis of the data: +- POSITIVE amounts = Money leaving the account (expenses, purchases, payments, fees, withdrawals) +- NEGATIVE amounts = Money entering the account (deposits, refunds, credits, interest, income) + +Convert to cents: $10.50 = 1050, R$ 25,00 = 2500 + +If the CSV has separate debit/credit columns: +- Debits → POSITIVE (expenses) +- Credits → NEGATIVE (income) + +If the CSV has a single amount column, interpret based on: +- User's context about sign convention +- Column headers or indicators +- Transaction descriptions (look for "deposit", "refund", "payment", etc.) + +### Deduplication +- Same date + same merchant + same amount = likely duplicate, extract only once + +### Accuracy Focus +- Preserve exact spelling and formatting of merchant names +- Do not guess or infer missing data - only extract what is clearly visible`; +} + +function buildUserPrompt(csvContent: string) { + return `## CSV CONTENT +\`\`\` +${csvContent} +\`\`\` + +## INSTRUCTIONS +1. Parse ALL transaction rows from the CSV +2. Keep merchant names exactly as shown (preserve prefixes) +3. Apply correct sign convention (positive = expense, negative = income) +4. Convert amounts to cents +5. Do not skip any transactions + +## EXAMPLE OUTPUT FORMAT +\`\`\`json +{ + "transactions": [ + { + "date": "2025-01-15", + "merchantName": "Ifd*Japakitos", + "description": "2/3", + "amount": 2500, + "currency": "BRL" + }, + { + "date": "2025-01-14", + "merchantName": "NETFLIX.COM", + "amount": 3990, + "currency": "BRL" + }, + { + "date": "2025-01-10", + "merchantName": "TED RECEBIDO", + "description": "Salary deposit", + "amount": -500000, + "currency": "BRL" + } + ], + "statementCurrency": "BRL" +} +\`\`\` + +Now analyze the CSV and extract all transactions with perfect accuracy.`; +} + +export const extractTransactionsCsvTask = task({ + id: "extract-transactions-csv", + retry: { randomize: false }, + run: async (payload: { uploadId: string }, { ctx }) => { + logger.info(`Extracting transactions from CSV ${payload.uploadId}...`, { + payload, + ctx, + }); + + const [uploadRecord] = await db + .select({ + id: upload.id, + filePath: upload.filePath, + tenantId: upload.tenantId, + metadata: upload.metadata, + }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + if (!uploadRecord) { + throw new AbortTaskRunError( + `Upload ${payload.uploadId} not found in database` + ); + } + + const csvConfig = uploadRecord.metadata?.csvConfig; + if (!csvConfig?.answers) { + throw new AbortTaskRunError( + `Upload ${payload.uploadId} missing CSV configuration answers` + ); + } + + const supabase = createLambdaClient(); + const { data: signedUrlData, error: signedUrlError } = + await supabase.storage + .from("bank-statements") + .createSignedUrl(uploadRecord.filePath, 60 * 15); + + if (signedUrlError || !signedUrlData?.signedUrl) { + logger.error(`Failed to get signed URL for upload ${payload.uploadId}`, { + error: signedUrlError?.message, + }); + throw new Error("Failed to get signed URL"); + } + + logger.info(`Downloading CSV file for upload ${payload.uploadId}...`); + const response = await retry.fetch(signedUrlData.signedUrl, { + method: "GET", + }); + const csvContent = await response.text(); + + logger.info("Extracting transactions with AI..."); + const result = await generateObject({ + model: "anthropic/claude-sonnet-4.5", + mode: "json", + schemaName: "extract-transactions-csv", + schemaDescription: + "Transactions extracted from a CSV bank statement with high accuracy.", + schema: extractionResultSchema, + messages: [ + { + role: "system", + content: buildSystemPrompt( + csvConfig.answers, + csvConfig.inferredMapping + ), + }, + { + role: "user", + content: buildUserPrompt(csvContent), + }, + ], + }); + + logger.info("CSV transaction extraction complete", { + transactionCount: result.object.transactions.length, + statementCurrency: result.object.statementCurrency, + openingBalance: result.object.openingBalance, + closingBalance: result.object.closingBalance, + }); + + const supabaseAdmin = createLambdaClient(); + const { error: deleteError } = await supabaseAdmin.storage + .from("bank-statements") + .remove([uploadRecord.filePath]); + + if (deleteError) { + logger.warn(`Failed to delete CSV file: ${deleteError.message}`); + } else { + await db + .update(upload) + .set({ pdfDeleted: true }) + .where(eq(upload.id, payload.uploadId)); + logger.info(`Deleted CSV file for upload ${payload.uploadId}`); + } + + return { + success: true, + uploadId: payload.uploadId, + tenantId: uploadRecord.tenantId, + ...result.object, + }; + }, +}); From b78bf34a786a2ce20558725cee34ca16513b5f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sun, 18 Jan 2026 16:36:05 -0300 Subject: [PATCH 2/9] fix(csv-import): address PR review feedback - Add 1MiB file size validation for CSV uploads - Handle AI analysis failure by marking upload as failed - Store preview in metadata for cached questions - Cleanup skipped CSV files when multiple uploaded - Add server-side validation for required answers - Fix early returns in csv-breakdown to use AbortTaskRunError - Use toast notifications instead of button label changes --- src/components/logged-in/nav-main.tsx | 7 ++ .../logged-in/uploads/csv-config-dialog.tsx | 19 ++- src/db/schema.ts | 4 + src/server/routers/uploads.ts | 108 ++++++++++++------ src/trigger/ai/csv-breakdown.ts | 11 +- 5 files changed, 95 insertions(+), 54 deletions(-) diff --git a/src/components/logged-in/nav-main.tsx b/src/components/logged-in/nav-main.tsx index c67a882..36e9ef0 100644 --- a/src/components/logged-in/nav-main.tsx +++ b/src/components/logged-in/nav-main.tsx @@ -54,6 +54,9 @@ export function NavMain({ useHotkeys(IMPORT_BANK_STATEMENT_SHORTCUT, () => handleImportClick()); + const { mutate: deleteStorageFiles } = + trpc.uploads.deleteStorageFiles.useMutation(); + const { mutate: createCsvUpload } = trpc.uploads.createCsvUpload.useMutation({ onSuccess: (data, variables) => { toast.success("CSV uploaded", { @@ -117,6 +120,10 @@ export function NavMain({ }); if (csvUploads.length > 1) { + const skippedCsvPaths = csvUploads + .slice(1) + .map((u) => u.signedUrlConfig.path); + deleteStorageFiles({ filePaths: skippedCsvPaths }); toast.info("Only one CSV can be processed at a time", { description: "Additional CSV files were skipped.", }); diff --git a/src/components/logged-in/uploads/csv-config-dialog.tsx b/src/components/logged-in/uploads/csv-config-dialog.tsx index 07598ad..8e904f6 100644 --- a/src/components/logged-in/uploads/csv-config-dialog.tsx +++ b/src/components/logged-in/uploads/csv-config-dialog.tsx @@ -67,9 +67,15 @@ export function CsvConfigDialog({ const { mutate: submitAnswers, isPending: isSubmitting } = trpc.uploads.submitCsvAnswers.useMutation({ + onMutate: () => { + toast.loading("Submitting answers...", { id: "csv-submit" }); + }, onSuccess: () => { toast.success( - "CSV processing started. We'll email you when it's done." + "CSV processing started. We'll email you when it's done.", + { + id: "csv-submit", + } ); setOpen(false); setAnswers({}); @@ -78,7 +84,7 @@ export function CsvConfigDialog({ onSuccess?.(); }, onError: (error) => { - toast.error(error.message); + toast.error(error.message, { id: "csv-submit" }); }, }); @@ -287,14 +293,7 @@ export function CsvConfigDialog({ disabled={isAnalyzing || isSubmitting || !analysisData} type="submit" > - {isSubmitting ? ( - <> - - Submitting... - - ) : ( - "Submit" - )} + Submit diff --git a/src/db/schema.ts b/src/db/schema.ts index aec8f43..6f9904a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -174,6 +174,10 @@ export interface CsvConfig { amountColumn?: string; descriptionColumn?: string; }; + preview?: { + headers: string[]; + sampleRows: string[][]; + }; } export interface UploadMetadata { diff --git a/src/server/routers/uploads.ts b/src/server/routers/uploads.ts index 1c21375..e182db2 100644 --- a/src/server/routers/uploads.ts +++ b/src/server/routers/uploads.ts @@ -567,7 +567,7 @@ export const uploadsRouter = router({ z.object({ fileName: z.string(), filePath: z.string(), - fileSize: z.number(), + fileSize: z.number().max(1024 * 1024, "CSV file must be under 1MiB"), }) ) .mutation(async ({ input, ctx }) => { @@ -622,7 +622,7 @@ export const uploadsRouter = router({ return { questions: existingUpload.metadata.csvConfig .questions as CsvQuestion[], - preview: { + preview: existingUpload.metadata.csvConfig.preview ?? { headers: [] as string[], sampleRows: [] as string[][], }, @@ -652,45 +652,62 @@ export const uploadsRouter = router({ }); } - const analysisResult = await generateObject({ - model: "anthropic/claude-haiku-4.5", - mode: "json", - schemaName: "csv-analysis", - schemaDescription: - "Analysis result for a CSV bank statement with questions and column mapping.", - schema: csvAnalysisResultSchema, - messages: [ - { - role: "user", - content: buildCsvAnalysisPrompt(headers, rows), - }, - ], - }); - - const questions = analysisResult.object.questions; - const inferredMapping = analysisResult.object.inferredMapping; - - await ctx.db - .update(upload) - .set({ - metadata: { - ...existingUpload.metadata, - csvConfig: { - questions, - inferredMapping, + try { + const analysisResult = await generateObject({ + model: "anthropic/claude-haiku-4.5", + mode: "json", + schemaName: "csv-analysis", + schemaDescription: + "Analysis result for a CSV bank statement with questions and column mapping.", + schema: csvAnalysisResultSchema, + messages: [ + { + role: "user", + content: buildCsvAnalysisPrompt(headers, rows), }, - }, - }) - .where(eq(upload.id, input.uploadId)); + ], + }); - return { - questions, - preview: { + const questions = analysisResult.object.questions; + const inferredMapping = analysisResult.object.inferredMapping; + const preview = { headers, sampleRows: rows.slice(0, 5), - }, - inferredMapping, - }; + }; + + await ctx.db + .update(upload) + .set({ + metadata: { + ...existingUpload.metadata, + csvConfig: { + questions, + inferredMapping, + preview, + }, + }, + }) + .where(eq(upload.id, input.uploadId)); + + return { + questions, + preview, + inferredMapping, + }; + } catch { + await ctx.db + .update(upload) + .set({ + status: "failed", + failedReason: "Failed to analyze CSV file. Please try again.", + }) + .where(eq(upload.id, input.uploadId)); + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to analyze CSV file. Please try again.", + }); + } }), submitCsvAnswers: protectedProcedure .input( @@ -729,6 +746,16 @@ export const uploadsRouter = router({ }); } + const questions = existingUpload.metadata?.csvConfig?.questions ?? []; + for (const question of questions) { + if (question.required && !input.answers[question.id]?.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Required answer missing: ${question.label}`, + }); + } + } + await ctx.db .update(upload) .set({ @@ -745,6 +772,13 @@ export const uploadsRouter = router({ await tasks.trigger("csv-breakdown", { uploadId: input.uploadId }); + return { success: true }; + }), + deleteStorageFiles: protectedProcedure + .input(z.object({ filePaths: z.array(z.string()).min(1).max(10) })) + .mutation(async ({ input }) => { + const supabase = await createClient({ admin: true }); + await supabase.storage.from("bank-statements").remove(input.filePaths); return { success: true }; }), }); diff --git a/src/trigger/ai/csv-breakdown.ts b/src/trigger/ai/csv-breakdown.ts index 36b0bab..1925935 100644 --- a/src/trigger/ai/csv-breakdown.ts +++ b/src/trigger/ai/csv-breakdown.ts @@ -1,4 +1,4 @@ -import { logger, task } from "@trigger.dev/sdk/v3"; +import { AbortTaskRunError, logger, task } from "@trigger.dev/sdk/v3"; import { and, eq, ne } from "drizzle-orm"; import { db } from "@/db"; import { upload, user } from "@/db/schema"; @@ -28,14 +28,12 @@ export const csvBreakdownTask = task({ .where(eq(upload.id, payload.uploadId)); if (!existingUpload) { - logger.error(`Upload ${payload.uploadId} not found`); - return { success: false, reason: "Upload not found" }; + throw new AbortTaskRunError(`Upload ${payload.uploadId} not found`); } const userId = await getUserIdFromTenant(existingUpload.tenantId); if (!userId) { - logger.error("Tenant not found for upload"); - return { success: false, reason: "Tenant not found" }; + throw new AbortTaskRunError("Tenant not found for upload"); } const [uploadUser] = await db @@ -44,8 +42,7 @@ export const csvBreakdownTask = task({ .where(eq(user.id, userId)); if (!uploadUser) { - logger.error("User not found for upload"); - return { success: false, reason: "User not found" }; + throw new AbortTaskRunError("User not found for upload"); } await db From 8650a20b3e208c0a69e99787c8014c5728cf13fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sun, 18 Jan 2026 16:44:20 -0300 Subject: [PATCH 3/9] fix(csv-import): address nitpick comments - Add ref guard to prevent re-analysis on useEffect re-runs - Initialize boolean question defaults to "false" - Reset analysis trigger flag when dialog closes --- .../logged-in/uploads/csv-config-dialog.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/logged-in/uploads/csv-config-dialog.tsx b/src/components/logged-in/uploads/csv-config-dialog.tsx index 8e904f6..2380eb5 100644 --- a/src/components/logged-in/uploads/csv-config-dialog.tsx +++ b/src/components/logged-in/uploads/csv-config-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { IconLoader2 } from "@tabler/icons-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -52,6 +52,7 @@ export function CsvConfigDialog({ }: CsvConfigDialogProps) { const [open, setOpen] = useState(defaultOpen); const [answers, setAnswers] = useState>({}); + const hasTriggeredAnalysis = useRef(false); const invalidate = useInvalidateUploads(); const { @@ -89,7 +90,13 @@ export function CsvConfigDialog({ }); useEffect(() => { - if (open && !analysisData && !isAnalyzing) { + if ( + open && + !analysisData && + !isAnalyzing && + !hasTriggeredAnalysis.current + ) { + hasTriggeredAnalysis.current = true; analyzeCsv({ uploadId }); } }, [open, uploadId, analysisData, isAnalyzing, analyzeCsv]); @@ -100,6 +107,8 @@ export function CsvConfigDialog({ for (const question of analysisData.questions) { if (question.defaultValue) { initialAnswers[question.id] = question.defaultValue; + } else if (question.type === "boolean") { + initialAnswers[question.id] = "false"; } } setAnswers(initialAnswers); @@ -125,6 +134,7 @@ export function CsvConfigDialog({ if (!newOpen) { setAnswers({}); resetAnalysis(); + hasTriggeredAnalysis.current = false; } }; From 40beb43473f9894b2ef2e4ad896b1c754d036a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sun, 18 Jan 2026 16:47:56 -0300 Subject: [PATCH 4/9] fix(csv-import): secure file cleanup in createCsvUpload - Remove separate deleteStorageFiles mutation (security concern) - Move orphaned file cleanup into createCsvUpload via cleanupFilePaths param - Files are deleted server-side after upload record is created --- src/components/logged-in/nav-main.tsx | 13 ++++++------- src/server/routers/uploads.ts | 15 ++++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/logged-in/nav-main.tsx b/src/components/logged-in/nav-main.tsx index 36e9ef0..0f24098 100644 --- a/src/components/logged-in/nav-main.tsx +++ b/src/components/logged-in/nav-main.tsx @@ -54,9 +54,6 @@ export function NavMain({ useHotkeys(IMPORT_BANK_STATEMENT_SHORTCUT, () => handleImportClick()); - const { mutate: deleteStorageFiles } = - trpc.uploads.deleteStorageFiles.useMutation(); - const { mutate: createCsvUpload } = trpc.uploads.createCsvUpload.useMutation({ onSuccess: (data, variables) => { toast.success("CSV uploaded", { @@ -113,17 +110,19 @@ export function NavMain({ if (csvUploads.length > 0) { const firstCsv = csvUploads[0]; + const skippedCsvPaths = + csvUploads.length > 1 + ? csvUploads.slice(1).map((u) => u.signedUrlConfig.path) + : undefined; + createCsvUpload({ fileName: firstCsv.file.name, filePath: firstCsv.signedUrlConfig.path, fileSize: firstCsv.file.size, + cleanupFilePaths: skippedCsvPaths, }); if (csvUploads.length > 1) { - const skippedCsvPaths = csvUploads - .slice(1) - .map((u) => u.signedUrlConfig.path); - deleteStorageFiles({ filePaths: skippedCsvPaths }); toast.info("Only one CSV can be processed at a time", { description: "Additional CSV files were skipped.", }); diff --git a/src/server/routers/uploads.ts b/src/server/routers/uploads.ts index e182db2..4ced83f 100644 --- a/src/server/routers/uploads.ts +++ b/src/server/routers/uploads.ts @@ -568,6 +568,7 @@ export const uploadsRouter = router({ fileName: z.string(), filePath: z.string(), fileSize: z.number().max(1024 * 1024, "CSV file must be under 1MiB"), + cleanupFilePaths: z.array(z.string()).max(10).optional(), }) ) .mutation(async ({ input, ctx }) => { @@ -583,6 +584,13 @@ export const uploadsRouter = router({ }) .returning({ id: upload.id }); + if (input.cleanupFilePaths && input.cleanupFilePaths.length > 0) { + const supabase = await createClient({ admin: true }); + await supabase.storage + .from("bank-statements") + .remove(input.cleanupFilePaths); + } + return { uploadId: newUpload.id }; }), analyzeCsv: protectedProcedure @@ -772,13 +780,6 @@ export const uploadsRouter = router({ await tasks.trigger("csv-breakdown", { uploadId: input.uploadId }); - return { success: true }; - }), - deleteStorageFiles: protectedProcedure - .input(z.object({ filePaths: z.array(z.string()).min(1).max(10) })) - .mutation(async ({ input }) => { - const supabase = await createClient({ admin: true }); - await supabase.storage.from("bank-statements").remove(input.filePaths); return { success: true }; }), }); From b03ddbca19976d58e26c576297304df83c07a946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sun, 18 Jan 2026 16:55:16 -0300 Subject: [PATCH 5/9] fix(csv-import): add error UI for analyzeCsv failure --- .../logged-in/uploads/csv-config-dialog.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/logged-in/uploads/csv-config-dialog.tsx b/src/components/logged-in/uploads/csv-config-dialog.tsx index 2380eb5..2708145 100644 --- a/src/components/logged-in/uploads/csv-config-dialog.tsx +++ b/src/components/logged-in/uploads/csv-config-dialog.tsx @@ -59,12 +59,9 @@ export function CsvConfigDialog({ mutate: analyzeCsv, data: analysisData, isPending: isAnalyzing, + error: analysisError, reset: resetAnalysis, - } = trpc.uploads.analyzeCsv.useMutation({ - onError: (error) => { - toast.error(error.message); - }, - }); + } = trpc.uploads.analyzeCsv.useMutation(); const { mutate: submitAnswers, isPending: isSubmitting } = trpc.uploads.submitCsvAnswers.useMutation({ @@ -138,6 +135,12 @@ export function CsvConfigDialog({ } }; + const handleRetryAnalysis = () => { + resetAnalysis(); + hasTriggeredAnalysis.current = false; + analyzeCsv({ uploadId }); + }; + const renderQuestion = (question: CsvQuestion) => { const value = answers[question.id] ?? ""; @@ -219,6 +222,21 @@ export function CsvConfigDialog({
)} + {analysisError && !isAnalyzing && ( +
+

+ Failed to analyze CSV. Please try again. +

+ +
+ )} + {analysisData && !isAnalyzing && (
{analysisData.preview.headers.length > 0 && ( From 1286af3f6fc4afa09739b66fdb375bbdc0a98d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Melo?= Date: Sun, 18 Jan 2026 17:00:51 -0300 Subject: [PATCH 6/9] refactor(csv-import): remove preview table from config dialog --- .../logged-in/uploads/csv-config-dialog.tsx | 50 +------------------ 1 file changed, 2 insertions(+), 48 deletions(-) diff --git a/src/components/logged-in/uploads/csv-config-dialog.tsx b/src/components/logged-in/uploads/csv-config-dialog.tsx index 2708145..1dbd75c 100644 --- a/src/components/logged-in/uploads/csv-config-dialog.tsx +++ b/src/components/logged-in/uploads/csv-config-dialog.tsx @@ -23,14 +23,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import type { CsvQuestion } from "@/db/schema"; import { useInvalidateUploads } from "@/hooks/use-uploads"; import { trpc } from "@/lib/trpc/client"; @@ -203,10 +195,10 @@ export function CsvConfigDialog({ return ( {children && {children}} - +
- Configure CSV Import + Configure CSV import We need a bit more context about {fileName} to extract transactions accurately. @@ -239,44 +231,6 @@ export function CsvConfigDialog({ {analysisData && !isAnalyzing && (
- {analysisData.preview.headers.length > 0 && ( -
- -
- - - - {analysisData.preview.headers.map((header, i) => ( - - {header} - - ))} - - - - {analysisData.preview.sampleRows.map( - (row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - {cell} - - ))} - - ) - )} - -
-
-
- )} - {analysisData.questions.length > 0 && (