From dfdcd733e23559cf92d49ef296a23139ba6004c9 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 04:57:17 +0000 Subject: [PATCH 1/9] feat: add transaction image scanner --- apps/api/src/api/endpoints.ts | 24 + apps/api/src/api/handlers/index.ts | 8 + .../api/src/api/handlers/transaction-scans.ts | 56 + apps/api/src/application/openai.test.ts | 72 +- apps/api/src/application/openai.ts | 41 +- .../src/application/transaction-scans.test.ts | 353 ++++++ apps/api/src/application/transaction-scans.ts | 792 +++++++++++++ apps/api/src/config.test.ts | 1 + apps/api/src/config.ts | 4 + .../db/migrations/014_transaction_scans.ts | 71 ++ apps/api/src/db/schemas.ts | 82 ++ apps/web/app/(app)/capture/page.tsx | 4 +- apps/web/components/forms/category-form.tsx | 23 +- .../components/transaction-scan-capture.tsx | 1022 +++++++++++++++++ apps/web/lib/actions.ts | 83 +- packages/contracts/src/api.test.ts | 2 + packages/contracts/src/api.ts | 27 + packages/contracts/src/schemas.test.ts | 66 ++ packages/contracts/src/schemas.ts | 196 ++++ 19 files changed, 2908 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/api/handlers/transaction-scans.ts create mode 100644 apps/api/src/application/transaction-scans.test.ts create mode 100644 apps/api/src/application/transaction-scans.ts create mode 100644 apps/api/src/db/migrations/014_transaction_scans.ts create mode 100644 apps/web/components/transaction-scan-capture.tsx diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index c1a64f1..049e0ab 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -297,6 +297,26 @@ export const DeleteTransactionEndpoint = api.transactions.delete .tags('transactions') .operationId('deleteTransaction'); +export const CreateTransactionScanEndpoint = api.transactionScans.create + .authorize(PrincipalSchema) + .inject({ db: DbToken, config: ConfigToken }) + .summary('Scan transaction image') + .description( + 'Extracts draft transactions from an uploaded receipt, invoice, bank app screenshot, or statement image.' + ) + .tags('transaction-scans') + .operationId('createTransactionScan'); + +export const DecideTransactionScanItemEndpoint = api.transactionScans.decide + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Record transaction scan decision') + .description( + 'Records whether a scanned draft was confirmed or discarded, including user corrections for future scans.' + ) + .tags('transaction-scans') + .operationId('decideTransactionScanItem'); + export const DashboardSummaryEndpoint = api.dashboard.summary .authorize(PrincipalSchema) .inject({ db: DbToken }) @@ -387,6 +407,10 @@ export const endpoints = { update: UpdateTransactionEndpoint, delete: DeleteTransactionEndpoint }, + transactionScans: { + create: CreateTransactionScanEndpoint, + decide: DecideTransactionScanItemEndpoint + }, dashboard: { summary: DashboardSummaryEndpoint, window: DashboardWindowEndpoint diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index 00d807f..b35363e 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -29,6 +29,10 @@ import { telegramStatusHandler, telegramTokenHandler } from './telegram.js'; +import { + createTransactionScanHandler, + decideTransactionScanItemHandler +} from './transaction-scans.js'; import { categoryTrendHandler, createTransactionHandler, @@ -100,6 +104,10 @@ export const handlers = { update: updateTransactionHandler, delete: deleteTransactionHandler }, + transactionScans: { + create: createTransactionScanHandler, + decide: decideTransactionScanItemHandler + }, dashboard: { summary: dashboardSummaryHandler, window: dashboardWindowHandler diff --git a/apps/api/src/api/handlers/transaction-scans.ts b/apps/api/src/api/handlers/transaction-scans.ts new file mode 100644 index 0000000..62c931d --- /dev/null +++ b/apps/api/src/api/handlers/transaction-scans.ts @@ -0,0 +1,56 @@ +import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + recordTransactionScanDecision, + scanTransactionsFromImage, + TransactionScanInputError, + TransactionScanNotFoundError +} from '../../application/transaction-scans.js'; +import type { + CreateTransactionScanEndpoint, + DecideTransactionScanItemEndpoint +} from '../endpoints.js'; + +export const createTransactionScanHandler: Handler< + typeof CreateTransactionScanEndpoint +> = async ({ body, principal }, { db, config }) => { + try { + const scan = await scanTransactionsFromImage( + db, + config, + principal.userId, + body + ); + return ActionResult.created( + scan, + `/api/transaction-scans/${scan.scanId}` + ); + } catch (err) { + if (err instanceof TransactionScanInputError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; + +export const decideTransactionScanItemHandler: Handler< + typeof DecideTransactionScanItemEndpoint +> = async ({ body, params, principal }, { db }) => { + try { + await recordTransactionScanDecision( + db, + principal.userId, + params.scanId, + params.itemId, + body + ); + return ActionResult.noContent(); + } catch (err) { + if (err instanceof TransactionScanInputError) { + return ActionResult.badRequest({ message: err.message }); + } + if (err instanceof TransactionScanNotFoundError) { + return ActionResult.notFound({ message: err.message }); + } + throw err; + } +}; diff --git a/apps/api/src/application/openai.test.ts b/apps/api/src/application/openai.test.ts index 58f0b20..7c5569b 100644 --- a/apps/api/src/application/openai.test.ts +++ b/apps/api/src/application/openai.test.ts @@ -1,11 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config.js'; -import { generateStructuredJson, OpenAIConfigError } from './openai.js'; +import { + generateStructuredJson, + generateStructuredJsonFromContent, + OpenAIConfigError +} from './openai.js'; const config = { openai: { apiKey: 'sk-test', - reportModel: 'gpt-5-mini' + reportModel: 'gpt-5-mini', + transactionScanModel: 'gpt-5.5' } } as Config; @@ -47,6 +52,69 @@ describe('generateStructuredJson', () => { }) }) ); + expect( + JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body)) + ).toMatchObject({ + store: false, + text: { + format: { + name: 'test_schema', + strict: true, + type: 'json_schema' + } + } + }); + }); + + it('requests structured JSON with image input content', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + output_text: JSON.stringify({ count: 2 }) + }) + } as Response); + + await expect( + generateStructuredJsonFromContent<{ count: number }>(config, { + content: [ + { type: 'input_text', text: 'Extract transactions.' }, + { + type: 'input_image', + image_url: 'data:image/png;base64,abc', + detail: 'original' + } + ], + model: config.openai.reportModel, + schema: { + type: 'object', + properties: { count: { type: 'number' } }, + required: ['count'], + additionalProperties: false + }, + schemaName: 'image_schema', + system: 'Write JSON.' + }) + ).resolves.toEqual({ count: 2 }); + + expect( + JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body)) + ).toMatchObject({ + input: [ + expect.any(Object), + { + role: 'user', + content: [ + { type: 'input_text', text: 'Extract transactions.' }, + { + type: 'input_image', + image_url: 'data:image/png;base64,abc', + detail: 'original' + } + ] + } + ] + }); }); it('throws when OpenAI is not configured', async () => { diff --git a/apps/api/src/application/openai.ts b/apps/api/src/application/openai.ts index f0daac7..9024116 100644 --- a/apps/api/src/application/openai.ts +++ b/apps/api/src/application/openai.ts @@ -12,6 +12,21 @@ type StructuredJsonOptions = { readonly system: string; }; +type InputContentPart = + | { + readonly text: string; + readonly type: 'input_text'; + } + | { + readonly detail?: 'auto' | 'high' | 'low' | 'original'; + readonly image_url: string; + readonly type: 'input_image'; + }; + +type StructuredJsonContentOptions = Omit & { + readonly content: readonly InputContentPart[]; +}; + function responseOutputText(json: unknown): string { if ( typeof json === 'object' && @@ -55,6 +70,24 @@ function responseOutputText(json: unknown): string { export async function generateStructuredJson( config: Config, options: StructuredJsonOptions +): Promise { + return generateStructuredJsonFromContent(config, { + content: [ + { + type: 'input_text', + text: JSON.stringify(options.input) + } + ], + model: options.model, + schema: options.schema, + schemaName: options.schemaName, + system: options.system + }); +} + +export async function generateStructuredJsonFromContent( + config: Config, + options: StructuredJsonContentOptions ): Promise { if (!config.openai.apiKey) { throw new OpenAIConfigError('OPENAI_API_KEY is not set.'); @@ -74,15 +107,11 @@ export async function generateStructuredJson( }, { role: 'user', - content: [ - { - type: 'input_text', - text: JSON.stringify(options.input) - } - ] + content: options.content } ], model: options.model, + store: false, text: { format: { name: options.schemaName, diff --git a/apps/api/src/application/transaction-scans.test.ts b/apps/api/src/application/transaction-scans.test.ts new file mode 100644 index 0000000..a253fa3 --- /dev/null +++ b/apps/api/src/application/transaction-scans.test.ts @@ -0,0 +1,353 @@ +import type { + Category, + Transaction, + TransactionScanDecisionBody, + Vendor +} from '@xpenser/contracts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config.js'; +import type { + AppDb, + TransactionDb, + TransactionScanItemDb, + UserDb +} from '../db/schemas.js'; +import { + recordTransactionScanDecision, + scanTransactionsFromImage +} from './transaction-scans.js'; + +const mocks = vi.hoisted(() => ({ + generateStructuredJsonFromContent: vi.fn(), + listCategories: vi.fn(), + listTransactions: vi.fn(), + listVendors: vi.fn() +})); + +vi.mock('./categories.js', () => ({ + listCategories: mocks.listCategories +})); + +vi.mock('./openai.js', () => ({ + generateStructuredJsonFromContent: mocks.generateStructuredJsonFromContent +})); + +vi.mock('./transactions.js', () => ({ + listTransactions: mocks.listTransactions +})); + +vi.mock('./vendors.js', () => ({ + listVendors: mocks.listVendors +})); + +const timestamp = new Date('2026-06-01T12:00:00.000Z'); + +type TestQuery = Promise & { + first: () => Promise; + update: (values: Partial) => Promise; + where: ( + selector: (row: T) => TValue, + value: TValue + ) => TestQuery; +}; + +function testQuery(rows: T[]): TestQuery { + const query = Promise.resolve(rows) as TestQuery; + query.where = (selector: (row: T) => TValue, value: TValue) => + testQuery(rows.filter(row => selector(row) === value)); + query.first = async () => rows[0]; + query.update = async (values: Partial) => { + for (const row of rows) { + Object.assign(row, values); + } + return rows; + }; + return query; +} + +function user(overrides: Partial = {}): UserDb { + return { + id: 1, + email: 'jane@example.com', + emailVerified: true, + role: 'user', + authProvider: 'local', + defaultCurrency: 'USD', + countryCode: 'US', + timezone: 'UTC', + weeklyEmailReportEnabled: true, + monthlyEmailReportEnabled: true, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function category(overrides: Partial = {}): Category { + return { + id: 7, + name: 'Groceries', + type: 'expense', + kind: 'normal', + parentId: null, + displayName: 'Groceries', + inUse: true, + hasChildren: false, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function vendor(overrides: Partial = {}): Vendor { + return { + id: 5, + name: 'Walmart', + displayName: 'Walmart', + domain: 'walmart.com', + transactionCount: 3, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function transaction(overrides: Partial = {}): Transaction { + return { + id: 99, + categoryId: 7, + vendorId: 5, + vendorName: 'Walmart', + categoryName: 'Groceries', + categoryDisplayName: 'Groceries', + categoryParentId: null, + categoryKind: 'normal', + type: 'expense', + amount: 12.34, + currency: 'USD', + defaultCurrencyAmount: 12.34, + defaultCurrency: 'USD', + exchangeRate: 1, + exchangeRateDate: '2026-06-01', + occurredAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function transactionRow(overrides: Partial = {}): TransactionDb { + return { + id: 99, + userId: 1, + categoryId: 7, + vendorId: 5, + type: 'expense', + amount: 12.34, + currency: 'USD', + defaultCurrencyAmount: 12.34, + defaultCurrency: 'USD', + exchangeRate: 1, + exchangeRateDate: '2026-06-01', + occurredAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function scanItem( + overrides: Partial = {} +): TransactionScanItemDb { + return { + id: 20, + scanId: 10, + userId: 1, + draftJson: '{}', + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function testDb({ + scanItems = [], + transactions = [], + users = [user()] +}: { + readonly scanItems?: TransactionScanItemDb[]; + readonly transactions?: TransactionDb[]; + readonly users?: UserDb[]; +} = {}): AppDb { + return { + users: { + find: vi.fn(async (id: number) => + users.find(candidate => candidate.id === id) + ) + }, + transactionScans: { + insert: vi.fn(async value => ({ + id: 10, + createdAt: timestamp, + updatedAt: timestamp, + ...value + })) + }, + transactionScanItems: { + where: vi.fn( + ( + selector: (row: TransactionScanItemDb) => TValue, + value: TValue + ) => testQuery(scanItems).where(selector, value) + ), + insert: vi.fn(async value => { + const created = scanItem({ + id: scanItems.length + 20, + ...value + }); + scanItems.push(created); + return created; + }) + }, + transactions: { + where: vi.fn( + ( + selector: (row: TransactionDb) => TValue, + value: TValue + ) => testQuery(transactions).where(selector, value) + ) + } + } as unknown as AppDb; +} + +const config = { + openai: { + apiKey: 'sk-test', + reportModel: 'gpt-5-mini', + transactionScanModel: 'gpt-5.5' + } +} as Config; + +describe('transaction image scans', () => { + afterEach(() => { + vi.restoreAllMocks(); + mocks.generateStructuredJsonFromContent.mockReset(); + mocks.listCategories.mockReset(); + mocks.listTransactions.mockReset(); + mocks.listVendors.mockReset(); + }); + + it('creates sanitized draft transactions from image scan output', async () => { + mocks.listCategories.mockResolvedValue([category()]); + mocks.listVendors.mockResolvedValue([vendor()]); + mocks.listTransactions.mockResolvedValue({ items: [transaction()] }); + mocks.generateStructuredJsonFromContent.mockResolvedValue({ + documentKind: 'receipt', + warnings: ['Check tax lines.'], + transactions: [ + { + amount: 12.34, + categoryId: 7, + confidence: { + amount: 'high', + category: 'medium', + currency: 'high', + date: 'medium', + overall: 'medium', + vendor: 'high' + }, + currency: 'usd', + evidence: 'Walmart 12.34', + note: 'Receipt total', + occurredDate: '2026-06-01', + occurredTime: null, + suggestedCategoryKind: null, + suggestedCategoryName: null, + suggestedCategoryParentId: null, + suggestedCategoryReason: null, + suggestedCategoryType: null, + suggestedVendorName: null, + transactionType: 'expense', + vendorId: 5 + } + ] + }); + + const scanItems: TransactionScanItemDb[] = []; + const db = testDb({ scanItems }); + const result = await scanTransactionsFromImage(db, config, 1, { + imageBase64: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + fileName: 'receipt.png' + }); + + expect(result).toMatchObject({ + scanId: 10, + documentKind: 'receipt', + warnings: ['Check tax lines.'], + drafts: [ + { + id: 20, + amount: 12.34, + categoryId: 7, + currency: 'USD', + vendorId: 5, + possibleDuplicateTransactionIds: [99] + } + ] + }); + expect(scanItems).toHaveLength(1); + expect(JSON.parse(scanItems[0]?.draftJson ?? '{}')).toMatchObject({ + amount: 12.34, + categoryId: 7, + currency: 'USD', + vendorId: 5 + }); + expect( + mocks.generateStructuredJsonFromContent.mock.calls[0]?.[1].content + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + image_url: expect.stringContaining( + 'data:image/png;base64,' + ), + type: 'input_image' + }) + ]) + ); + }); + + it('records confirmed scan corrections', async () => { + const item = scanItem(); + const db = testDb({ + scanItems: [item], + transactions: [transactionRow({ id: 42 })] + }); + const body: TransactionScanDecisionBody = { + decision: 'confirmed', + transactionId: 42, + correctedTransaction: { + amount: 19.99, + categoryId: 7, + currency: 'USD', + occurredAt: timestamp, + vendorId: 5, + note: 'Corrected' + } + }; + + await recordTransactionScanDecision(db, 1, 10, 20, body); + + expect(item).toMatchObject({ + decision: 'confirmed', + transactionId: 42 + }); + expect(JSON.parse(item.correctedJson ?? '{}')).toMatchObject({ + amount: 19.99, + categoryId: 7, + note: 'Corrected' + }); + expect(item.decidedAt).toBeInstanceOf(Date); + }); +}); diff --git a/apps/api/src/application/transaction-scans.ts b/apps/api/src/application/transaction-scans.ts new file mode 100644 index 0000000..c191fda --- /dev/null +++ b/apps/api/src/application/transaction-scans.ts @@ -0,0 +1,792 @@ +import { createHash } from 'node:crypto'; +import type { + Category, + Transaction, + TransactionScanBody, + TransactionScanDecisionBody, + TransactionScanDraft, + TransactionScanResponse, + Vendor +} from '@xpenser/contracts'; +import { FieldLimits } from '@xpenser/contracts'; +import { + dateToLocalDateParam, + localDateTimeInputToDate +} from '@xpenser/timezone'; +import type { Config } from '../config.js'; +import type { AppDb, TransactionScanItemDb, UserDb } from '../db/schemas.js'; +import { listCategories } from './categories.js'; +import { generateStructuredJsonFromContent } from './openai.js'; +import { listTransactions } from './transactions.js'; +import { listVendors } from './vendors.js'; + +export class TransactionScanInputError extends Error {} +export class TransactionScanNotFoundError extends Error {} + +const maxImageBytes = 10 * 1024 * 1024; +const maxDrafts = 25; +const maxContextVendors = 100; +const maxRecentTransactions = 100; +const maxCorrectionExamples = 10; + +const confidenceValues = new Set(['high', 'medium', 'low']); +const documentKinds = new Set([ + 'bank_app', + 'bank_statement', + 'invoice', + 'receipt', + 'other' +]); + +type Confidence = 'high' | 'low' | 'medium'; +type TransactionType = 'expense' | 'income'; + +type RawFieldConfidence = { + readonly amount?: unknown; + readonly category?: unknown; + readonly currency?: unknown; + readonly date?: unknown; + readonly overall?: unknown; + readonly vendor?: unknown; +}; + +type RawScannedTransaction = { + readonly amount?: unknown; + readonly categoryId?: unknown; + readonly confidence?: RawFieldConfidence; + readonly currency?: unknown; + readonly evidence?: unknown; + readonly note?: unknown; + readonly occurredDate?: unknown; + readonly occurredTime?: unknown; + readonly suggestedCategoryKind?: unknown; + readonly suggestedCategoryName?: unknown; + readonly suggestedCategoryParentId?: unknown; + readonly suggestedCategoryReason?: unknown; + readonly suggestedCategoryType?: unknown; + readonly suggestedVendorName?: unknown; + readonly transactionType?: unknown; + readonly vendorId?: unknown; +}; + +type RawScanResult = { + readonly documentKind?: unknown; + readonly transactions?: unknown; + readonly warnings?: unknown; +}; + +type PromptCategory = Pick< + Category, + 'displayName' | 'id' | 'kind' | 'name' | 'parentId' | 'parentName' | 'type' +> & { + readonly effectiveType: TransactionType; +}; + +type PromptVendor = Pick< + Vendor, + | 'displayName' + | 'domain' + | 'id' + | 'name' + | 'resolvedName' + | 'suggestedCategoryDisplayName' + | 'suggestedCategoryId' + | 'transactionCount' +>; + +type CorrectionExample = { + readonly decision: string; + readonly draft: unknown; + readonly corrected: unknown; +}; + +type TransactionScanItemQuery = Promise & { + readonly first: () => Promise; + readonly update: ( + values: Partial + ) => Promise; + readonly where: ( + selector: (row: TransactionScanItemDb) => TValue, + value: TValue + ) => TransactionScanItemQuery; +}; + +type TransactionScanItemTable = { + readonly insert: ( + value: Pick + ) => Promise; + readonly where: ( + selector: (row: TransactionScanItemDb) => TValue, + value: TValue + ) => TransactionScanItemQuery; +}; + +function scanItemTable(db: AppDb): TransactionScanItemTable { + return db.transactionScanItems as unknown as TransactionScanItemTable; +} + +const scanResultSchema = { + additionalProperties: false, + properties: { + documentKind: { + enum: ['bank_app', 'bank_statement', 'invoice', 'receipt', 'other'], + type: 'string' + }, + warnings: { + items: { type: 'string' }, + type: 'array' + }, + transactions: { + items: { + additionalProperties: false, + properties: { + amount: { type: ['number', 'null'] }, + categoryId: { type: ['number', 'null'] }, + confidence: { + additionalProperties: false, + properties: { + amount: { + enum: ['high', 'medium', 'low'], + type: 'string' + }, + category: { + enum: ['high', 'medium', 'low'], + type: 'string' + }, + currency: { + enum: ['high', 'medium', 'low'], + type: 'string' + }, + date: { + enum: ['high', 'medium', 'low'], + type: 'string' + }, + overall: { + enum: ['high', 'medium', 'low'], + type: 'string' + }, + vendor: { + enum: ['high', 'medium', 'low'], + type: 'string' + } + }, + required: [ + 'amount', + 'category', + 'currency', + 'date', + 'overall', + 'vendor' + ], + type: 'object' + }, + currency: { type: ['string', 'null'] }, + evidence: { type: 'string' }, + note: { type: ['string', 'null'] }, + occurredDate: { type: ['string', 'null'] }, + occurredTime: { type: ['string', 'null'] }, + suggestedCategoryKind: { + enum: ['normal', 'offset', null] + }, + suggestedCategoryName: { type: ['string', 'null'] }, + suggestedCategoryParentId: { type: ['number', 'null'] }, + suggestedCategoryReason: { type: ['string', 'null'] }, + suggestedCategoryType: { + enum: ['expense', 'income', null] + }, + suggestedVendorName: { type: ['string', 'null'] }, + transactionType: { + enum: ['expense', 'income'] + }, + vendorId: { type: ['number', 'null'] } + }, + required: [ + 'amount', + 'categoryId', + 'confidence', + 'currency', + 'evidence', + 'note', + 'occurredDate', + 'occurredTime', + 'suggestedCategoryKind', + 'suggestedCategoryName', + 'suggestedCategoryParentId', + 'suggestedCategoryReason', + 'suggestedCategoryType', + 'suggestedVendorName', + 'transactionType', + 'vendorId' + ], + type: 'object' + }, + type: 'array' + } + }, + required: ['documentKind', 'warnings', 'transactions'], + type: 'object' +} as const; + +function stripDataUrl(value: string): string { + const commaIndex = value.indexOf(','); + return value.startsWith('data:') && commaIndex >= 0 + ? value.slice(commaIndex + 1) + : value; +} + +function imageBuffer(body: TransactionScanBody): Buffer { + try { + const buffer = Buffer.from(stripDataUrl(body.imageBase64), 'base64'); + if (buffer.length === 0) { + throw new TransactionScanInputError('Upload a non-empty image.'); + } + if (buffer.length > maxImageBytes) { + throw new TransactionScanInputError( + 'Image must be 10 MB or smaller.' + ); + } + return buffer; + } catch (err) { + if (err instanceof TransactionScanInputError) { + throw err; + } + throw new TransactionScanInputError('Upload a valid image.'); + } +} + +function oneLine(value: string, maxLength: number): string { + return value.replace(/\s+/g, ' ').trim().slice(0, maxLength); +} + +function stringValue(value: unknown, maxLength = 200): string | null { + return typeof value === 'string' && value.trim() + ? oneLine(value, maxLength) + : null; +} + +function numberValue(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return value; +} + +function idValue( + value: unknown, + allowedIds: ReadonlySet +): number | null { + const id = numberValue(value); + return id !== null && allowedIds.has(id) ? id : null; +} + +function confidence(value: unknown): Confidence { + return typeof value === 'string' && confidenceValues.has(value) + ? (value as Confidence) + : 'low'; +} + +function effectiveCategoryType(category: Pick) { + if (category.kind !== 'offset') { + return category.type; + } + return category.type === 'expense' ? 'income' : 'expense'; +} + +function categoryById( + categories: readonly Category[], + categoryId: number | null +): Category | undefined { + return categoryId === null + ? undefined + : categories.find(category => category.id === categoryId); +} + +function transactionType( + value: unknown, + category: Category | undefined +): TransactionType { + if (category) { + return effectiveCategoryType(category); + } + return value === 'income' ? 'income' : 'expense'; +} + +function currencyValue(value: unknown, fallback: string): string { + const currency = stringValue(value, 3)?.toUpperCase(); + return currency && /^[A-Z]{3}$/.test(currency) ? currency : fallback; +} + +function dateValue( + rawDate: unknown, + rawTime: unknown, + timezone: string +): Date | null { + const date = stringValue(rawDate, 10); + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return null; + } + const time = stringValue(rawTime, 5); + const localTime = time && /^\d{2}:\d{2}$/.test(time) ? time : '12:00'; + return localDateTimeInputToDate(`${date}T${localTime}`, timezone) ?? null; +} + +function suggestedCategory( + raw: RawScannedTransaction, + type: TransactionType, + categories: readonly Category[] +): TransactionScanDraft['suggestedCategory'] { + const name = stringValue( + raw.suggestedCategoryName, + FieldLimits.categoryName + ); + if (!name) { + return null; + } + const suggestedType = + raw.suggestedCategoryType === 'income' || + raw.suggestedCategoryType === 'expense' + ? raw.suggestedCategoryType + : type; + const parentId = idValue( + raw.suggestedCategoryParentId, + new Set( + categories + .filter( + category => + category.parentId === null && + category.type === suggestedType + ) + .map(category => category.id) + ) + ); + + return { + name, + type: suggestedType, + parentId, + kind: + parentId !== null && raw.suggestedCategoryKind === 'offset' + ? 'offset' + : 'normal', + reason: + stringValue(raw.suggestedCategoryReason, 160) ?? + 'No existing category looked like a strong fit.' + }; +} + +function possibleDuplicates({ + categories, + draft, + recentTransactions, + timezone +}: { + readonly categories: readonly Category[]; + readonly draft: Omit; + readonly recentTransactions: readonly Transaction[]; + readonly timezone: string; +}): number[] { + if (!draft.amount || !draft.currency || !draft.occurredAt) { + return []; + } + + const draftDate = dateToLocalDateParam(draft.occurredAt, timezone); + const category = categoryById(categories, draft.categoryId); + const type = category + ? effectiveCategoryType(category) + : draft.transactionType; + + return recentTransactions + .filter(transaction => { + if (Math.abs(transaction.amount - draft.amount!) > 0.005) { + return false; + } + if (transaction.currency !== draft.currency) { + return false; + } + if ( + dateToLocalDateParam(transaction.occurredAt, timezone) !== + draftDate + ) { + return false; + } + if (transaction.type !== type) { + return false; + } + if ( + draft.vendorId !== null && + transaction.vendorId !== draft.vendorId + ) { + return false; + } + return true; + }) + .slice(0, 3) + .map(transaction => transaction.id); +} + +function sanitizeDraft({ + categories, + defaultCurrency, + raw, + recentTransactions, + timezone, + vendors +}: { + readonly categories: readonly Category[]; + readonly defaultCurrency: string; + readonly raw: RawScannedTransaction; + readonly recentTransactions: readonly Transaction[]; + readonly timezone: string; + readonly vendors: readonly Vendor[]; +}): Omit | undefined { + const categoryIds = new Set(categories.map(category => category.id)); + const vendorIds = new Set(vendors.map(vendor => vendor.id)); + const categoryId = idValue(raw.categoryId, categoryIds); + const vendorId = idValue(raw.vendorId, vendorIds); + const category = categoryById(categories, categoryId); + const type = transactionType(raw.transactionType, category); + const amount = numberValue(raw.amount); + const safeAmount = amount !== null && amount > 0 ? amount : null; + const fieldConfidence = raw.confidence ?? {}; + const draft = { + amount: safeAmount, + categoryId, + suggestedCategory: + categoryId === null + ? suggestedCategory(raw, type, categories) + : null, + currency: currencyValue(raw.currency, defaultCurrency), + occurredAt: dateValue(raw.occurredDate, raw.occurredTime, timezone), + vendorId, + suggestedVendorName: + vendorId === null + ? stringValue(raw.suggestedVendorName, FieldLimits.vendorName) + : null, + transactionType: type, + note: stringValue(raw.note, FieldLimits.transactionNote), + evidence: stringValue(raw.evidence, 500) ?? '', + confidence: { + amount: confidence(fieldConfidence.amount), + category: confidence(fieldConfidence.category), + currency: raw.currency + ? confidence(fieldConfidence.currency) + : 'low', + date: raw.occurredDate ? confidence(fieldConfidence.date) : 'low', + overall: confidence(fieldConfidence.overall), + vendor: confidence(fieldConfidence.vendor) + }, + possibleDuplicateTransactionIds: [] + } satisfies Omit; + + if (!draft.evidence && !draft.amount && !draft.note) { + return undefined; + } + + return { + ...draft, + possibleDuplicateTransactionIds: possibleDuplicates({ + categories, + draft, + recentTransactions, + timezone + }) + }; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +async function getUser(db: AppDb, userId: number): Promise { + const user = await db.users.find(userId); + if (!user) { + throw new TransactionScanInputError('User was not found.'); + } + return user as UserDb; +} + +async function correctionExamples( + db: AppDb, + userId: number +): Promise { + const rows = await scanItemTable(db).where(item => item.userId, userId); + + return rows + .filter(row => row.decision) + .sort( + (left, right) => + (right.decidedAt?.getTime() ?? 0) - + (left.decidedAt?.getTime() ?? 0) + ) + .slice(0, maxCorrectionExamples) + .map(row => ({ + decision: row.decision ?? '', + draft: parseJson(row.draftJson), + corrected: row.correctedJson ? parseJson(row.correctedJson) : null + })); +} + +function promptCategories(categories: readonly Category[]): PromptCategory[] { + return categories.map(category => ({ + id: category.id, + name: category.name, + displayName: category.displayName, + parentId: category.parentId, + parentName: category.parentName, + type: category.type, + kind: category.kind, + effectiveType: effectiveCategoryType(category) + })); +} + +function promptVendors(vendors: readonly Vendor[]): PromptVendor[] { + return vendors.map(vendor => ({ + id: vendor.id, + name: vendor.name, + displayName: vendor.displayName, + resolvedName: vendor.resolvedName, + domain: vendor.domain, + suggestedCategoryId: vendor.suggestedCategoryId, + suggestedCategoryDisplayName: vendor.suggestedCategoryDisplayName, + transactionCount: vendor.transactionCount + })); +} + +function promptTransactions( + transactions: readonly Transaction[], + timezone: string +) { + return transactions.map(transaction => ({ + id: transaction.id, + amount: transaction.amount, + currency: transaction.currency, + localDate: dateToLocalDateParam(transaction.occurredAt, timezone), + type: transaction.type, + categoryId: transaction.categoryId, + category: transaction.categoryDisplayName, + vendorId: transaction.vendorId, + vendorName: transaction.vendorName, + note: transaction.note + })); +} + +function scanPrompt() { + return [ + 'You extract draft personal-finance transactions from one uploaded image.', + 'The image may be a receipt, invoice, bank app screenshot, or bank statement.', + 'Return one or more transactions. Split one receipt into multiple transactions when visible line items clearly belong to different categories or vendors.', + 'Do not create transactions, categories, or vendors. Only choose existing IDs from context or suggest names for the user to create later.', + 'Prefer existing category IDs and vendor IDs when they are plausible. Suggest a new category or vendor only when no existing record is a good fit.', + 'Use positive amounts only. Infer expense/income through transactionType and category fit; do not encode signs in amount.', + 'If a field is not visible or not reliable, return null for that field and low confidence.', + 'Use correction examples as user-specific preferences and avoid repeating prior mistakes.', + 'Use only facts visible in the image and the provided context. Do not invent vendors, dates, currencies, amounts, or line items.' + ].join(' '); +} + +function promptInput({ + categories, + correctionExamples, + recentTransactions, + user, + vendors +}: { + readonly categories: readonly Category[]; + readonly correctionExamples: readonly CorrectionExample[]; + readonly recentTransactions: readonly Transaction[]; + readonly user: UserDb; + readonly vendors: readonly Vendor[]; +}) { + return { + user: { + defaultCurrency: user.defaultCurrency, + timezone: user.timezone, + localToday: dateToLocalDateParam(new Date(), user.timezone) + }, + categories: promptCategories(categories), + vendors: promptVendors(vendors), + recentTransactions: promptTransactions( + recentTransactions, + user.timezone + ), + correctionExamples, + outputRules: { + occurredDate: + 'Use YYYY-MM-DD when visible or inferable from the document; otherwise null.', + occurredTime: 'Use HH:mm when visible; otherwise null.', + categoryId: 'Use only IDs from categories.', + vendorId: 'Use only IDs from vendors.', + suggestedCategoryParentId: + 'Use only a parent category ID from categories, or null.', + maxTransactions: maxDrafts + } + }; +} + +function documentKind(value: unknown): TransactionScanResponse['documentKind'] { + return typeof value === 'string' && documentKinds.has(value) + ? (value as TransactionScanResponse['documentKind']) + : 'other'; +} + +function warnings(value: unknown): string[] { + return Array.isArray(value) + ? value + .map(item => stringValue(item, 200)) + .filter((item): item is string => item !== null) + .slice(0, 8) + : []; +} + +export async function scanTransactionsFromImage( + db: AppDb, + config: Config, + userId: number, + body: TransactionScanBody +): Promise { + const buffer = imageBuffer(body); + const imageHash = createHash('sha256').update(buffer).digest('hex'); + const user = await getUser(db, userId); + const [categories, vendors, recentTransactions, examples] = + await Promise.all([ + listCategories(db, userId, { activeOnly: true }), + listVendors(db, userId, { limit: maxContextVendors }), + listTransactions(db, userId, { + direction: 'desc', + limit: maxRecentTransactions, + page: 1 + }).then(response => response.items), + correctionExamples(db, userId) + ]); + + const parsed = await generateStructuredJsonFromContent( + config, + { + content: [ + { + type: 'input_text', + text: JSON.stringify( + promptInput({ + categories, + correctionExamples: examples, + recentTransactions, + user, + vendors + }) + ) + }, + { + type: 'input_image', + image_url: `data:${body.mimeType};base64,${stripDataUrl( + body.imageBase64 + )}`, + detail: 'original' + } + ], + model: config.openai.transactionScanModel, + schema: scanResultSchema, + schemaName: 'transaction_image_scan', + system: scanPrompt() + } + ); + + const scan = await db.transactionScans.insert({ + userId, + documentKind: documentKind(parsed.documentKind), + imageHash, + model: config.openai.transactionScanModel, + warningsJson: JSON.stringify(warnings(parsed.warnings)) + }); + + const rawTransactions = Array.isArray(parsed.transactions) + ? (parsed.transactions as RawScannedTransaction[]) + : []; + const drafts: TransactionScanDraft[] = []; + + for (const raw of rawTransactions.slice(0, maxDrafts)) { + const sanitized = sanitizeDraft({ + categories, + defaultCurrency: user.defaultCurrency, + raw, + recentTransactions, + timezone: user.timezone, + vendors + }); + if (!sanitized) { + continue; + } + + const item = await scanItemTable(db).insert({ + scanId: scan.id, + userId, + draftJson: JSON.stringify(sanitized) + }); + drafts.push({ ...sanitized, id: item.id }); + } + + return { + scanId: scan.id, + documentKind: documentKind(parsed.documentKind), + warnings: warnings(parsed.warnings), + drafts + }; +} + +async function ensureTransactionOwner( + db: AppDb, + userId: number, + transactionId: number +): Promise { + const transaction = await db.transactions + .where(row => row.id, transactionId) + .where(row => row.userId, userId) + .first(); + if (!transaction) { + throw new TransactionScanInputError('Transaction was not found.'); + } +} + +export async function recordTransactionScanDecision( + db: AppDb, + userId: number, + scanId: number, + itemId: number, + body: TransactionScanDecisionBody +): Promise { + const item = await scanItemTable(db) + .where(row => row.id, itemId) + .where(row => row.scanId, scanId) + .where(row => row.userId, userId) + .first(); + if (!item) { + throw new TransactionScanNotFoundError('Scan item was not found.'); + } + + if (body.decision === 'confirmed') { + if (!body.transactionId || !body.correctedTransaction) { + throw new TransactionScanInputError( + 'Confirmed scan items require a transaction and corrected values.' + ); + } + await ensureTransactionOwner(db, userId, body.transactionId); + } + + await scanItemTable(db) + .where(row => row.id, itemId) + .where(row => row.userId, userId) + .update({ + decision: body.decision, + correctedJson: body.correctedTransaction + ? JSON.stringify(body.correctedTransaction) + : (null as never), + transactionId: (body.transactionId ?? null) as never, + createdCategoryId: (body.createdCategoryId ?? null) as never, + createdVendorId: (body.createdVendorId ?? null) as never, + decidedAt: new Date(), + updatedAt: new Date() + }); +} diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts index 7873dcb..1d7e5a8 100644 --- a/apps/api/src/config.test.ts +++ b/apps/api/src/config.test.ts @@ -47,6 +47,7 @@ describe('API config', () => { expect(config.emailConfirmation.tokenTtlSeconds).toBe(86_400); expect(config.resend.emailFrom).toBe('Xpenser '); expect(config.openai.reportModel).toBe('gpt-5-mini'); + expect(config.openai.transactionScanModel).toBe('gpt-5.5'); }); it('normalizes vendor enrichment feature flags', async () => { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index ea6eae6..3ecb935 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -94,6 +94,10 @@ export const config = parseEnv( reportModel: env( 'OPENAI_REPORT_MODEL', string().default('gpt-5-mini') + ), + transactionScanModel: env( + 'OPENAI_TRANSACTION_SCAN_MODEL', + string().default('gpt-5.5') ) }, resend: { diff --git a/apps/api/src/db/migrations/014_transaction_scans.ts b/apps/api/src/db/migrations/014_transaction_scans.ts new file mode 100644 index 0000000..ab9e2e4 --- /dev/null +++ b/apps/api/src/db/migrations/014_transaction_scans.ts @@ -0,0 +1,71 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('transaction_scans', table => { + table.increments('id').primary(); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('document_kind', 32).notNullable(); + table.string('image_hash', 128).notNullable(); + table.string('model', 128).notNullable(); + table.text('warnings_json').notNullable(); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table.index(['user_id'], 'idx_transaction_scans_user_id'); + table.index(['user_id', 'created_at'], 'idx_transaction_scans_recent'); + }); + + await knex.schema.createTable('transaction_scan_items', table => { + table.increments('id').primary(); + table + .integer('scan_id') + .notNullable() + .references('id') + .inTable('transaction_scans') + .onDelete('CASCADE'); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.text('draft_json').notNullable(); + table.string('decision', 32); + table.text('corrected_json'); + table + .integer('transaction_id') + .references('id') + .inTable('transactions') + .onDelete('SET NULL'); + table + .integer('created_category_id') + .references('id') + .inTable('categories') + .onDelete('SET NULL'); + table + .integer('created_vendor_id') + .references('id') + .inTable('vendors') + .onDelete('SET NULL'); + table.timestamp('decided_at'); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table.index(['scan_id'], 'idx_transaction_scan_items_scan_id'); + table.index(['user_id'], 'idx_transaction_scan_items_user_id'); + table.index( + ['user_id', 'decided_at'], + 'idx_transaction_scan_items_recent_decisions' + ); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('transaction_scan_items'); + await knex.schema.dropTableIfExists('transaction_scans'); +} diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index fc7807a..3d8c948 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -197,6 +197,56 @@ export const TransactionDbSchema = object({ category: CategoryDbSchema.optional() }).hasTableName('transactions'); +export const TransactionScanDbSchema = object({ + id: number().primaryKey(), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scans_user_id'), + documentKind: string().hasColumnName('document_kind'), + imageHash: string().hasColumnName('image_hash'), + model: string(), + warningsJson: string().hasColumnName('warnings_json'), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}).hasTableName('transaction_scans'); + +export const TransactionScanItemDbSchema = object({ + id: number().primaryKey(), + scanId: number() + .hasColumnName('scan_id') + .references('transaction_scans', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_items_scan_id'), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_items_user_id'), + draftJson: string().hasColumnName('draft_json'), + decision: string().optional(), + correctedJson: string().hasColumnName('corrected_json').optional(), + transactionId: number() + .hasColumnName('transaction_id') + .references('transactions', 'id') + .onDelete('SET NULL') + .optional(), + createdCategoryId: number() + .hasColumnName('created_category_id') + .references('categories', 'id') + .onDelete('SET NULL') + .optional(), + createdVendorId: number() + .hasColumnName('created_vendor_id') + .references('vendors', 'id') + .onDelete('SET NULL') + .optional(), + decidedAt: date().hasColumnName('decided_at').optional(), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}).hasTableName('transaction_scan_items'); + export const ExchangeRateDbSchema = object({ id: number().primaryKey(), baseCurrency: string().hasColumnName('base_currency'), @@ -233,6 +283,10 @@ export const TransactionEntity = defineEntity(TransactionDbSchema).belongsTo( l => l.categoryId, r => r.id ); +export const TransactionScanEntity = defineEntity(TransactionScanDbSchema); +export const TransactionScanItemEntity = defineEntity( + TransactionScanItemDbSchema +); export const ExchangeRateEntity = defineEntity(ExchangeRateDbSchema); export const entityMap = { @@ -245,6 +299,8 @@ export const entityMap = { categories: CategoryEntity, vendors: VendorEntity, transactions: TransactionEntity, + transactionScans: TransactionScanEntity, + transactionScanItems: TransactionScanItemEntity, exchangeRates: ExchangeRateEntity }; @@ -355,3 +411,29 @@ export type TransactionDb = { readonly createdAt: Date; readonly updatedAt: Date; }; + +export type TransactionScanDb = { + readonly id: number; + readonly userId: number; + readonly documentKind: string; + readonly imageHash: string; + readonly model: string; + readonly warningsJson: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}; + +export type TransactionScanItemDb = { + readonly id: number; + readonly scanId: number; + readonly userId: number; + readonly draftJson: string; + readonly decision?: string | null; + readonly correctedJson?: string | null; + readonly transactionId?: number | null; + readonly createdCategoryId?: number | null; + readonly createdVendorId?: number | null; + readonly decidedAt?: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +}; diff --git a/apps/web/app/(app)/capture/page.tsx b/apps/web/app/(app)/capture/page.tsx index d6fa51a..aeac169 100644 --- a/apps/web/app/(app)/capture/page.tsx +++ b/apps/web/app/(app)/capture/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { QuickCaptureForm } from '@/components/quick-capture-form'; +import { TransactionCaptureWorkspace } from '@/components/transaction-scan-capture'; import { getApiClient } from '@/lib/api'; import { categoriesByRecentUse } from '@/lib/capture-suggestions'; @@ -24,7 +24,7 @@ export default async function CapturePage() { return (
- ; readonly namePlaceholder?: string; - readonly onSaved?: () => void; + readonly onSaved?: (category?: Category) => void; readonly submitLabel?: string; }) { const form = useSchemaForm(CreateCategoryBodySchema); @@ -59,12 +64,15 @@ export function CategoryForm({ const offsetKindLabel = selectedType === 'expense' ? 'Return' : 'Expense'; useEffect(() => { - const nextType = initialCategory?.type ?? 'expense'; - const nextParentId = initialCategory?.parentId ?? null; - const nextKind = initialCategory?.kind ?? 'normal'; + const nextType = + initialCategory?.type ?? initialValues?.type ?? 'expense'; + const nextParentId = + initialCategory?.parentId ?? initialValues?.parentId ?? null; + const nextKind = + initialCategory?.kind ?? initialValues?.kind ?? 'normal'; form.reset({ - name: initialCategory?.name, + name: initialCategory?.name ?? initialValues?.name, type: nextType, parentId: nextParentId, kind: nextKind @@ -73,7 +81,7 @@ export function CategoryForm({ setSelectedParentId(nextParentId); setSelectedKind(nextKind); setFormVersion(version => version + 1); - }, [form, initialCategory]); + }, [form, initialCategory, initialValues]); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -100,9 +108,10 @@ export function CategoryForm({ router.refresh(); onSaved?.(); } else { - await createCategoryAction(formData); + const category = await createCategoryAction(formData); form.reset({ type: 'expense', parentId: null, kind: 'normal' }); router.refresh(); + onSaved?.(category); } } catch (caught) { if (isNextRedirectError(caught)) { diff --git a/apps/web/components/transaction-scan-capture.tsx b/apps/web/components/transaction-scan-capture.tsx new file mode 100644 index 0000000..1caed7d --- /dev/null +++ b/apps/web/components/transaction-scan-capture.tsx @@ -0,0 +1,1022 @@ +'use client'; + +import type { + Category, + Currency, + Transaction, + TransactionScanDraft, + TransactionScanResponse, + Vendor +} from '@xpenser/contracts'; +import { + dateToLocalDateTimeInput, + localDateTimeInputToDate +} from '@xpenser/timezone'; +import { + Button, + Card, + CardContent, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + Field, + FieldError, + FieldGroup, + FieldLabel, + Input, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@xpenser/ui'; +import { + AlertCircleIcon, + CheckCircle2Icon, + ChevronLeftIcon, + ChevronRightIcon, + ImageUpIcon, + PlusIcon, + ScanLineIcon, + Trash2Icon +} from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useMemo, useState } from 'react'; +import { + createCaptureTransactionAction, + createVendorAction, + recordTransactionScanDecisionAction, + scanTransactionImageAction +} from '@/lib/actions'; +import { + categoryEffectiveType, + categoryTypeLabel, + transactionCategoryOptions +} from '@/lib/category-display'; +import { formatDateTime, formatTransactionMoney } from '@/lib/format'; +import { transactionCurrencyOptions } from '@/lib/transaction-currencies'; +import { CategoryForm } from './forms/category-form'; +import { QuickCaptureForm } from './quick-capture-form'; +import { VendorPicker } from './vendor-picker'; + +type CaptureMode = 'manual' | 'scan'; +type Decision = 'confirmed' | 'discarded'; +type TransactionType = Category['type']; + +const maxImageBytes = 10 * 1024 * 1024; + +function parseAmount(value: string): number | undefined { + const normalized = value.trim().replace(',', '.'); + if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) { + return undefined; + } + + const amount = Number(normalized); + return Number.isFinite(amount) && amount > 0 ? amount : undefined; +} + +function firstCategoryId( + categories: readonly Category[], + type: TransactionType +): number | undefined { + return categories.find(category => categoryEffectiveType(category) === type) + ?.id; +} + +function draftCategoryType( + draft: TransactionScanDraft, + categories: readonly Category[] +): TransactionType { + const category = categories.find(item => item.id === draft.categoryId); + return category ? categoryEffectiveType(category) : draft.transactionType; +} + +function initialValues({ + categories, + defaultCurrency, + draft, + timezone +}: { + readonly categories: readonly Category[]; + readonly defaultCurrency: string; + readonly draft: TransactionScanDraft; + readonly timezone: string; +}) { + const type = draftCategoryType(draft, categories); + return { + amount: draft.amount ? String(draft.amount) : '', + categoryId: draft.categoryId ?? firstCategoryId(categories, type), + currency: draft.currency ?? defaultCurrency, + occurredAtText: dateToLocalDateTimeInput( + draft.occurredAt ?? new Date(), + timezone + ), + note: draft.note ?? '', + type, + vendorId: draft.vendorId + }; +} + +function confidenceLabel(value: string): string { + return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`; +} + +function savedSummary(transaction: Transaction, timezone: string) { + const vendor = transaction.vendorName ? `${transaction.vendorName} - ` : ''; + return `${vendor}${transaction.categoryDisplayName} - ${formatTransactionMoney( + transaction.amount, + transaction.currency, + transaction.type, + transaction.categoryKind + )} - ${formatDateTime(transaction.occurredAt, timezone)}`; +} + +function nextPendingIndex( + drafts: readonly TransactionScanDraft[], + decisions: Readonly>, + currentIndex: number +): number | undefined { + const afterCurrent = drafts.findIndex( + (draft, index) => index > currentIndex && !decisions[draft.id] + ); + if (afterCurrent >= 0) { + return afterCurrent; + } + const beforeCurrent = drafts.findIndex(draft => !decisions[draft.id]); + return beforeCurrent >= 0 ? beforeCurrent : undefined; +} + +function ScanUpload({ + onScanned +}: { + readonly onScanned: (scan: TransactionScanResponse) => void; +}) { + const [file, setFile] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!file) { + setError('Choose an image to scan.'); + return; + } + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { + setError('Upload a PNG, JPEG, or WebP image.'); + return; + } + if (file.size > maxImageBytes) { + setError('Image must be 10 MB or smaller.'); + return; + } + + const formData = new FormData(); + formData.set('image', file); + + setPending(true); + setError(null); + try { + const result = await scanTransactionImageAction(formData); + if (result.error) { + setError(result.error); + return; + } + if (!result.scan) { + setError('Could not scan the image. Try again.'); + return; + } + onScanned(result.scan); + } catch { + setError('Could not scan the image. Try again.'); + } finally { + setPending(false); + } + } + + return ( + + +
+ + + Invoice, receipt, or bank screenshot + + + setFile(event.target.files?.[0] ?? null) + } + type="file" + /> + + {file ? ( +
+ {file.name} - {(file.size / 1024 / 1024).toFixed(2)}{' '} + MB +
+ ) : null} + {error ? ( + {error} + ) : null} + +
+
+
+ ); +} + +function SuggestedVendor({ + name, + onCreated +}: { + readonly name: string; + readonly onCreated: (vendor: Vendor) => void; +}) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function handleCreate() { + const formData = new FormData(); + formData.set('name', name); + + setPending(true); + setError(null); + try { + onCreated(await createVendorAction(formData)); + } catch { + setError('Could not create vendor.'); + } finally { + setPending(false); + } + } + + return ( +
+
+

Suggested vendor

+

{name}

+
+ {error ? {error} : null} + +
+ ); +} + +function SuggestedCategory({ + categories, + draft, + onCreated +}: { + readonly categories: readonly Category[]; + readonly draft: TransactionScanDraft; + readonly onCreated: (category: Category) => void; +}) { + const [open, setOpen] = useState(false); + const suggestion = draft.suggestedCategory; + if (!suggestion) { + return null; + } + + return ( +
+
+

Suggested category

+

+ {categoryTypeLabel(suggestion.type)} - {suggestion.name} +

+

+ {suggestion.reason} +

+
+ + + + + + + Create category + + The new category will be selected for this scanned + transaction. + + + { + if (category) { + onCreated(category); + } + setOpen(false); + }} + /> + + +
+ ); +} + +function ScanWizard({ + categories, + currencies, + defaultCurrency, + onReset, + scan, + setCategories, + setVendors, + timezone, + transactionCurrencies, + vendors +}: { + readonly categories: readonly Category[]; + readonly currencies: readonly Currency[]; + readonly defaultCurrency: string; + readonly onReset: () => void; + readonly scan: TransactionScanResponse; + readonly setCategories: (categories: readonly Category[]) => void; + readonly setVendors: (vendors: readonly Vendor[]) => void; + readonly timezone: string; + readonly transactionCurrencies: readonly string[]; + readonly vendors: readonly Vendor[]; +}) { + const router = useRouter(); + const transactionCategories = useMemo( + () => transactionCategoryOptions(categories), + [categories] + ); + const currencyOptions = useMemo( + () => + transactionCurrencyOptions( + currencies, + defaultCurrency, + transactionCurrencies + ), + [currencies, defaultCurrency, transactionCurrencies] + ); + const [currentIndex, setCurrentIndex] = useState(0); + const [decisions, setDecisions] = useState>({}); + const [lastSaved, setLastSaved] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const draft = scan.drafts[currentIndex]; + const values = useMemo( + () => + draft + ? initialValues({ + categories: transactionCategories, + defaultCurrency, + draft, + timezone + }) + : undefined, + [defaultCurrency, draft, timezone, transactionCategories] + ); + const [amount, setAmount] = useState(values?.amount ?? ''); + const [categoryId, setCategoryId] = useState( + values?.categoryId + ); + const [currency, setCurrency] = useState( + values?.currency ?? defaultCurrency + ); + const [occurredAtText, setOccurredAtText] = useState( + values?.occurredAtText ?? dateToLocalDateTimeInput(new Date(), timezone) + ); + const [note, setNote] = useState(values?.note ?? ''); + const [selectedType, setSelectedType] = useState( + values?.type ?? 'expense' + ); + const [vendorId, setVendorId] = useState( + values?.vendorId + ); + const [createdCategoryId, setCreatedCategoryId] = useState( + null + ); + const [createdVendorId, setCreatedVendorId] = useState(null); + + function loadDraft(nextIndex: number) { + const nextDraft = scan.drafts[nextIndex]; + if (!nextDraft) { + return; + } + const next = initialValues({ + categories: transactionCategories, + defaultCurrency, + draft: nextDraft, + timezone + }); + setCurrentIndex(nextIndex); + setAmount(next.amount); + setCategoryId(next.categoryId); + setCurrency(next.currency); + setOccurredAtText(next.occurredAtText); + setNote(next.note); + setSelectedType(next.type); + setVendorId(next.vendorId); + setCreatedCategoryId(null); + setCreatedVendorId(null); + setError(null); + setLastSaved(null); + } + + function moveAfterDecision(nextDecisions: Record) { + const nextIndex = nextPendingIndex( + scan.drafts, + nextDecisions, + currentIndex + ); + if (nextIndex !== undefined) { + loadDraft(nextIndex); + } + } + + function handleTypeChange(type: TransactionType) { + setSelectedType(type); + const current = transactionCategories.find( + category => category.id === categoryId + ); + if (current && categoryEffectiveType(current) === type) { + return; + } + setCategoryId(firstCategoryId(transactionCategories, type)); + } + + function handleVendorChange(vendor: Vendor | undefined) { + setVendorId(vendor?.id ?? null); + if (!vendor?.suggestedCategoryId) { + return; + } + const suggested = transactionCategories.find( + category => category.id === vendor.suggestedCategoryId + ); + if (!suggested) { + return; + } + setSelectedType(categoryEffectiveType(suggested)); + setCategoryId(suggested.id); + } + + function handleCategoryCreated(category: Category) { + setCategories([ + category, + ...categories.filter(item => item.id !== category.id) + ]); + setSelectedType(categoryEffectiveType(category)); + setCategoryId(category.id); + setCreatedCategoryId(category.id); + } + + function handleVendorCreated(vendor: Vendor) { + setVendors([vendor, ...vendors.filter(item => item.id !== vendor.id)]); + setVendorId(vendor.id); + setCreatedVendorId(vendor.id); + if (vendor.suggestedCategoryId) { + const suggested = transactionCategories.find( + category => category.id === vendor.suggestedCategoryId + ); + if (suggested) { + setSelectedType(categoryEffectiveType(suggested)); + setCategoryId(suggested.id); + } + } + } + + async function handleDiscard() { + if (!draft) { + return; + } + setPending(true); + setError(null); + try { + await recordTransactionScanDecisionAction({ + scanId: scan.scanId, + itemId: draft.id, + body: { decision: 'discarded' } + }); + const nextDecisions = { + ...decisions, + [draft.id]: 'discarded' as const + }; + setDecisions(nextDecisions); + moveAfterDecision(nextDecisions); + } catch { + setError('Could not discard this scanned transaction.'); + } finally { + setPending(false); + } + } + + async function handleConfirm(event: FormEvent) { + event.preventDefault(); + if (!draft) { + return; + } + + const amountValue = parseAmount(amount); + const occurredAt = localDateTimeInputToDate(occurredAtText, timezone); + if (amountValue === undefined) { + setError('Enter a positive amount with up to two decimals.'); + return; + } + if (!categoryId) { + setError('Choose a category.'); + return; + } + if (!currency) { + setError('Choose a currency.'); + return; + } + if (!occurredAt) { + setError('Choose a valid date and time.'); + return; + } + + const formData = new FormData(); + formData.set('amount', String(amountValue)); + formData.set('categoryId', String(categoryId)); + formData.set('currency', currency); + formData.set('occurredAt', occurredAt.toISOString()); + if (vendorId) { + formData.set('vendorId', String(vendorId)); + } + if (note.trim()) { + formData.set('note', note.trim()); + } + + setPending(true); + setError(null); + try { + const transaction = await createCaptureTransactionAction(formData); + await recordTransactionScanDecisionAction({ + scanId: scan.scanId, + itemId: draft.id, + body: { + decision: 'confirmed', + transactionId: transaction.id, + createdCategoryId, + createdVendorId, + correctedTransaction: { + amount: amountValue, + categoryId, + currency, + occurredAt, + vendorId: vendorId ?? null, + note: note.trim() || null + } + } + }); + const nextDecisions = { + ...decisions, + [draft.id]: 'confirmed' as const + }; + setDecisions(nextDecisions); + setLastSaved(transaction); + router.refresh(); + moveAfterDecision(nextDecisions); + } catch { + setError('Could not save this scanned transaction.'); + } finally { + setPending(false); + } + } + + if (scan.drafts.length === 0) { + return ( + + +
+

+ No transactions found +

+

+ Try a clearer receipt, invoice, or banking + screenshot. +

+
+ +
+
+ ); + } + + if (!draft) { + return null; + } + + const completeCount = Object.keys(decisions).length; + const complete = completeCount === scan.drafts.length; + const filteredCategories = transactionCategories.filter( + category => categoryEffectiveType(category) === selectedType + ); + + if (complete) { + return ( + + + +
+

Scan reviewed

+

+ Confirmed{' '} + { + Object.values(decisions).filter( + value => value === 'confirmed' + ).length + }{' '} + and discarded{' '} + { + Object.values(decisions).filter( + value => value === 'discarded' + ).length + } + . +

+
+ +
+
+ ); + } + + return ( + + +
+
+
+

+ Transaction {currentIndex + 1} of{' '} + {scan.drafts.length} +

+

+ {scan.documentKind.replace('_', ' ')} +

+
+
+ + +
+
+ {scan.warnings.map(warning => ( +
+ + {warning} +
+ ))} +
+ +
+ +
+

+ Visible evidence +

+

+ {draft.evidence || + 'No supporting text was returned.'} +

+

+ Confidence:{' '} + {confidenceLabel(draft.confidence.overall)} +

+
+ + {draft.possibleDuplicateTransactionIds.length > 0 ? ( +
+ + + Possible duplicate of transaction{' '} + {draft.possibleDuplicateTransactionIds.join( + ', ' + )} + . + +
+ ) : null} + + + Type +
+ {(['expense', 'income'] as const).map(type => ( + + ))} +
+
+ + + Category + + + + {!categoryId ? ( + + ) : null} + + + + {!vendorId && draft.suggestedVendorName ? ( + + ) : null} + + + + Amount + +
+ + setAmount(event.target.value) + } + placeholder="0.00" + step="0.01" + type="text" + value={amount} + /> + +
+
+ + + + Date and time + + + setOccurredAtText(event.target.value) + } + type="datetime-local" + value={occurredAtText} + /> + + + + Note + setNote(event.target.value)} + value={note} + /> + + + {lastSaved ? ( +
+

Saved

+

+ {savedSummary(lastSaved, timezone)} +

+
+ ) : null} + + {error ? ( + {error} + ) : null} + +
+ + +
+
+
+
+
+ ); +} + +export function TransactionCaptureWorkspace({ + categories, + currencies, + defaultCurrency, + timezone, + transactionCurrencies, + vendors +}: { + readonly categories: readonly Category[]; + readonly currencies: readonly Currency[]; + readonly defaultCurrency: string; + readonly timezone: string; + readonly transactionCurrencies: readonly string[]; + readonly vendors: readonly Vendor[]; +}) { + const [mode, setMode] = useState('manual'); + const [scan, setScan] = useState(null); + const [localCategories, setLocalCategories] = + useState(categories); + const [localVendors, setLocalVendors] = + useState(vendors); + + return ( +
+
+ + +
+ + {mode === 'manual' ? ( + + ) : scan ? ( + setScan(null)} + scan={scan} + setCategories={setLocalCategories} + setVendors={setLocalVendors} + timezone={timezone} + transactionCurrencies={transactionCurrencies} + vendors={localVendors} + /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 7d367ad..27f6ef6 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -2,7 +2,10 @@ import { createHash, randomBytes } from 'node:crypto'; import { + type Category, type Transaction, + type TransactionScanDecisionBody, + type TransactionScanResponse, UpdateVendorBodySchema, type Vendor, type VendorCandidate @@ -336,9 +339,11 @@ export async function logoutAction() { await signOut({ redirectTo: '/login' }); } -export async function createCategoryAction(formData: FormData) { +export async function createCategoryAction( + formData: FormData +): Promise { const client = await getApiClient(); - await client.categories.create({ + const category = await client.categories.create({ body: categoryBody(formData) }); revalidateTag('categories', 'max'); @@ -347,6 +352,7 @@ export async function createCategoryAction(formData: FormData) { revalidatePath('/settings/categories'); revalidatePath('/settings/preferences'); revalidatePath('/setup/categories'); + return category; } export async function createFirstCategoryAction(formData: FormData) { @@ -595,6 +601,79 @@ export async function createCaptureTransactionAction( return transaction; } +function uploadedFile(value: FormDataEntryValue | null): File | undefined { + if ( + typeof value === 'object' && + value !== null && + 'arrayBuffer' in value && + 'name' in value && + 'size' in value && + 'type' in value + ) { + return value as File; + } + return undefined; +} + +export async function scanTransactionImageAction( + formData: FormData +): Promise< + | { readonly error: string; readonly scan?: undefined } + | { readonly error?: undefined; readonly scan: TransactionScanResponse } +> { + const file = uploadedFile(formData.get('image')); + if (!file || file.size === 0) { + return { error: 'Choose an image to scan.' }; + } + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { + return { error: 'Upload a PNG, JPEG, or WebP image.' }; + } + if (file.size > 10 * 1024 * 1024) { + return { error: 'Image must be 10 MB or smaller.' }; + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const client = await getApiClient(); + try { + const scan = await client.transactionScans.create({ + body: { + imageBase64: buffer.toString('base64'), + mimeType: file.type as + | 'image/jpeg' + | 'image/png' + | 'image/webp', + fileName: file.name + } + }); + return { scan }; + } catch (err) { + if (apiErrorStatus(err) === 400) { + return { + error: + apiErrorMessage(err) ?? + 'Could not scan the image. Try a clearer image.' + }; + } + throw err; + } +} + +export async function recordTransactionScanDecisionAction({ + body, + itemId, + scanId +}: { + readonly body: TransactionScanDecisionBody; + readonly itemId: number; + readonly scanId: number; +}): Promise { + const client = await getApiClient(); + await client.transactionScans.decide({ + params: { scanId, itemId }, + body + }); +} + export async function updateTransactionAction(formData: FormData) { const client = await getApiClient(); await client.transactions.update({ diff --git a/packages/contracts/src/api.test.ts b/packages/contracts/src/api.test.ts index bc6e1a7..90bb19c 100644 --- a/packages/contracts/src/api.test.ts +++ b/packages/contracts/src/api.test.ts @@ -47,6 +47,8 @@ describe('api contract authorization metadata', () => { api.transactions.create, api.transactions.update, api.transactions.delete, + api.transactionScans.create, + api.transactionScans.decide, api.dashboard.summary, api.dashboard.window, api.stats.overview, diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 0f43618..157daa7 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -43,6 +43,9 @@ import { TokenResponseSchema, TransactionListQuerySchema, TransactionListResponseSchema, + TransactionScanBodySchema, + TransactionScanDecisionBodySchema, + TransactionScanResponseSchema, TransactionSchema, UpdateCategoryBodySchema, UpdateTransactionBodySchema, @@ -62,6 +65,10 @@ const CategoryMoveAndDelete = route({ id: number().coerce() })`/${t => const VendorEnrich = route({ id: number().coerce() })`/${t => t.id}/enrich`; const StatsCategoryTrend = route({ id: number().coerce() })`/categories/${t => t.id}/trend`; +const TransactionScanDecision = route({ + scanId: number().coerce(), + itemId: number().coerce() +})`/${t => t.scanId}/items/${t => t.itemId}/decision`; const categories = endpoint .resource('/api/categories') .authorize(PrincipalSchema); @@ -75,6 +82,9 @@ const vendorCandidateDetails = endpoint const transactions = endpoint .resource('/api/transactions') .authorize(PrincipalSchema); +const transactionScans = endpoint + .resource('/api/transaction-scans') + .authorize(PrincipalSchema); const stats = endpoint.resource('/api/stats').authorize(PrincipalSchema); const apiKeys = endpoint .resource('/api/users/me/api-keys') @@ -373,6 +383,23 @@ export const api = defineApi({ .clearsCacheTag('stats') .responses({ 204: null, 404: ErrorResponseSchema }) }, + transactionScans: { + create: transactionScans + .post() + .body(TransactionScanBodySchema) + .responses({ + 201: TransactionScanResponseSchema, + 400: ErrorResponseSchema + }), + decide: transactionScans + .post(TransactionScanDecision) + .body(TransactionScanDecisionBodySchema) + .responses({ + 204: null, + 400: ErrorResponseSchema, + 404: ErrorResponseSchema + }) + }, dashboard: { summary: endpoint .get('/api/dashboard') diff --git a/packages/contracts/src/schemas.test.ts b/packages/contracts/src/schemas.test.ts index 9d16e45..2239865 100644 --- a/packages/contracts/src/schemas.test.ts +++ b/packages/contracts/src/schemas.test.ts @@ -27,6 +27,9 @@ import { TimeZoneSchema, TokenResponseSchema, TransactionListQuerySchema, + TransactionScanBodySchema, + TransactionScanDecisionBodySchema, + TransactionScanResponseSchema, UpdateCategoryBodySchema, UpdateUserPreferenceBodySchema, UpdateVendorBodySchema, @@ -529,6 +532,69 @@ describe('shared schemas', () => { ); }); + it('validates transaction image scan payloads and feedback', () => { + expect( + TransactionScanBodySchema.validate({ + imageBase64: 'aW1hZ2U=', + mimeType: 'image/png', + fileName: 'receipt.png' + }).valid + ).toBe(true); + expect( + TransactionScanBodySchema.validate({ + imageBase64: 'aW1hZ2U=', + mimeType: 'application/pdf' + } as never).valid + ).toBe(false); + + expect( + TransactionScanResponseSchema.validate({ + scanId: 10, + documentKind: 'receipt', + warnings: ['Check date.'], + drafts: [ + { + id: 20, + amount: 12.34, + categoryId: 1, + suggestedCategory: null, + currency: 'USD', + occurredAt: new Date('2026-06-01T12:00:00.000Z'), + vendorId: null, + suggestedVendorName: 'Walmart', + transactionType: 'expense', + note: null, + evidence: 'Walmart 12.34', + confidence: { + amount: 'high', + category: 'medium', + currency: 'high', + date: 'low', + overall: 'medium', + vendor: 'medium' + }, + possibleDuplicateTransactionIds: [99] + } + ] + }).valid + ).toBe(true); + + expect( + TransactionScanDecisionBodySchema.validate({ + decision: 'confirmed', + transactionId: 42, + correctedTransaction: { + amount: 12.34, + categoryId: 1, + currency: 'USD', + occurredAt: new Date('2026-06-01T12:00:00.000Z'), + vendorId: null, + note: null + } + }).valid + ).toBe(true); + }); + it('validates stats reporting controls', () => { expect( StatsQuerySchema.validate({ diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 9d87a57..7bbe20a 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -1237,6 +1237,194 @@ export const TransactionListResponseSchema = object({ limit: number().describe('Number of records per page.') }).schemaName('TransactionListResponse'); +export const TransactionScanDocumentKindSchema = enumOf( + 'bank_app', + 'bank_statement', + 'invoice', + 'receipt', + 'other' +) + .describe('Type of uploaded transaction source inferred from the image.') + .schemaName('TransactionScanDocumentKind'); + +export const TransactionScanConfidenceSchema = enumOf('high', 'medium', 'low') + .describe('Model confidence for a scanned transaction field.') + .schemaName('TransactionScanConfidence'); + +export const TransactionScanSuggestedCategorySchema = object({ + /** Suggested category name when no existing category fits. */ + name: string().describe( + 'Suggested category name when no existing category fits.' + ), + /** Suggested category direction. */ + type: CategoryTypeSchema.describe('Suggested category direction.'), + /** Optional parent category identifier for a suggested subcategory. */ + parentId: number() + .nullable() + .describe( + 'Optional parent category identifier for a suggested subcategory.' + ), + /** Suggested category kind. */ + kind: CategoryKindSchema.describe('Suggested category kind.'), + /** Short reason for suggesting this new category. */ + reason: string().describe('Short reason for suggesting this new category.') +}).schemaName('TransactionScanSuggestedCategory'); + +export const TransactionScanFieldConfidenceSchema = object({ + amount: TransactionScanConfidenceSchema.describe('Amount confidence.'), + category: TransactionScanConfidenceSchema.describe('Category confidence.'), + currency: TransactionScanConfidenceSchema.describe('Currency confidence.'), + date: TransactionScanConfidenceSchema.describe('Date confidence.'), + overall: TransactionScanConfidenceSchema.describe('Overall confidence.'), + vendor: TransactionScanConfidenceSchema.describe('Vendor confidence.') +}).schemaName('TransactionScanFieldConfidence'); + +export const TransactionScanDraftSchema = object({ + /** Stable scan item identifier used to record the wizard decision. */ + id: number().describe( + 'Stable scan item identifier used to record the wizard decision.' + ), + /** Positive amount in the original currency, when visible. */ + amount: decimalNumber() + .nullable() + .describe('Positive amount in the original currency, when visible.'), + /** Existing category identifier selected by the scanner, when available. */ + categoryId: number() + .nullable() + .describe( + 'Existing category identifier selected by the scanner, when available.' + ), + /** Scanner suggestion for a category that does not exist yet. */ + suggestedCategory: + TransactionScanSuggestedCategorySchema.nullable().describe( + 'Scanner suggestion for a category that does not exist yet.' + ), + /** Currency used for the scanned amount, when visible. */ + currency: CurrencyCodeSchema.nullable().describe( + 'Currency used for the scanned amount, when visible.' + ), + /** Date and time when the scanned transaction happened, when visible. */ + occurredAt: date() + .coerce() + .nullable() + .describe( + 'Date and time when the scanned transaction happened, when visible.' + ), + /** Existing vendor identifier selected by the scanner, when available. */ + vendorId: number() + .nullable() + .describe( + 'Existing vendor identifier selected by the scanner, when available.' + ), + /** Vendor name suggested by the scanner when no existing vendor fits. */ + suggestedVendorName: string() + .nullable() + .describe( + 'Vendor name suggested by the scanner when no existing vendor fits.' + ), + /** Suggested transaction direction used to filter categories in the wizard. */ + transactionType: CategoryTypeSchema.describe( + 'Suggested transaction direction used to filter categories in the wizard.' + ), + /** Optional note generated from visible source context. */ + note: string() + .nullable() + .describe('Optional note generated from visible source context.'), + /** Text from the image that supports this draft. */ + evidence: string().describe( + 'Text from the image that supports this draft.' + ), + /** Confidence by scanned field. */ + confidence: TransactionScanFieldConfidenceSchema.describe( + 'Confidence by scanned field.' + ), + /** Existing transactions that may already represent this draft. */ + possibleDuplicateTransactionIds: array(number()).describe( + 'Existing transactions that may already represent this draft.' + ) +}).schemaName('TransactionScanDraft'); + +export const TransactionScanBodySchema = object({ + /** Raw uploaded image bytes encoded as base64, without a data URL prefix. */ + imageBase64: string() + .required('image is required') + .nonempty('image is required') + .describe( + 'Raw uploaded image bytes encoded as base64, without a data URL prefix.' + ), + /** Uploaded image MIME type. */ + mimeType: enumOf('image/jpeg', 'image/png', 'image/webp').describe( + 'Uploaded image MIME type.' + ), + /** Original file name, when provided by the browser. */ + fileName: string() + .optional() + .describe('Original file name, when provided by the browser.') +}).schemaName('TransactionScanBody'); + +export const TransactionScanResponseSchema = object({ + /** Stable scan identifier. */ + scanId: number().describe('Stable scan identifier.'), + /** Type of uploaded source inferred from the image. */ + documentKind: TransactionScanDocumentKindSchema.describe( + 'Type of uploaded source inferred from the image.' + ), + /** User-facing scanner warnings. */ + warnings: array(string()).describe('User-facing scanner warnings.'), + /** Draft transactions that require user confirmation. */ + drafts: array(TransactionScanDraftSchema).describe( + 'Draft transactions that require user confirmation.' + ) +}).schemaName('TransactionScanResponse'); + +export const TransactionScanDecisionSchema = enumOf( + 'confirmed', + 'discarded' +).schemaName('TransactionScanDecision'); + +export const TransactionScanCorrectedTransactionSchema = object({ + /** Confirmed category identifier. */ + categoryId: number().describe('Confirmed category identifier.'), + /** Confirmed vendor identifier, when selected. */ + vendorId: number() + .nullable() + .describe('Confirmed vendor identifier, when selected.'), + /** Confirmed amount. */ + amount: decimalNumber().describe('Confirmed amount.'), + /** Confirmed currency. */ + currency: CurrencyCodeSchema.describe('Confirmed currency.'), + /** Confirmed transaction date. */ + occurredAt: date().coerce().describe('Confirmed transaction date.'), + /** Confirmed note. */ + note: string().nullable().describe('Confirmed note.') +}).schemaName('TransactionScanCorrectedTransaction'); + +export const TransactionScanDecisionBodySchema = object({ + /** User decision for this scanned draft. */ + decision: TransactionScanDecisionSchema.describe( + 'User decision for this scanned draft.' + ), + /** Transaction created from this draft, when confirmed. */ + transactionId: number() + .nullable() + .optional() + .describe('Transaction created from this draft, when confirmed.'), + /** Category created inline for this draft, when applicable. */ + createdCategoryId: number() + .nullable() + .optional() + .describe('Category created inline for this draft, when applicable.'), + /** Vendor created inline for this draft, when applicable. */ + createdVendorId: number() + .nullable() + .optional() + .describe('Vendor created inline for this draft, when applicable.'), + /** Final user-corrected values, when confirmed. */ + correctedTransaction: TransactionScanCorrectedTransactionSchema.nullable() + .optional() + .describe('Final user-corrected values, when confirmed.') +}).schemaName('TransactionScanDecisionBody'); + export const DashboardQuerySchema = object({ /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), @@ -1803,6 +1991,14 @@ export type CreateTransactionBody = InferType< typeof CreateTransactionBodySchema >; export type TransactionListQuery = InferType; +export type TransactionScanBody = InferType; +export type TransactionScanResponse = InferType< + typeof TransactionScanResponseSchema +>; +export type TransactionScanDraft = InferType; +export type TransactionScanDecisionBody = InferType< + typeof TransactionScanDecisionBodySchema +>; export type DashboardSummary = InferType; export type DashboardWindowResponse = InferType< typeof DashboardWindowResponseSchema From 3c49eb3ce1b66204107d9ac5dfae0792c67ec0f4 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 05:27:15 +0000 Subject: [PATCH 2/9] fix: extend transaction scan timeout --- apps/web/lib/actions.ts | 6 ++- apps/web/lib/api.ts | 16 +++++-- packages/client/src/index.test.ts | 74 +++++++++++++++++++++++++++++++ packages/client/src/index.ts | 11 ++++- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/index.test.ts diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 27f6ef6..220bb6b 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -20,6 +20,7 @@ import { VendorUpdateActionRejected } from './log-templates'; import { loggerFor } from './logger'; const passportPkceCookie = 'xpenser_passport_pkce'; +const transactionScanTimeoutMs = 60_000; const vendorActionLogger = loggerFor('Vendor actions'); function normalizeFormText(value: string): string { @@ -633,7 +634,10 @@ export async function scanTransactionImageAction( } const buffer = Buffer.from(await file.arrayBuffer()); - const client = await getApiClient(); + const client = await getApiClient({ + retryOnTimeout: false, + timeoutMs: transactionScanTimeoutMs + }); try { const scan = await client.transactionScans.create({ body: { diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 5bc01ab..72a9e9f 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,4 +1,7 @@ -import { createXpenserClient } from '@xpenser/client'; +import { + createXpenserClient, + type XpenserClientOptions +} from '@xpenser/client'; import { redirect } from 'next/navigation'; import { auth } from '../auth'; import { expiredSessionPath } from './auth-routes'; @@ -12,14 +15,21 @@ export async function getSessionOrRedirect() { return session; } -export async function getApiClient() { +type ApiClientOptions = Pick< + XpenserClientOptions, + 'retryOnTimeout' | 'timeoutMs' +>; + +export async function getApiClient(options: ApiClientOptions = {}) { const session = await getSessionOrRedirect(); return createXpenserClient({ baseUrl: webConfig.apiBaseUrl, getToken: () => session.apiToken, onUnauthorized: () => { redirect(expiredSessionPath); - } + }, + retryOnTimeout: options.retryOnTimeout, + timeoutMs: options.timeoutMs }); } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts new file mode 100644 index 0000000..2e5c984 --- /dev/null +++ b/packages/client/src/index.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createXpenserClient } from './index'; + +const middlewareMocks = vi.hoisted(() => ({ + batching: vi.fn(() => ({ name: 'batching' })), + cacheTags: vi.fn(() => ({ name: 'cacheTags' })), + clientTracingMiddleware: vi.fn(() => ({ name: 'tracing' })), + createClient: vi.fn(() => ({ name: 'client' })), + dedupe: vi.fn(() => ({ name: 'dedupe' })), + retry: vi.fn(() => ({ name: 'retry' })), + timeout: vi.fn(() => ({ name: 'timeout' })) +})); + +vi.mock('@cleverbrush/client', () => ({ + createClient: middlewareMocks.createClient +})); + +vi.mock('@cleverbrush/client/batching', () => ({ + batching: middlewareMocks.batching +})); + +vi.mock('@cleverbrush/client/cache', () => ({ + cacheTags: middlewareMocks.cacheTags +})); + +vi.mock('@cleverbrush/client/dedupe', () => ({ + dedupe: middlewareMocks.dedupe +})); + +vi.mock('@cleverbrush/client/retry', () => ({ + retry: middlewareMocks.retry +})); + +vi.mock('@cleverbrush/client/timeout', () => ({ + timeout: middlewareMocks.timeout +})); + +vi.mock('@cleverbrush/otel/client', () => ({ + clientTracingMiddleware: middlewareMocks.clientTracingMiddleware +})); + +describe('createXpenserClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the default request timeout and timeout retry behavior', () => { + createXpenserClient({ baseUrl: 'http://api:4000' }); + + expect(middlewareMocks.retry).toHaveBeenCalledWith({ + limit: 2, + retryOnTimeout: true + }); + expect(middlewareMocks.timeout).toHaveBeenCalledWith({ + timeout: 10_000 + }); + }); + + it('allows long-running requests to override timeout behavior', () => { + createXpenserClient({ + baseUrl: 'http://api:4000', + retryOnTimeout: false, + timeoutMs: 60_000 + }); + + expect(middlewareMocks.retry).toHaveBeenCalledWith({ + limit: 2, + retryOnTimeout: false + }); + expect(middlewareMocks.timeout).toHaveBeenCalledWith({ + timeout: 60_000 + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6bffcf0..27cd8f3 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -20,6 +20,10 @@ export type XpenserClientOptions = { readonly headers?: Record; /** Optional fetch implementation for server tests and Next.js fetch options. */ readonly fetch?: typeof fetch; + /** Request timeout in milliseconds. Defaults to 10 seconds. */ + readonly timeoutMs?: number; + /** Whether timeout failures should be retried. Defaults to true. */ + readonly retryOnTimeout?: boolean; }; function hasBasePath(baseUrl: string): boolean { @@ -49,8 +53,11 @@ export function createXpenserClient(options: XpenserClientOptions) { fetch: options.fetch, middlewares: [ clientTracingMiddleware(), - retry({ limit: 2, retryOnTimeout: true }), - timeout({ timeout: 10_000 }), + retry({ + limit: 2, + retryOnTimeout: options.retryOnTimeout ?? true + }), + timeout({ timeout: options.timeoutMs ?? 10_000 }), dedupe(), cacheTags({ defaultTtl: 5_000, From 5556eb85f6bab27df39b3070e469683337331a99 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 06:19:26 +0000 Subject: [PATCH 3/9] feat: store scanned transaction images --- apps/api/src/api/endpoints.ts | 17 +- apps/api/src/api/handlers/index.ts | 4 +- apps/api/src/api/handlers/transactions.ts | 19 +- .../src/application/transaction-scans.test.ts | 202 +++++++++++++++++- apps/api/src/application/transaction-scans.ts | 97 ++++++++- apps/api/src/application/transactions.test.ts | 56 ++++- apps/api/src/application/transactions.ts | 134 +++++++++++- .../migrations/015_transaction_scan_images.ts | 35 +++ apps/api/src/db/schemas.ts | 38 ++++ apps/api/src/server.ts | 2 +- .../components/transaction-scan-capture.tsx | 77 +++++-- apps/web/components/transactions-browser.tsx | 122 ++++++++++- apps/web/lib/actions.ts | 13 +- apps/web/next.config.ts | 5 + packages/contracts/src/api.test.ts | 1 + packages/contracts/src/api.ts | 9 +- packages/contracts/src/limits.ts | 4 + packages/contracts/src/schemas.test.ts | 49 +++++ packages/contracts/src/schemas.ts | 86 +++++++- 19 files changed, 908 insertions(+), 62 deletions(-) create mode 100644 apps/api/src/db/migrations/015_transaction_scan_images.ts diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index 049e0ab..1dadb3d 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -1,5 +1,5 @@ import { api, PrincipalSchema } from '@xpenser/contracts'; -import { ConfigToken, DbToken, LoggerToken } from '../di/tokens.js'; +import { ConfigToken, DbToken, KnexToken, LoggerToken } from '../di/tokens.js'; export const RegisterEndpoint = api.auth.register .inject({ db: DbToken, config: ConfigToken }) @@ -265,7 +265,7 @@ export const EnrichVendorEndpoint = api.vendors.enrich export const ListTransactionsEndpoint = api.transactions.list .authorize(PrincipalSchema) - .inject({ db: DbToken }) + .inject({ db: DbToken, knex: KnexToken }) .summary('List transactions') .description('Lists transactions owned by the authenticated user.') .tags('transactions') @@ -297,6 +297,16 @@ export const DeleteTransactionEndpoint = api.transactions.delete .tags('transactions') .operationId('deleteTransaction'); +export const GetTransactionScanImageEndpoint = api.transactions.scanImage + .authorize(PrincipalSchema) + .inject({ knex: KnexToken }) + .summary('Get scanned transaction image') + .description( + 'Returns the original scanner image attached to a confirmed transaction.' + ) + .tags('transactions') + .operationId('getTransactionScanImage'); + export const CreateTransactionScanEndpoint = api.transactionScans.create .authorize(PrincipalSchema) .inject({ db: DbToken, config: ConfigToken }) @@ -405,7 +415,8 @@ export const endpoints = { list: ListTransactionsEndpoint, create: CreateTransactionEndpoint, update: UpdateTransactionEndpoint, - delete: DeleteTransactionEndpoint + delete: DeleteTransactionEndpoint, + scanImage: GetTransactionScanImageEndpoint }, transactionScans: { create: CreateTransactionScanEndpoint, diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index b35363e..4527ad1 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -39,6 +39,7 @@ import { dashboardSummaryHandler, dashboardWindowHandler, deleteTransactionHandler, + getTransactionScanImageHandler, listTransactionsHandler, statsOverviewHandler, statsWindowHandler, @@ -102,7 +103,8 @@ export const handlers = { list: listTransactionsHandler, create: createTransactionHandler, update: updateTransactionHandler, - delete: deleteTransactionHandler + delete: deleteTransactionHandler, + scanImage: getTransactionScanImageHandler }, transactionScans: { create: createTransactionScanHandler, diff --git a/apps/api/src/api/handlers/transactions.ts b/apps/api/src/api/handlers/transactions.ts index dcd809b..95f3a19 100644 --- a/apps/api/src/api/handlers/transactions.ts +++ b/apps/api/src/api/handlers/transactions.ts @@ -5,6 +5,7 @@ import { dashboardSummary, dashboardWindow, deleteTransaction, + getTransactionScanImage, listTransactions, statsOverview, statsWindow, @@ -19,6 +20,7 @@ import type { DashboardSummaryEndpoint, DashboardWindowEndpoint, DeleteTransactionEndpoint, + GetTransactionScanImageEndpoint, ListTransactionsEndpoint, StatsOverviewEndpoint, StatsWindowEndpoint, @@ -27,8 +29,8 @@ import type { export const listTransactionsHandler: Handler< typeof ListTransactionsEndpoint -> = async ({ query, principal }, { db }) => { - return listTransactions(db, principal.userId, query); +> = async ({ query, principal }, { db, knex }) => { + return listTransactions(db, principal.userId, query, knex); }; export const createTransactionHandler: Handler< @@ -93,6 +95,19 @@ export const deleteTransactionHandler: Handler< } }; +export const getTransactionScanImageHandler: Handler< + typeof GetTransactionScanImageEndpoint +> = async ({ params, principal }, { knex }) => { + try { + return await getTransactionScanImage(knex, principal.userId, params.id); + } catch (err) { + if (err instanceof TransactionNotFoundError) { + return ActionResult.notFound({ message: err.message }); + } + throw err; + } +}; + export const dashboardSummaryHandler: Handler< typeof DashboardSummaryEndpoint > = async ({ query, principal }, { db }) => { diff --git a/apps/api/src/application/transaction-scans.test.ts b/apps/api/src/application/transaction-scans.test.ts index a253fa3..3c6583f 100644 --- a/apps/api/src/application/transaction-scans.test.ts +++ b/apps/api/src/application/transaction-scans.test.ts @@ -9,6 +9,8 @@ import type { Config } from '../config.js'; import type { AppDb, TransactionDb, + TransactionScanDb, + TransactionScanImageDb, TransactionScanItemDb, UserDb } from '../db/schemas.js'; @@ -171,12 +173,50 @@ function scanItem( }; } +function scan(overrides: Partial = {}): TransactionScanDb { + return { + id: 10, + userId: 1, + documentKind: 'receipt', + imageHash: + '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', + model: 'gpt-5.5', + warningsJson: '[]', + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function scanImage( + overrides: Partial = {} +): TransactionScanImageDb { + return { + id: 30, + scanId: 10, + userId: 1, + imageHash: + '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', + mimeType: 'image/png', + fileName: 'receipt.png', + sizeBytes: 5, + imageBase64: Buffer.from('image').toString('base64'), + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + function testDb({ + scanImages = [], scanItems = [], + scans = [scan()], transactions = [], users = [user()] }: { + readonly scanImages?: TransactionScanImageDb[]; readonly scanItems?: TransactionScanItemDb[]; + readonly scans?: TransactionScanDb[]; readonly transactions?: TransactionDb[]; readonly users?: UserDb[]; } = {}): AppDb { @@ -187,12 +227,20 @@ function testDb({ ) }, transactionScans: { - insert: vi.fn(async value => ({ - id: 10, - createdAt: timestamp, - updatedAt: timestamp, - ...value - })) + where: vi.fn( + ( + selector: (row: TransactionScanDb) => TValue, + value: TValue + ) => testQuery(scans).where(selector, value) + ), + insert: vi.fn(async value => { + const created = scan({ + id: 10, + ...value + }); + scans.push(created); + return created; + }) }, transactionScanItems: { where: vi.fn( @@ -210,6 +258,22 @@ function testDb({ return created; }) }, + transactionScanImages: { + where: vi.fn( + ( + selector: (row: TransactionScanImageDb) => TValue, + value: TValue + ) => testQuery(scanImages).where(selector, value) + ), + insert: vi.fn(async value => { + const created = scanImage({ + id: scanImages.length + 30, + ...value + }); + scanImages.push(created); + return created; + }) + }, transactions: { where: vi.fn( ( @@ -318,6 +382,63 @@ describe('transaction image scans', () => { ); }); + it('suggests category creation instead of trusting low-confidence category IDs', async () => { + mocks.listCategories.mockResolvedValue([category()]); + mocks.listVendors.mockResolvedValue([vendor()]); + mocks.listTransactions.mockResolvedValue({ items: [] }); + mocks.generateStructuredJsonFromContent.mockResolvedValue({ + documentKind: 'receipt', + warnings: [], + transactions: [ + { + amount: 18.5, + categoryId: 7, + confidence: { + amount: 'high', + category: 'low', + currency: 'high', + date: 'medium', + overall: 'medium', + vendor: 'low' + }, + currency: 'USD', + evidence: 'Hardware supplies 18.50', + note: null, + occurredDate: '2026-06-01', + occurredTime: null, + suggestedCategoryKind: 'normal', + suggestedCategoryName: 'Home supplies', + suggestedCategoryParentId: null, + suggestedCategoryReason: + 'No existing category matches hardware supplies.', + suggestedCategoryType: 'expense', + suggestedVendorName: 'Hardware Shop', + transactionType: 'expense', + vendorId: null + } + ] + }); + + const result = await scanTransactionsFromImage(testDb(), config, 1, { + imageBase64: Buffer.from('image').toString('base64'), + mimeType: 'image/png' + }); + + expect(result.drafts[0]).toMatchObject({ + categoryId: null, + suggestedCategory: { + name: 'Home supplies', + type: 'expense', + parentId: null, + kind: 'normal', + reason: 'No existing category matches hardware supplies.' + }, + confidence: { + category: 'low' + } + }); + }); + it('records confirmed scan corrections', async () => { const item = scanItem(); const db = testDb({ @@ -350,4 +471,73 @@ describe('transaction image scans', () => { }); expect(item.decidedAt).toBeInstanceOf(Date); }); + + it('stores the original image for confirmed scan transactions', async () => { + const item = scanItem(); + const scanImages: TransactionScanImageDb[] = []; + const db = testDb({ + scanImages, + scanItems: [item], + transactions: [transactionRow({ id: 42 })] + }); + + await recordTransactionScanDecision(db, 1, 10, 20, { + decision: 'confirmed', + transactionId: 42, + correctedTransaction: { + amount: 19.99, + categoryId: 7, + currency: 'USD', + occurredAt: timestamp, + vendorId: null, + note: null + }, + attachment: { + imageBase64: Buffer.from('image').toString('base64'), + mimeType: 'image/png', + fileName: 'receipt.png' + } + }); + + expect(scanImages).toHaveLength(1); + expect(scanImages[0]).toMatchObject({ + scanId: 10, + userId: 1, + mimeType: 'image/png', + fileName: 'receipt.png', + sizeBytes: 5, + imageBase64: Buffer.from('image').toString('base64') + }); + }); + + it('rejects scan attachments that do not match the original scan hash', async () => { + const item = scanItem(); + const scanImages: TransactionScanImageDb[] = []; + const db = testDb({ + scanImages, + scanItems: [item], + transactions: [transactionRow({ id: 42 })] + }); + + await expect( + recordTransactionScanDecision(db, 1, 10, 20, { + decision: 'confirmed', + transactionId: 42, + correctedTransaction: { + amount: 19.99, + categoryId: 7, + currency: 'USD', + occurredAt: timestamp, + vendorId: null, + note: null + }, + attachment: { + imageBase64: Buffer.from('other').toString('base64'), + mimeType: 'image/png', + fileName: 'receipt.png' + } + }) + ).rejects.toThrow('Confirmed scan image did not match'); + expect(scanImages).toHaveLength(0); + }); }); diff --git a/apps/api/src/application/transaction-scans.ts b/apps/api/src/application/transaction-scans.ts index c191fda..b656258 100644 --- a/apps/api/src/application/transaction-scans.ts +++ b/apps/api/src/application/transaction-scans.ts @@ -8,13 +8,18 @@ import type { TransactionScanResponse, Vendor } from '@xpenser/contracts'; -import { FieldLimits } from '@xpenser/contracts'; +import { FieldLimits, TransactionScanLimits } from '@xpenser/contracts'; import { dateToLocalDateParam, localDateTimeInputToDate } from '@xpenser/timezone'; import type { Config } from '../config.js'; -import type { AppDb, TransactionScanItemDb, UserDb } from '../db/schemas.js'; +import type { + AppDb, + TransactionScanDb, + TransactionScanItemDb, + UserDb +} from '../db/schemas.js'; import { listCategories } from './categories.js'; import { generateStructuredJsonFromContent } from './openai.js'; import { listTransactions } from './transactions.js'; @@ -23,7 +28,7 @@ import { listVendors } from './vendors.js'; export class TransactionScanInputError extends Error {} export class TransactionScanNotFoundError extends Error {} -const maxImageBytes = 10 * 1024 * 1024; +const maxImageBytes = TransactionScanLimits.maxImageBytes; const maxDrafts = 25; const maxContextVendors = 100; const maxRecentTransactions = 100; @@ -234,9 +239,9 @@ function stripDataUrl(value: string): string { : value; } -function imageBuffer(body: TransactionScanBody): Buffer { +function scanImageBuffer(imageBase64: string): Buffer { try { - const buffer = Buffer.from(stripDataUrl(body.imageBase64), 'base64'); + const buffer = Buffer.from(stripDataUrl(imageBase64), 'base64'); if (buffer.length === 0) { throw new TransactionScanInputError('Upload a non-empty image.'); } @@ -254,6 +259,10 @@ function imageBuffer(body: TransactionScanBody): Buffer { } } +function imageBuffer(body: TransactionScanBody): Buffer { + return scanImageBuffer(body.imageBase64); +} + function oneLine(value: string, maxLength: number): string { return value.replace(/\s+/g, ' ').trim().slice(0, maxLength); } @@ -448,11 +457,14 @@ function sanitizeDraft({ const amount = numberValue(raw.amount); const safeAmount = amount !== null && amount > 0 ? amount : null; const fieldConfidence = raw.confidence ?? {}; + const categoryConfidence = confidence(fieldConfidence.category); + const exactCategoryId = + categoryId !== null && categoryConfidence !== 'low' ? categoryId : null; const draft = { amount: safeAmount, - categoryId, + categoryId: exactCategoryId, suggestedCategory: - categoryId === null + exactCategoryId === null ? suggestedCategory(raw, type, categories) : null, currency: currencyValue(raw.currency, defaultCurrency), @@ -467,7 +479,7 @@ function sanitizeDraft({ evidence: stringValue(raw.evidence, 500) ?? '', confidence: { amount: confidence(fieldConfidence.amount), - category: confidence(fieldConfidence.category), + category: categoryConfidence, currency: raw.currency ? confidence(fieldConfidence.currency) : 'low', @@ -580,7 +592,8 @@ function scanPrompt() { 'The image may be a receipt, invoice, bank app screenshot, or bank statement.', 'Return one or more transactions. Split one receipt into multiple transactions when visible line items clearly belong to different categories or vendors.', 'Do not create transactions, categories, or vendors. Only choose existing IDs from context or suggest names for the user to create later.', - 'Prefer existing category IDs and vendor IDs when they are plausible. Suggest a new category or vendor only when no existing record is a good fit.', + 'Prefer existing category IDs and vendor IDs only when they are a strong fit.', + 'When no existing category is a strong fit, set categoryId to null and fill suggestedCategoryName, suggestedCategoryType, suggestedCategoryKind, optional suggestedCategoryParentId, and suggestedCategoryReason.', 'Use positive amounts only. Infer expense/income through transactionType and category fit; do not encode signs in amount.', 'If a field is not visible or not reliable, return null for that field and low confidence.', 'Use correction examples as user-specific preferences and avoid repeating prior mistakes.', @@ -618,7 +631,8 @@ function promptInput({ occurredDate: 'Use YYYY-MM-DD when visible or inferable from the document; otherwise null.', occurredTime: 'Use HH:mm when visible; otherwise null.', - categoryId: 'Use only IDs from categories.', + categoryId: + 'Use only IDs from categories when the match is strong; otherwise null and provide suggested category fields.', vendorId: 'Use only IDs from vendors.', suggestedCategoryParentId: 'Use only a parent category ID from categories, or null.', @@ -750,6 +764,54 @@ async function ensureTransactionOwner( } } +async function storeScanAttachment({ + attachment, + db, + scan, + userId +}: { + readonly attachment: NonNullable; + readonly db: AppDb; + readonly scan: TransactionScanDb; + readonly userId: number; +}): Promise { + const imageBase64 = stripDataUrl(attachment.imageBase64); + const buffer = scanImageBuffer(imageBase64); + const imageHash = createHash('sha256').update(buffer).digest('hex'); + if (imageHash !== scan.imageHash) { + throw new TransactionScanInputError( + 'Confirmed scan image did not match the original scan.' + ); + } + + const existing = await db.transactionScanImages + .where(row => row.scanId, scan.id) + .where(row => row.userId, userId) + .first(); + const values = { + imageHash, + mimeType: attachment.mimeType, + fileName: stringValue(attachment.fileName, 255) ?? null, + sizeBytes: buffer.length, + imageBase64, + updatedAt: new Date() + }; + + if (existing) { + await db.transactionScanImages + .where(row => row.id, existing.id) + .where(row => row.userId, userId) + .update(values as never); + return; + } + + await db.transactionScanImages.insert({ + scanId: scan.id, + userId, + ...values + } as never); +} + export async function recordTransactionScanDecision( db: AppDb, userId: number, @@ -765,6 +827,13 @@ export async function recordTransactionScanDecision( if (!item) { throw new TransactionScanNotFoundError('Scan item was not found.'); } + const scan = await db.transactionScans + .where(row => row.id, scanId) + .where(row => row.userId, userId) + .first(); + if (!scan) { + throw new TransactionScanNotFoundError('Scan was not found.'); + } if (body.decision === 'confirmed') { if (!body.transactionId || !body.correctedTransaction) { @@ -773,6 +842,14 @@ export async function recordTransactionScanDecision( ); } await ensureTransactionOwner(db, userId, body.transactionId); + if (body.attachment) { + await storeScanAttachment({ + attachment: body.attachment, + db, + scan, + userId + }); + } } await scanItemTable(db) diff --git a/apps/api/src/application/transactions.test.ts b/apps/api/src/application/transactions.test.ts index 4519270..fb907d3 100644 --- a/apps/api/src/application/transactions.test.ts +++ b/apps/api/src/application/transactions.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { CategoryDb, VendorDb } from '../db/schemas.js'; import { categoryTrendMaxBuckets, compareTransactionsByOccurrenceAsc, compareTransactionsByOccurrenceDesc, dashboardStatsGroupBy, + getTransactionScanImage, percentChange, resolveCategoryTrendRange, resolveDashboardComparisonRange, @@ -451,6 +452,59 @@ describe('transaction category signs', () => { }); }); +describe('transaction scan images', () => { + it('returns the latest confirmed scan image for a transaction', async () => { + const scanTimestamp = new Date('2026-06-01T12:00:00.000Z'); + const row = { + scanId: 10, + scanItemId: 20, + fileName: 'receipt.png', + mimeType: 'image/png', + sizeBytes: '5', + createdAt: scanTimestamp, + imageBase64: Buffer.from('image').toString('base64') + }; + const query = { + join: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + select: vi.fn(() => query), + first: vi.fn(async () => row) + }; + const knex = vi.fn(() => query); + + await expect( + getTransactionScanImage(knex as never, 1, 42) + ).resolves.toEqual({ + scanId: 10, + scanItemId: 20, + fileName: 'receipt.png', + mimeType: 'image/png', + sizeBytes: 5, + createdAt: scanTimestamp, + imageBase64: Buffer.from('image').toString('base64') + }); + expect(knex).toHaveBeenCalledWith('transaction_scan_items as item'); + expect(query.where).toHaveBeenCalledWith('item.user_id', 1); + expect(query.where).toHaveBeenCalledWith('item.transaction_id', 42); + expect(query.where).toHaveBeenCalledWith('item.decision', 'confirmed'); + }); + + it('throws when a transaction has no stored scan image', async () => { + const query = { + join: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + select: vi.fn(() => query), + first: vi.fn(async () => undefined) + }; + + await expect( + getTransactionScanImage(vi.fn(() => query) as never, 1, 42) + ).rejects.toBeInstanceOf(TransactionNotFoundError); + }); +}); + describe('stats range resolution', () => { it('compares an in-progress month with the same elapsed previous month', () => { const now = new Date(2026, 4, 10, 12, 34, 0, 0); diff --git a/apps/api/src/application/transactions.ts b/apps/api/src/application/transactions.ts index 435a2d2..26cef41 100644 --- a/apps/api/src/application/transactions.ts +++ b/apps/api/src/application/transactions.ts @@ -10,7 +10,8 @@ import type { StatsQuery, StatsWindowResponse, Transaction, - TransactionListQuery + TransactionListQuery, + TransactionScanImageResponse } from '@xpenser/contracts'; import { addLocalDays, @@ -33,6 +34,7 @@ import { statsBucketKeyInTimeZone, statsBucketLabelInTimeZone } from '@xpenser/timezone'; +import type { Knex } from 'knex'; import type { Config } from '../config.js'; import type { AppDb, @@ -62,6 +64,20 @@ type DashboardPeriod = NonNullable; type DashboardCategory = DashboardSummary['byCategory'][number]; type DashboardVendor = DashboardSummary['topVendors'][number]; +type TransactionScanAttachment = NonNullable; +type TransactionScanAttachmentRow = { + readonly createdAt: Date; + readonly fileName: string | null; + readonly mimeType: 'image/jpeg' | 'image/png' | 'image/webp'; + readonly scanId: number; + readonly scanItemId: number; + readonly sizeBytes: number | string; + readonly transactionId: number; +}; + +type TransactionScanImageRow = TransactionScanAttachmentRow & { + readonly imageBase64: string; +}; type StatsBucket = StatsOverview['trend'][number]; @@ -210,7 +226,8 @@ function categoryForTransaction( function mapTransaction( row: TransactionDb, categoriesById: ReadonlyMap, - vendorsById: ReadonlyMap + vendorsById: ReadonlyMap, + scanAttachments: ReadonlyMap = new Map() ): Transaction { const category = categoryForTransaction(row, categoriesById); const fields = categoryFields(category, row, categoriesById); @@ -236,11 +253,65 @@ function mapTransaction( exchangeRateDate: row.exchangeRateDate, occurredAt: row.occurredAt, note: row.note ?? undefined, + scanAttachment: scanAttachments.get(row.id) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt }; } +function scanAttachmentFromRow( + row: TransactionScanAttachmentRow +): TransactionScanAttachment { + return { + scanId: Number(row.scanId), + scanItemId: Number(row.scanItemId), + fileName: row.fileName, + mimeType: row.mimeType, + sizeBytes: Number(row.sizeBytes), + createdAt: row.createdAt + }; +} + +async function scanAttachmentsByTransaction( + knex: Knex | undefined, + userId: number, + transactionIds: readonly number[] +): Promise> { + const uniqueIds = [...new Set(transactionIds)]; + if (!knex || uniqueIds.length === 0) { + return new Map(); + } + + const rows = (await knex('transaction_scan_items as item') + .join( + 'transaction_scan_images as image', + 'image.scan_id', + 'item.scan_id' + ) + .where('item.user_id', userId) + .where('image.user_id', userId) + .where('item.decision', 'confirmed') + .whereIn('item.transaction_id', uniqueIds) + .orderBy('item.decided_at', 'desc') + .select({ + transactionId: 'item.transaction_id', + scanId: 'item.scan_id', + scanItemId: 'item.id', + fileName: 'image.file_name', + mimeType: 'image.mime_type', + sizeBytes: 'image.size_bytes', + createdAt: 'image.created_at' + })) as TransactionScanAttachmentRow[]; + + const attachments = new Map(); + for (const row of rows) { + if (!attachments.has(row.transactionId)) { + attachments.set(row.transactionId, scanAttachmentFromRow(row)); + } + } + return attachments; +} + export function compareTransactionsByOccurrenceDesc( left: Pick, right: Pick @@ -302,7 +373,8 @@ async function validateVendor( export async function listTransactions( db: AppDb, userId: number, - query: TransactionListQuery + query: TransactionListQuery, + knex?: Knex ) { const page = Math.max(1, query.page ?? 1); const limit = Math.min(100, Math.max(1, query.limit ?? 50)); @@ -416,13 +488,22 @@ export async function listTransactions( ); }); const offset = (page - 1) * limit; + const pageRows = filtered.slice(offset, offset + limit) as TransactionDb[]; + const scanAttachments = await scanAttachmentsByTransaction( + knex, + userId, + pageRows.map(transaction => transaction.id) + ); return { - items: filtered - .slice(offset, offset + limit) - .map(transaction => - mapTransaction(transaction, categoriesById, vendorsById) - ), + items: pageRows.map(transaction => + mapTransaction( + transaction, + categoriesById, + vendorsById, + scanAttachments + ) + ), total: filtered.length, page, limit @@ -498,6 +579,43 @@ export async function getTransaction( return mapTransaction(row as TransactionDb, categoriesById, vendorsById); } +export async function getTransactionScanImage( + knex: Knex, + userId: number, + transactionId: number +): Promise { + const row = (await knex('transaction_scan_items as item') + .join( + 'transaction_scan_images as image', + 'image.scan_id', + 'item.scan_id' + ) + .where('item.user_id', userId) + .where('image.user_id', userId) + .where('item.transaction_id', transactionId) + .where('item.decision', 'confirmed') + .orderBy('item.decided_at', 'desc') + .select({ + scanId: 'item.scan_id', + scanItemId: 'item.id', + fileName: 'image.file_name', + mimeType: 'image.mime_type', + sizeBytes: 'image.size_bytes', + createdAt: 'image.created_at', + imageBase64: 'image.image_base64' + }) + .first()) as TransactionScanImageRow | undefined; + + if (!row) { + throw new TransactionNotFoundError('Scanned image was not found.'); + } + + return { + ...scanAttachmentFromRow({ ...row, transactionId }), + imageBase64: row.imageBase64 + }; +} + export async function updateTransaction( db: AppDb, config: Config, diff --git a/apps/api/src/db/migrations/015_transaction_scan_images.ts b/apps/api/src/db/migrations/015_transaction_scan_images.ts new file mode 100644 index 0000000..343f1ad --- /dev/null +++ b/apps/api/src/db/migrations/015_transaction_scan_images.ts @@ -0,0 +1,35 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('transaction_scan_images', table => { + table.increments('id').primary(); + table + .integer('scan_id') + .notNullable() + .references('id') + .inTable('transaction_scans') + .onDelete('CASCADE'); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('image_hash', 128).notNullable(); + table.string('mime_type', 64).notNullable(); + table.text('file_name'); + table.integer('size_bytes').notNullable(); + table.text('image_base64').notNullable(); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table.unique(['scan_id'], { + indexName: 'uq_transaction_scan_images_scan_id' + }); + table.index(['user_id'], 'idx_transaction_scan_images_user_id'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('transaction_scan_images'); +} diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index 3d8c948..427828b 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -247,6 +247,27 @@ export const TransactionScanItemDbSchema = object({ updatedAt: date().hasColumnName('updated_at').defaultTo('now') }).hasTableName('transaction_scan_items'); +export const TransactionScanImageDbSchema = object({ + id: number().primaryKey(), + scanId: number() + .hasColumnName('scan_id') + .references('transaction_scans', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_images_scan_id'), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_images_user_id'), + imageHash: string().hasColumnName('image_hash'), + mimeType: string().hasColumnName('mime_type'), + fileName: string().hasColumnName('file_name').optional(), + sizeBytes: number().hasColumnName('size_bytes'), + imageBase64: string().hasColumnName('image_base64'), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}).hasTableName('transaction_scan_images'); + export const ExchangeRateDbSchema = object({ id: number().primaryKey(), baseCurrency: string().hasColumnName('base_currency'), @@ -287,6 +308,9 @@ export const TransactionScanEntity = defineEntity(TransactionScanDbSchema); export const TransactionScanItemEntity = defineEntity( TransactionScanItemDbSchema ); +export const TransactionScanImageEntity = defineEntity( + TransactionScanImageDbSchema +); export const ExchangeRateEntity = defineEntity(ExchangeRateDbSchema); export const entityMap = { @@ -301,6 +325,7 @@ export const entityMap = { transactions: TransactionEntity, transactionScans: TransactionScanEntity, transactionScanItems: TransactionScanItemEntity, + transactionScanImages: TransactionScanImageEntity, exchangeRates: ExchangeRateEntity }; @@ -437,3 +462,16 @@ export type TransactionScanItemDb = { readonly createdAt: Date; readonly updatedAt: Date; }; + +export type TransactionScanImageDb = { + readonly id: number; + readonly scanId: number; + readonly userId: number; + readonly imageHash: string; + readonly mimeType: 'image/jpeg' | 'image/png' | 'image/webp'; + readonly fileName?: string | null; + readonly sizeBytes: number; + readonly imageBase64: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 123a2bd..378305b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -49,7 +49,7 @@ export function buildServer( }); const server = createServer({ - maxBodySize: 1024 * 1024 + maxBodySize: 20 * 1024 * 1024 }) .use(tracingMiddleware({ excludePaths: ['/health'] })) .use(corsMiddleware(config)) diff --git a/apps/web/components/transaction-scan-capture.tsx b/apps/web/components/transaction-scan-capture.tsx index 1caed7d..a982723 100644 --- a/apps/web/components/transaction-scan-capture.tsx +++ b/apps/web/components/transaction-scan-capture.tsx @@ -1,12 +1,14 @@ 'use client'; -import type { - Category, - Currency, - Transaction, - TransactionScanDraft, - TransactionScanResponse, - Vendor +import { + type Category, + type Currency, + type Transaction, + type TransactionScanDecisionBody, + type TransactionScanDraft, + TransactionScanLimits, + type TransactionScanResponse, + type Vendor } from '@xpenser/contracts'; import { dateToLocalDateTimeInput, @@ -65,9 +67,27 @@ import { VendorPicker } from './vendor-picker'; type CaptureMode = 'manual' | 'scan'; type Decision = 'confirmed' | 'discarded'; +type ScanAttachment = NonNullable; type TransactionType = Category['type']; -const maxImageBytes = 10 * 1024 * 1024; +const maxImageBytes = TransactionScanLimits.maxImageBytes; + +function fileImageBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read image.')); + reader.onload = () => { + const value = reader.result; + if (typeof value !== 'string') { + reject(new Error('Could not read image.')); + return; + } + const commaIndex = value.indexOf(','); + resolve(commaIndex >= 0 ? value.slice(commaIndex + 1) : value); + }; + reader.readAsDataURL(file); + }); +} function parseAmount(value: string): number | undefined { const normalized = value.trim().replace(',', '.'); @@ -109,7 +129,7 @@ function initialValues({ const type = draftCategoryType(draft, categories); return { amount: draft.amount ? String(draft.amount) : '', - categoryId: draft.categoryId ?? firstCategoryId(categories, type), + categoryId: draft.categoryId ?? undefined, currency: draft.currency ?? defaultCurrency, occurredAtText: dateToLocalDateTimeInput( draft.occurredAt ?? new Date(), @@ -153,7 +173,10 @@ function nextPendingIndex( function ScanUpload({ onScanned }: { - readonly onScanned: (scan: TransactionScanResponse) => void; + readonly onScanned: ( + scan: TransactionScanResponse, + attachment: ScanAttachment + ) => void; }) { const [file, setFile] = useState(null); const [pending, setPending] = useState(false); @@ -189,7 +212,11 @@ function ScanUpload({ setError('Could not scan the image. Try again.'); return; } - onScanned(result.scan); + onScanned(result.scan, { + imageBase64: await fileImageBase64(file), + mimeType: file.type as ScanAttachment['mimeType'], + fileName: file.name + }); } catch { setError('Could not scan the image. Try again.'); } finally { @@ -352,6 +379,7 @@ function SuggestedCategory({ } function ScanWizard({ + attachment, categories, currencies, defaultCurrency, @@ -363,6 +391,7 @@ function ScanWizard({ transactionCurrencies, vendors }: { + readonly attachment: ScanAttachment; readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; readonly defaultCurrency: string; @@ -428,6 +457,7 @@ function ScanWizard({ null ); const [createdVendorId, setCreatedVendorId] = useState(null); + const [attachmentSubmitted, setAttachmentSubmitted] = useState(false); function loadDraft(nextIndex: number) { const nextDraft = scan.drafts[nextIndex]; @@ -582,6 +612,7 @@ function ScanWizard({ setError(null); try { const transaction = await createCaptureTransactionAction(formData); + const shouldSubmitAttachment = !attachmentSubmitted; await recordTransactionScanDecisionAction({ scanId: scan.scanId, itemId: draft.id, @@ -597,9 +628,13 @@ function ScanWizard({ occurredAt, vendorId: vendorId ?? null, note: note.trim() || null - } + }, + attachment: shouldSubmitAttachment ? attachment : undefined } }); + if (shouldSubmitAttachment) { + setAttachmentSubmitted(true); + } const nextDecisions = { ...decisions, [draft.id]: 'confirmed' as const @@ -963,7 +998,10 @@ export function TransactionCaptureWorkspace({ readonly vendors: readonly Vendor[]; }) { const [mode, setMode] = useState('manual'); - const [scan, setScan] = useState(null); + const [scanSession, setScanSession] = useState<{ + readonly attachment: ScanAttachment; + readonly scan: TransactionScanResponse; + } | null>(null); const [localCategories, setLocalCategories] = useState(categories); const [localVendors, setLocalVendors] = @@ -1001,13 +1039,14 @@ export function TransactionCaptureWorkspace({ timezone={timezone} transactionCurrencies={transactionCurrencies} /> - ) : scan ? ( + ) : scanSession ? ( setScan(null)} - scan={scan} + onReset={() => setScanSession(null)} + scan={scanSession.scan} setCategories={setLocalCategories} setVendors={setLocalVendors} timezone={timezone} @@ -1015,7 +1054,11 @@ export function TransactionCaptureWorkspace({ vendors={localVendors} /> ) : ( - + + setScanSession({ attachment, scan }) + } + /> )}
); diff --git a/apps/web/components/transactions-browser.tsx b/apps/web/components/transactions-browser.tsx index ac985f6..6dfbb00 100644 --- a/apps/web/components/transactions-browser.tsx +++ b/apps/web/components/transactions-browser.tsx @@ -4,6 +4,7 @@ import type { Category, Currency, Transaction, + TransactionScanImageResponse, Vendor } from '@xpenser/contracts'; import { FieldLimits } from '@xpenser/contracts'; @@ -31,7 +32,13 @@ import { TableHeader, TableRow } from '@xpenser/ui'; -import { PencilIcon, SlidersHorizontalIcon, Trash2Icon } from 'lucide-react'; +import { + ImageIcon, + PencilIcon, + SlidersHorizontalIcon, + Trash2Icon +} from 'lucide-react'; +import Image from 'next/image'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { @@ -44,6 +51,7 @@ import { } from 'react'; import { deleteTransactionAction, + getTransactionScanImageAction, updateTransactionAction } from '@/lib/actions'; import { expiredSessionPath } from '@/lib/auth-routes'; @@ -130,14 +138,114 @@ function transactionAmount(transaction: Transaction) { ); } +function imageSizeLabel(sizeBytes: number): string { + return `${(sizeBytes / 1024 / 1024).toFixed(2)} MB`; +} + +function ScanImageReviewButton({ + transaction +}: { + readonly transaction: Transaction; +}) { + const [open, setOpen] = useState(false); + const [image, setImage] = useState( + null + ); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const attachment = transaction.scanAttachment; + + useEffect(() => { + if (!open || image || pending || !attachment) { + return; + } + + let active = true; + setPending(true); + setError(null); + getTransactionScanImageAction(transaction.id) + .then(result => { + if (active) { + setImage(result); + } + }) + .catch(() => { + if (active) { + setError('Could not load scanned image.'); + } + }) + .finally(() => { + if (active) { + setPending(false); + } + }); + + return () => { + active = false; + }; + }, [attachment, image, open, pending, transaction.id]); + + if (!attachment) { + return null; + } + + const visibleImage = image ?? attachment; + + return ( + + + + + + + Scanned image + + {visibleImage.fileName ?? 'Uploaded image'} -{' '} + {imageSizeLabel(visibleImage.sizeBytes)} + + + {pending ? ( +

+ Loading image... +

+ ) : null} + {error ? ( +

{error}

+ ) : null} + {image ? ( + {image.fileName + ) : null} +
+
+ ); +} + function transactionBadges(transaction: Transaction) { return ( - - {categoryTypeLabel(transaction.type)} - + <> + + {categoryTypeLabel(transaction.type)} + + + ); } diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 220bb6b..6922778 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -5,6 +5,8 @@ import { type Category, type Transaction, type TransactionScanDecisionBody, + type TransactionScanImageResponse, + TransactionScanLimits, type TransactionScanResponse, UpdateVendorBodySchema, type Vendor, @@ -629,7 +631,7 @@ export async function scanTransactionImageAction( if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { return { error: 'Upload a PNG, JPEG, or WebP image.' }; } - if (file.size > 10 * 1024 * 1024) { + if (file.size > TransactionScanLimits.maxImageBytes) { return { error: 'Image must be 10 MB or smaller.' }; } @@ -678,6 +680,15 @@ export async function recordTransactionScanDecisionAction({ }); } +export async function getTransactionScanImageAction( + transactionId: number +): Promise { + const client = await getApiClient(); + return client.transactions.scanImage({ + params: { id: transactionId } + }); +} + export async function updateTransactionAction(formData: FormData) { const client = await getApiClient(); await client.transactions.update({ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 7c0e518..4ea4f4b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,11 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + experimental: { + serverActions: { + bodySizeLimit: '20mb' + } + }, transpilePackages: [ '@xpenser/ui', '@xpenser/client', diff --git a/packages/contracts/src/api.test.ts b/packages/contracts/src/api.test.ts index 90bb19c..a05596f 100644 --- a/packages/contracts/src/api.test.ts +++ b/packages/contracts/src/api.test.ts @@ -47,6 +47,7 @@ describe('api contract authorization metadata', () => { api.transactions.create, api.transactions.update, api.transactions.delete, + api.transactions.scanImage, api.transactionScans.create, api.transactionScans.decide, api.dashboard.summary, diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 157daa7..c0b6f7d 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -45,6 +45,7 @@ import { TransactionListResponseSchema, TransactionScanBodySchema, TransactionScanDecisionBodySchema, + TransactionScanImageResponseSchema, TransactionScanResponseSchema, TransactionSchema, UpdateCategoryBodySchema, @@ -69,6 +70,8 @@ const TransactionScanDecision = route({ scanId: number().coerce(), itemId: number().coerce() })`/${t => t.scanId}/items/${t => t.itemId}/decision`; +const TransactionScanImage = route({ id: number().coerce() })`/${t => + t.id}/scan-image`; const categories = endpoint .resource('/api/categories') .authorize(PrincipalSchema); @@ -381,7 +384,11 @@ export const api = defineApi({ .clearsCacheTag('user-profile') .clearsCacheTag('dashboard') .clearsCacheTag('stats') - .responses({ 204: null, 404: ErrorResponseSchema }) + .responses({ 204: null, 404: ErrorResponseSchema }), + scanImage: transactions.get(TransactionScanImage).responses({ + 200: TransactionScanImageResponseSchema, + 404: ErrorResponseSchema + }) }, transactionScans: { create: transactionScans diff --git a/packages/contracts/src/limits.ts b/packages/contracts/src/limits.ts index 64a4d7b..351e713 100644 --- a/packages/contracts/src/limits.ts +++ b/packages/contracts/src/limits.ts @@ -25,3 +25,7 @@ export const FieldLimits = { vendorPrimaryColor: 7, vendorSearch: 160 } as const; + +export const TransactionScanLimits = { + maxImageBytes: 10 * 1024 * 1024 +} as const; diff --git a/packages/contracts/src/schemas.test.ts b/packages/contracts/src/schemas.test.ts index 2239865..b1e52bd 100644 --- a/packages/contracts/src/schemas.test.ts +++ b/packages/contracts/src/schemas.test.ts @@ -29,7 +29,9 @@ import { TransactionListQuerySchema, TransactionScanBodySchema, TransactionScanDecisionBodySchema, + TransactionScanImageResponseSchema, TransactionScanResponseSchema, + TransactionSchema, UpdateCategoryBodySchema, UpdateUserPreferenceBodySchema, UpdateVendorBodySchema, @@ -590,9 +592,56 @@ describe('shared schemas', () => { occurredAt: new Date('2026-06-01T12:00:00.000Z'), vendorId: null, note: null + }, + attachment: { + imageBase64: 'aW1hZ2U=', + mimeType: 'image/png', + fileName: 'receipt.png' } }).valid ).toBe(true); + + expect( + TransactionSchema.validate({ + id: 42, + categoryId: 1, + vendorId: null, + categoryName: 'Groceries', + categoryDisplayName: 'Groceries', + categoryParentId: null, + categoryKind: 'normal', + type: 'expense', + amount: 12.34, + currency: 'USD', + defaultCurrencyAmount: 12.34, + defaultCurrency: 'USD', + exchangeRate: 1, + exchangeRateDate: '2026-06-01', + occurredAt: new Date('2026-06-01T12:00:00.000Z'), + scanAttachment: { + scanId: 10, + scanItemId: 20, + fileName: 'receipt.png', + mimeType: 'image/png', + sizeBytes: 5, + createdAt: new Date('2026-06-01T12:00:00.000Z') + }, + createdAt: new Date('2026-06-01T12:00:00.000Z'), + updatedAt: new Date('2026-06-01T12:00:00.000Z') + }).valid + ).toBe(true); + + expect( + TransactionScanImageResponseSchema.validate({ + scanId: 10, + scanItemId: 20, + fileName: 'receipt.png', + mimeType: 'image/png', + sizeBytes: 5, + createdAt: new Date('2026-06-01T12:00:00.000Z'), + imageBase64: 'aW1hZ2U=' + }).valid + ).toBe(true); }); it('validates stats reporting controls', () => { diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 7bbe20a..353c950 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -121,6 +121,14 @@ export const ErrorResponseSchema = object({ ) }).schemaName('ErrorResponse'); +export const ImageMimeTypeSchema = enumOf( + 'image/jpeg', + 'image/png', + 'image/webp' +) + .describe('Supported image MIME type.') + .schemaName('ImageMimeType'); + export const PrincipalSchema = object({ /** Authenticated user identifier encoded in the API JWT. */ userId: number().describe( @@ -1096,6 +1104,32 @@ export const TransactionSchema = object({ .describe('Date and time when the transaction happened.'), /** Optional note entered by the user. */ note: string().optional().describe('Optional note entered by the user.'), + /** Original scanner image metadata when this transaction came from a scan. */ + scanAttachment: object({ + /** Scan session identifier. */ + scanId: number().describe('Scan session identifier.'), + /** Scan item identifier linked to this transaction. */ + scanItemId: number().describe( + 'Scan item identifier linked to this transaction.' + ), + /** Original file name when available. */ + fileName: string() + .nullable() + .describe('Original file name when available.'), + /** Stored image MIME type. */ + mimeType: ImageMimeTypeSchema.describe('Stored image MIME type.'), + /** Original image size in bytes. */ + sizeBytes: number().describe('Original image size in bytes.'), + /** Timestamp when the image was stored. */ + createdAt: date() + .coerce() + .describe('Timestamp when the image was stored.') + }) + .optional() + .nullable() + .describe( + 'Original scanner image metadata when this transaction came from a scan.' + ), /** Creation timestamp. */ createdAt: date().coerce().describe('Creation timestamp.'), /** Last update timestamp. */ @@ -1353,15 +1387,29 @@ export const TransactionScanBodySchema = object({ 'Raw uploaded image bytes encoded as base64, without a data URL prefix.' ), /** Uploaded image MIME type. */ - mimeType: enumOf('image/jpeg', 'image/png', 'image/webp').describe( - 'Uploaded image MIME type.' - ), + mimeType: ImageMimeTypeSchema.describe('Uploaded image MIME type.'), /** Original file name, when provided by the browser. */ fileName: string() .optional() .describe('Original file name, when provided by the browser.') }).schemaName('TransactionScanBody'); +export const TransactionScanAttachmentBodySchema = object({ + /** Raw uploaded image bytes encoded as base64, without a data URL prefix. */ + imageBase64: string() + .required('image is required') + .nonempty('image is required') + .describe( + 'Raw uploaded image bytes encoded as base64, without a data URL prefix.' + ), + /** Uploaded image MIME type. */ + mimeType: ImageMimeTypeSchema.describe('Uploaded image MIME type.'), + /** Original file name, when provided by the browser. */ + fileName: string() + .optional() + .describe('Original file name, when provided by the browser.') +}).schemaName('TransactionScanAttachmentBody'); + export const TransactionScanResponseSchema = object({ /** Stable scan identifier. */ scanId: number().describe('Stable scan identifier.'), @@ -1422,9 +1470,36 @@ export const TransactionScanDecisionBodySchema = object({ /** Final user-corrected values, when confirmed. */ correctedTransaction: TransactionScanCorrectedTransactionSchema.nullable() .optional() - .describe('Final user-corrected values, when confirmed.') + .describe('Final user-corrected values, when confirmed.'), + /** Original scan image, stored once for confirmed transactions. */ + attachment: TransactionScanAttachmentBodySchema.optional().describe( + 'Original scan image, stored once for confirmed transactions.' + ) }).schemaName('TransactionScanDecisionBody'); +export const TransactionScanImageResponseSchema = object({ + /** Scan session identifier. */ + scanId: number().describe('Scan session identifier.'), + /** Scan item identifier linked to this transaction. */ + scanItemId: number().describe( + 'Scan item identifier linked to this transaction.' + ), + /** Original file name when available. */ + fileName: string() + .nullable() + .describe('Original file name when available.'), + /** Stored image MIME type. */ + mimeType: ImageMimeTypeSchema.describe('Stored image MIME type.'), + /** Original image size in bytes. */ + sizeBytes: number().describe('Original image size in bytes.'), + /** Timestamp when the image was stored. */ + createdAt: date().coerce().describe('Timestamp when the image was stored.'), + /** Raw uploaded image bytes encoded as base64. */ + imageBase64: string().describe( + 'Raw uploaded image bytes encoded as base64.' + ) +}).schemaName('TransactionScanImageResponse'); + export const DashboardQuerySchema = object({ /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), @@ -1999,6 +2074,9 @@ export type TransactionScanDraft = InferType; export type TransactionScanDecisionBody = InferType< typeof TransactionScanDecisionBodySchema >; +export type TransactionScanImageResponse = InferType< + typeof TransactionScanImageResponseSchema +>; export type DashboardSummary = InferType; export type DashboardWindowResponse = InferType< typeof DashboardWindowResponseSchema From ba4b07b0fc883f58e98fd508e387cce73d3e33e6 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 06:41:07 +0000 Subject: [PATCH 4/9] fix: upload scan images through route handler --- .../app/api/transaction-scans/route.test.ts | 94 ++++++++++++++ apps/web/app/api/transaction-scans/route.ts | 121 ++++++++++++++++++ .../components/transaction-scan-capture.tsx | 37 +++++- apps/web/lib/actions.ts | 63 --------- apps/web/next.config.ts | 1 + 5 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 apps/web/app/api/transaction-scans/route.test.ts create mode 100644 apps/web/app/api/transaction-scans/route.ts diff --git a/apps/web/app/api/transaction-scans/route.test.ts b/apps/web/app/api/transaction-scans/route.test.ts new file mode 100644 index 0000000..a78d54e --- /dev/null +++ b/apps/web/app/api/transaction-scans/route.test.ts @@ -0,0 +1,94 @@ +import { TransactionScanLimits } from '@xpenser/contracts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + createXpenserClient: vi.fn(), + transactionScanCreate: vi.fn() +})); + +vi.mock('@/auth', () => ({ + auth: mocks.auth +})); + +vi.mock('@/lib/config', () => ({ + webConfig: { apiBaseUrl: 'https://api.example.test' } +})); + +vi.mock('@xpenser/client', () => ({ + createXpenserClient: mocks.createXpenserClient +})); + +import { POST } from './route'; + +function scanRequest(file: File) { + const formData = new FormData(); + formData.set('image', file); + return new Request('https://app.example.test/api/transaction-scans', { + method: 'POST', + body: formData + }); +} + +describe('transaction scan route', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue({ apiToken: 'api-token' }); + mocks.createXpenserClient.mockReturnValue({ + transactionScans: { create: mocks.transactionScanCreate } + }); + mocks.transactionScanCreate.mockResolvedValue({ + scanId: 42, + documentKind: 'receipt', + warnings: [], + drafts: [] + }); + }); + + it('forwards a valid image to the scanner API', async () => { + const file = new File(['receipt bytes'], 'receipt.jpg', { + type: 'image/jpeg' + }); + + const response = await POST(scanRequest(file)); + + await expect(response.json()).resolves.toEqual({ + scan: { + scanId: 42, + documentKind: 'receipt', + warnings: [], + drafts: [] + } + }); + expect(response.status).toBe(200); + expect(mocks.createXpenserClient).toHaveBeenCalledWith({ + baseUrl: 'https://api.example.test', + getToken: expect.any(Function), + retryOnTimeout: false, + timeoutMs: 60_000 + }); + expect(mocks.transactionScanCreate).toHaveBeenCalledWith({ + body: { + imageBase64: Buffer.from('receipt bytes').toString('base64'), + mimeType: 'image/jpeg', + fileName: 'receipt.jpg' + } + }); + }); + + it('rejects oversized images before calling the scanner API', async () => { + const file = new File( + [new Uint8Array(TransactionScanLimits.maxImageBytes + 1)], + 'large.jpg', + { type: 'image/jpeg' } + ); + + const response = await POST(scanRequest(file)); + + await expect(response.json()).resolves.toEqual({ + error: 'Image must be 10 MB or smaller.' + }); + expect(response.status).toBe(413); + expect(mocks.transactionScanCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/transaction-scans/route.ts b/apps/web/app/api/transaction-scans/route.ts new file mode 100644 index 0000000..a77c74a --- /dev/null +++ b/apps/web/app/api/transaction-scans/route.ts @@ -0,0 +1,121 @@ +import { createXpenserClient } from '@xpenser/client'; +import { + TransactionScanLimits, + type TransactionScanResponse +} from '@xpenser/contracts'; +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { webConfig } from '@/lib/config'; + +export const dynamic = 'force-dynamic'; + +const allowedImageTypes = ['image/jpeg', 'image/png', 'image/webp'] as const; +const transactionScanTimeoutMs = 60_000; + +type AllowedImageType = (typeof allowedImageTypes)[number]; +type ScanRouteResponse = + | { readonly error: string; readonly scan?: undefined } + | { readonly error?: undefined; readonly scan: TransactionScanResponse }; + +function uploadedFile(value: FormDataEntryValue | null): File | undefined { + if ( + typeof value === 'object' && + value !== null && + 'arrayBuffer' in value && + 'name' in value && + 'size' in value && + 'type' in value + ) { + return value as File; + } + return undefined; +} + +function isAllowedImageType(value: string): value is AllowedImageType { + return allowedImageTypes.includes(value as AllowedImageType); +} + +function apiErrorStatus(err: unknown): number | undefined { + return typeof err === 'object' && + err !== null && + 'status' in err && + typeof err.status === 'number' + ? err.status + : undefined; +} + +function apiErrorMessage(err: unknown): string | undefined { + const body = + typeof err === 'object' && err !== null && 'body' in err + ? (err as { readonly body?: unknown }).body + : undefined; + return typeof body === 'object' && + body !== null && + 'message' in body && + typeof body.message === 'string' + ? body.message + : undefined; +} + +function errorResponse(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.apiToken) { + return errorResponse('Unauthorized', 401); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return errorResponse('Upload a PNG, JPEG, or WebP image.', 400); + } + + const file = uploadedFile(formData.get('image')); + if (!file || file.size === 0) { + return errorResponse('Choose an image to scan.', 400); + } + if (!isAllowedImageType(file.type)) { + return errorResponse('Upload a PNG, JPEG, or WebP image.', 400); + } + if (file.size > TransactionScanLimits.maxImageBytes) { + return errorResponse('Image must be 10 MB or smaller.', 413); + } + + const client = createXpenserClient({ + baseUrl: webConfig.apiBaseUrl, + getToken: () => session.apiToken, + retryOnTimeout: false, + timeoutMs: transactionScanTimeoutMs + }); + + try { + const imageBase64 = Buffer.from(await file.arrayBuffer()).toString( + 'base64' + ); + const scan = await client.transactionScans.create({ + body: { + imageBase64, + mimeType: file.type, + fileName: file.name + } + }); + return NextResponse.json({ scan }); + } catch (err) { + const status = apiErrorStatus(err); + if (status === 400) { + return errorResponse( + apiErrorMessage(err) ?? + 'Could not scan the image. Try a clearer image.', + 400 + ); + } + if (status === 401) { + return errorResponse('Session expired.', 401); + } + throw err; + } +} diff --git a/apps/web/components/transaction-scan-capture.tsx b/apps/web/components/transaction-scan-capture.tsx index a982723..a0f82ff 100644 --- a/apps/web/components/transaction-scan-capture.tsx +++ b/apps/web/components/transaction-scan-capture.tsx @@ -51,8 +51,7 @@ import { type FormEvent, useMemo, useState } from 'react'; import { createCaptureTransactionAction, createVendorAction, - recordTransactionScanDecisionAction, - scanTransactionImageAction + recordTransactionScanDecisionAction } from '@/lib/actions'; import { categoryEffectiveType, @@ -72,6 +71,10 @@ type TransactionType = Category['type']; const maxImageBytes = TransactionScanLimits.maxImageBytes; +type ScanRouteResponse = + | { readonly error: string; readonly scan?: undefined } + | { readonly error?: undefined; readonly scan: TransactionScanResponse }; + function fileImageBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -89,6 +92,31 @@ function fileImageBase64(file: File): Promise { }); } +async function scanImageFile(file: File): Promise { + const formData = new FormData(); + formData.set('image', file); + + const response = await fetch('/api/transaction-scans', { + method: 'POST', + body: formData + }); + const result = (await response + .json() + .catch(() => null)) as ScanRouteResponse | null; + + if (!response.ok) { + return { + error: + result?.error ?? + (response.status === 413 + ? 'Image must be 10 MB or smaller.' + : 'Could not scan the image. Try again.') + }; + } + + return result ?? { error: 'Could not scan the image. Try again.' }; +} + function parseAmount(value: string): number | undefined { const normalized = value.trim().replace(',', '.'); if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) { @@ -197,13 +225,10 @@ function ScanUpload({ return; } - const formData = new FormData(); - formData.set('image', file); - setPending(true); setError(null); try { - const result = await scanTransactionImageAction(formData); + const result = await scanImageFile(file); if (result.error) { setError(result.error); return; diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 6922778..ac37ce1 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -6,8 +6,6 @@ import { type Transaction, type TransactionScanDecisionBody, type TransactionScanImageResponse, - TransactionScanLimits, - type TransactionScanResponse, UpdateVendorBodySchema, type Vendor, type VendorCandidate @@ -22,7 +20,6 @@ import { VendorUpdateActionRejected } from './log-templates'; import { loggerFor } from './logger'; const passportPkceCookie = 'xpenser_passport_pkce'; -const transactionScanTimeoutMs = 60_000; const vendorActionLogger = loggerFor('Vendor actions'); function normalizeFormText(value: string): string { @@ -604,66 +601,6 @@ export async function createCaptureTransactionAction( return transaction; } -function uploadedFile(value: FormDataEntryValue | null): File | undefined { - if ( - typeof value === 'object' && - value !== null && - 'arrayBuffer' in value && - 'name' in value && - 'size' in value && - 'type' in value - ) { - return value as File; - } - return undefined; -} - -export async function scanTransactionImageAction( - formData: FormData -): Promise< - | { readonly error: string; readonly scan?: undefined } - | { readonly error?: undefined; readonly scan: TransactionScanResponse } -> { - const file = uploadedFile(formData.get('image')); - if (!file || file.size === 0) { - return { error: 'Choose an image to scan.' }; - } - if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { - return { error: 'Upload a PNG, JPEG, or WebP image.' }; - } - if (file.size > TransactionScanLimits.maxImageBytes) { - return { error: 'Image must be 10 MB or smaller.' }; - } - - const buffer = Buffer.from(await file.arrayBuffer()); - const client = await getApiClient({ - retryOnTimeout: false, - timeoutMs: transactionScanTimeoutMs - }); - try { - const scan = await client.transactionScans.create({ - body: { - imageBase64: buffer.toString('base64'), - mimeType: file.type as - | 'image/jpeg' - | 'image/png' - | 'image/webp', - fileName: file.name - } - }); - return { scan }; - } catch (err) { - if (apiErrorStatus(err) === 400) { - return { - error: - apiErrorMessage(err) ?? - 'Could not scan the image. Try a clearer image.' - }; - } - throw err; - } -} - export async function recordTransactionScanDecisionAction({ body, itemId, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 4ea4f4b..eb177c1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { experimental: { + proxyClientMaxBodySize: 20 * 1024 * 1024, serverActions: { bodySizeLimit: '20mb' } From 5cca01e1d0ff0339193d84a1018534fd2b276cb6 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 06:58:19 +0000 Subject: [PATCH 5/9] fix: chunk transaction scan uploads --- .../app/api/transaction-scans/route.test.ts | 59 ++-- apps/web/app/api/transaction-scans/route.ts | 185 +++++++++--- .../components/transaction-scan-capture.tsx | 84 ++++-- apps/web/lib/actions.ts | 44 ++- apps/web/lib/transaction-scan-upload-store.ts | 274 ++++++++++++++++++ packages/contracts/src/limits.ts | 3 +- 6 files changed, 559 insertions(+), 90 deletions(-) create mode 100644 apps/web/lib/transaction-scan-upload-store.ts diff --git a/apps/web/app/api/transaction-scans/route.test.ts b/apps/web/app/api/transaction-scans/route.test.ts index a78d54e..98e0bee 100644 --- a/apps/web/app/api/transaction-scans/route.test.ts +++ b/apps/web/app/api/transaction-scans/route.test.ts @@ -1,5 +1,6 @@ import { TransactionScanLimits } from '@xpenser/contracts'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deleteScanUpload } from '@/lib/transaction-scan-upload-store'; const mocks = vi.hoisted(() => ({ auth: vi.fn(), @@ -21,19 +22,38 @@ vi.mock('@xpenser/client', () => ({ import { POST } from './route'; -function scanRequest(file: File) { - const formData = new FormData(); - formData.set('image', file); +const uploadId = '00000000-0000-4000-8000-000000000001'; +const userId = '1'; + +function scanRequest(body: Record) { return new Request('https://app.example.test/api/transaction-scans', { method: 'POST', - body: formData + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) }); } +function chunkBody(overrides: Partial> = {}) { + const bytes = Buffer.from('receipt bytes'); + return { + chunkBase64: bytes.toString('base64'), + chunkIndex: 0, + fileName: 'receipt.jpg', + fileSize: bytes.length, + mimeType: 'image/jpeg', + totalChunks: 1, + uploadId, + ...overrides + }; +} + describe('transaction scan route', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.auth.mockResolvedValue({ apiToken: 'api-token' }); + mocks.auth.mockResolvedValue({ + apiToken: 'api-token', + user: { id: userId } + }); mocks.createXpenserClient.mockReturnValue({ transactionScans: { create: mocks.transactionScanCreate } }); @@ -45,14 +65,19 @@ describe('transaction scan route', () => { }); }); - it('forwards a valid image to the scanner API', async () => { - const file = new File(['receipt bytes'], 'receipt.jpg', { - type: 'image/jpeg' - }); + afterEach(async () => { + await deleteScanUpload(userId, uploadId); + }); - const response = await POST(scanRequest(file)); + it('forwards a valid image to the scanner API', async () => { + const response = await POST(scanRequest(chunkBody())); await expect(response.json()).resolves.toEqual({ + attachment: { + fileName: 'receipt.jpg', + mimeType: 'image/jpeg', + uploadId + }, scan: { scanId: 42, documentKind: 'receipt', @@ -77,14 +102,14 @@ describe('transaction scan route', () => { }); it('rejects oversized images before calling the scanner API', async () => { - const file = new File( - [new Uint8Array(TransactionScanLimits.maxImageBytes + 1)], - 'large.jpg', - { type: 'image/jpeg' } + const response = await POST( + scanRequest( + chunkBody({ + fileSize: TransactionScanLimits.maxImageBytes + 1 + }) + ) ); - const response = await POST(scanRequest(file)); - await expect(response.json()).resolves.toEqual({ error: 'Image must be 10 MB or smaller.' }); diff --git a/apps/web/app/api/transaction-scans/route.ts b/apps/web/app/api/transaction-scans/route.ts index a77c74a..25fb3ac 100644 --- a/apps/web/app/api/transaction-scans/route.ts +++ b/apps/web/app/api/transaction-scans/route.ts @@ -1,39 +1,43 @@ import { createXpenserClient } from '@xpenser/client'; -import { - TransactionScanLimits, - type TransactionScanResponse -} from '@xpenser/contracts'; +import type { TransactionScanResponse } from '@xpenser/contracts'; import { NextResponse } from 'next/server'; import { auth } from '@/auth'; import { webConfig } from '@/lib/config'; +import { + assembleScanUploadChunks, + cleanupStaleScanUploads, + createScanUploadId, + isAllowedScanImageType, + isScanUploadId, + type StoredScanAttachment, + scanUploadChunkCountError, + scanUploadFileSizeError, + storeScanUpload, + transactionScanTimeoutMs, + writeScanUploadChunk +} from '@/lib/transaction-scan-upload-store'; export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; -const allowedImageTypes = ['image/jpeg', 'image/png', 'image/webp'] as const; -const transactionScanTimeoutMs = 60_000; - -type AllowedImageType = (typeof allowedImageTypes)[number]; type ScanRouteResponse = | { readonly error: string; readonly scan?: undefined } - | { readonly error?: undefined; readonly scan: TransactionScanResponse }; - -function uploadedFile(value: FormDataEntryValue | null): File | undefined { - if ( - typeof value === 'object' && - value !== null && - 'arrayBuffer' in value && - 'name' in value && - 'size' in value && - 'type' in value - ) { - return value as File; - } - return undefined; -} + | { readonly error?: undefined; readonly uploaded: true } + | { + readonly attachment: StoredScanAttachment; + readonly error?: undefined; + readonly scan: TransactionScanResponse; + }; -function isAllowedImageType(value: string): value is AllowedImageType { - return allowedImageTypes.includes(value as AllowedImageType); -} +type ScanChunkBody = { + readonly chunkBase64: string; + readonly chunkIndex: number; + readonly fileName?: string; + readonly fileSize: number; + readonly mimeType: string; + readonly totalChunks: number; + readonly uploadId: string; +}; function apiErrorStatus(err: unknown): number | undefined { return typeof err === 'object' && @@ -61,28 +65,118 @@ function errorResponse(message: string, status: number) { return NextResponse.json({ error: message }, { status }); } +function scanChunkBody(value: unknown): ScanChunkBody | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + + const record = value as Record; + if ( + typeof record.chunkBase64 !== 'string' || + typeof record.chunkIndex !== 'number' || + typeof record.fileSize !== 'number' || + typeof record.mimeType !== 'string' || + typeof record.totalChunks !== 'number' || + typeof record.uploadId !== 'string' + ) { + return undefined; + } + + if (record.fileName !== undefined && typeof record.fileName !== 'string') { + return undefined; + } + + return { + chunkBase64: record.chunkBase64, + chunkIndex: record.chunkIndex, + fileName: record.fileName, + fileSize: record.fileSize, + mimeType: record.mimeType, + totalChunks: record.totalChunks, + uploadId: record.uploadId + }; +} + +function validChunkIndex(chunkIndex: number, totalChunks: number): boolean { + return ( + Number.isSafeInteger(chunkIndex) && + chunkIndex >= 0 && + chunkIndex < totalChunks + ); +} + +function bodyValidationError(body: ScanChunkBody): string | undefined { + if (!isScanUploadId(body.uploadId)) { + return 'Could not scan the image. Try again.'; + } + if (!isAllowedScanImageType(body.mimeType)) { + return 'Upload a PNG, JPEG, or WebP image.'; + } + if (!validChunkIndex(body.chunkIndex, body.totalChunks)) { + return 'Could not scan the image. Try again.'; + } + return ( + scanUploadFileSizeError(body.fileSize) ?? + scanUploadChunkCountError(body.totalChunks) + ); +} + export async function POST(request: Request) { const session = await auth(); if (!session?.apiToken) { return errorResponse('Unauthorized', 401); } - let formData: FormData; + let body: ScanChunkBody | undefined; try { - formData = await request.formData(); + body = scanChunkBody(await request.json()); } catch { - return errorResponse('Upload a PNG, JPEG, or WebP image.', 400); + return errorResponse('Could not scan the image. Try again.', 400); + } + if (!body) { + return errorResponse('Could not scan the image. Try again.', 400); } - const file = uploadedFile(formData.get('image')); - if (!file || file.size === 0) { - return errorResponse('Choose an image to scan.', 400); + const bodyError = bodyValidationError(body); + if (bodyError) { + return errorResponse( + bodyError, + bodyError.includes('10 MB') ? 413 : 400 + ); } - if (!isAllowedImageType(file.type)) { + if (!isAllowedScanImageType(body.mimeType)) { return errorResponse('Upload a PNG, JPEG, or WebP image.', 400); } - if (file.size > TransactionScanLimits.maxImageBytes) { - return errorResponse('Image must be 10 MB or smaller.', 413); + + const userId = session.user.id; + const mimeType = body.mimeType; + await cleanupStaleScanUploads(); + + try { + await writeScanUploadChunk({ + chunkBase64: body.chunkBase64, + chunkIndex: body.chunkIndex, + uploadId: body.uploadId, + userId + }); + } catch { + return errorResponse('Could not scan the image. Try again.', 400); + } + + if (body.chunkIndex < body.totalChunks - 1) { + return NextResponse.json({ uploaded: true }); + } + + let image: Buffer; + try { + image = await assembleScanUploadChunks({ + expectedSize: body.fileSize, + totalChunks: body.totalChunks, + uploadId: body.uploadId, + userId + }); + } catch { + return errorResponse('Could not scan the image. Try again.', 400); } const client = createXpenserClient({ @@ -93,17 +187,22 @@ export async function POST(request: Request) { }); try { - const imageBase64 = Buffer.from(await file.arrayBuffer()).toString( - 'base64' - ); + const imageBase64 = image.toString('base64'); const scan = await client.transactionScans.create({ body: { imageBase64, - mimeType: file.type, - fileName: file.name + mimeType, + fileName: body.fileName } }); - return NextResponse.json({ scan }); + const attachment = await storeScanUpload({ + buffer: image, + fileName: body.fileName, + mimeType, + uploadId: body.uploadId, + userId + }); + return NextResponse.json({ attachment, scan }); } catch (err) { const status = apiErrorStatus(err); if (status === 400) { @@ -119,3 +218,7 @@ export async function POST(request: Request) { throw err; } } + +export async function GET() { + return NextResponse.json({ uploadId: createScanUploadId() }); +} diff --git a/apps/web/components/transaction-scan-capture.tsx b/apps/web/components/transaction-scan-capture.tsx index a0f82ff..240d495 100644 --- a/apps/web/components/transaction-scan-capture.tsx +++ b/apps/web/components/transaction-scan-capture.tsx @@ -66,16 +66,28 @@ import { VendorPicker } from './vendor-picker'; type CaptureMode = 'manual' | 'scan'; type Decision = 'confirmed' | 'discarded'; -type ScanAttachment = NonNullable; +type ScanAttachment = { + readonly fileName?: string; + readonly mimeType: NonNullable< + TransactionScanDecisionBody['attachment'] + >['mimeType']; + readonly uploadId: string; +}; type TransactionType = Category['type']; const maxImageBytes = TransactionScanLimits.maxImageBytes; +const uploadChunkBytes = TransactionScanLimits.uploadChunkBytes; type ScanRouteResponse = | { readonly error: string; readonly scan?: undefined } - | { readonly error?: undefined; readonly scan: TransactionScanResponse }; + | { readonly error?: undefined; readonly uploaded: true } + | { + readonly attachment: ScanAttachment; + readonly error?: undefined; + readonly scan: TransactionScanResponse; + }; -function fileImageBase64(file: File): Promise { +function blobBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read image.')); @@ -88,33 +100,53 @@ function fileImageBase64(file: File): Promise { const commaIndex = value.indexOf(','); resolve(commaIndex >= 0 ? value.slice(commaIndex + 1) : value); }; - reader.readAsDataURL(file); + reader.readAsDataURL(blob); }); } async function scanImageFile(file: File): Promise { - const formData = new FormData(); - formData.set('image', file); + const uploadId = crypto.randomUUID(); + const totalChunks = Math.ceil(file.size / uploadChunkBytes); - const response = await fetch('/api/transaction-scans', { - method: 'POST', - body: formData - }); - const result = (await response - .json() - .catch(() => null)) as ScanRouteResponse | null; + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + const start = chunkIndex * uploadChunkBytes; + const response = await fetch('/api/transaction-scans', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chunkBase64: await blobBase64( + file.slice(start, start + uploadChunkBytes) + ), + chunkIndex, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + totalChunks, + uploadId + }) + }); + const result = (await response + .json() + .catch(() => null)) as ScanRouteResponse | null; - if (!response.ok) { - return { - error: - result?.error ?? - (response.status === 413 - ? 'Image must be 10 MB or smaller.' - : 'Could not scan the image. Try again.') - }; + if (!response.ok) { + return { + error: + result?.error ?? + (response.status === 413 + ? 'Image must be 10 MB or smaller.' + : 'Could not scan the image. Try again.') + }; + } + if (result?.error) { + return result; + } + if (result && 'scan' in result) { + return result; + } } - return result ?? { error: 'Could not scan the image. Try again.' }; + return { error: 'Could not scan the image. Try again.' }; } function parseAmount(value: string): number | undefined { @@ -233,15 +265,11 @@ function ScanUpload({ setError(result.error); return; } - if (!result.scan) { + if (!('attachment' in result)) { setError('Could not scan the image. Try again.'); return; } - onScanned(result.scan, { - imageBase64: await fileImageBase64(file), - mimeType: file.type as ScanAttachment['mimeType'], - fileName: file.name - }); + onScanned(result.scan, result.attachment); } catch { setError('Could not scan the image. Try again.'); } finally { diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index ac37ce1..19ca84b 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -14,14 +14,33 @@ import { revalidatePath, revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { signIn, signOut } from '../auth'; -import { getAnonymousApiClient, getApiClient } from './api'; +import { + getAnonymousApiClient, + getApiClient, + getSessionOrRedirect +} from './api'; import { webConfig } from './config'; import { VendorUpdateActionRejected } from './log-templates'; import { loggerFor } from './logger'; +import { + deleteScanUpload, + readScanUploadAttachment +} from './transaction-scan-upload-store'; const passportPkceCookie = 'xpenser_passport_pkce'; const vendorActionLogger = loggerFor('Vendor actions'); +type ScanDecisionAttachment = + | NonNullable + | { readonly uploadId: string }; + +type TransactionScanDecisionActionBody = Omit< + TransactionScanDecisionBody, + 'attachment' +> & { + readonly attachment?: ScanDecisionAttachment; +}; + function normalizeFormText(value: string): string { return value.replace(/\r\n?/g, '\n').trim(); } @@ -606,15 +625,34 @@ export async function recordTransactionScanDecisionAction({ itemId, scanId }: { - readonly body: TransactionScanDecisionBody; + readonly body: TransactionScanDecisionActionBody; readonly itemId: number; readonly scanId: number; }): Promise { + const { attachment: requestedAttachment, ...decisionBody } = body; + const uploadId = + requestedAttachment && + 'uploadId' in requestedAttachment && + typeof requestedAttachment.uploadId === 'string' + ? requestedAttachment.uploadId + : undefined; + const session = uploadId ? await getSessionOrRedirect() : null; + const attachment: TransactionScanDecisionBody['attachment'] = uploadId + ? await readScanUploadAttachment(session?.user.id, uploadId) + : requestedAttachment && !('uploadId' in requestedAttachment) + ? requestedAttachment + : undefined; const client = await getApiClient(); await client.transactionScans.decide({ params: { scanId, itemId }, - body + body: { + ...decisionBody, + attachment + } }); + if (uploadId) { + await deleteScanUpload(session?.user.id, uploadId); + } } export async function getTransactionScanImageAction( diff --git a/apps/web/lib/transaction-scan-upload-store.ts b/apps/web/lib/transaction-scan-upload-store.ts new file mode 100644 index 0000000..d4c68ab --- /dev/null +++ b/apps/web/lib/transaction-scan-upload-store.ts @@ -0,0 +1,274 @@ +import { randomUUID } from 'node:crypto'; +import { + mkdir, + readdir, + readFile, + rm, + stat, + writeFile +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type TransactionScanDecisionBody, + TransactionScanLimits +} from '@xpenser/contracts'; + +export const allowedScanImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp' +] as const; + +export const transactionScanTimeoutMs = 60_000; + +const uploadRoot = join(tmpdir(), 'xpenser-transaction-scan-uploads'); +const uploadTtlMs = 24 * 60 * 60 * 1000; + +export type AllowedScanImageType = (typeof allowedScanImageTypes)[number]; +export type StoredScanAttachment = { + readonly fileName?: string; + readonly mimeType: AllowedScanImageType; + readonly uploadId: string; +}; + +type StoredScanMetadata = StoredScanAttachment & { + readonly createdAt: string; + readonly size: number; +}; + +export function createScanUploadId(): string { + return randomUUID(); +} + +export function isAllowedScanImageType( + value: string +): value is AllowedScanImageType { + return allowedScanImageTypes.includes(value as AllowedScanImageType); +} + +export function isScanUploadId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value + ); +} + +export function scanUploadFileSizeError(fileSize: number): string | undefined { + if (!Number.isSafeInteger(fileSize) || fileSize <= 0) { + return 'Choose an image to scan.'; + } + if (fileSize > TransactionScanLimits.maxImageBytes) { + return 'Image must be 10 MB or smaller.'; + } + return undefined; +} + +export function scanUploadChunkCountError( + totalChunks: number +): string | undefined { + const maxChunks = Math.ceil( + TransactionScanLimits.maxImageBytes / + TransactionScanLimits.uploadChunkBytes + ); + if ( + !Number.isSafeInteger(totalChunks) || + totalChunks <= 0 || + totalChunks > maxChunks + ) { + return 'Could not scan the image. Try again.'; + } + return undefined; +} + +function safeUserId(userId: unknown): string { + const normalized = String(userId ?? 'anonymous').replace( + /[^A-Za-z0-9_-]/g, + '_' + ); + return normalized || 'anonymous'; +} + +function scanUploadDir(userId: unknown, uploadId: string): string { + return join(uploadRoot, safeUserId(userId), uploadId); +} + +function chunksDir(userId: unknown, uploadId: string): string { + return join(scanUploadDir(userId, uploadId), 'chunks'); +} + +function imagePath(userId: unknown, uploadId: string): string { + return join(scanUploadDir(userId, uploadId), 'image.bin'); +} + +function metadataPath(userId: unknown, uploadId: string): string { + return join(scanUploadDir(userId, uploadId), 'metadata.json'); +} + +function chunkPath( + userId: unknown, + uploadId: string, + chunkIndex: number +): string { + return join(chunksDir(userId, uploadId), `${chunkIndex}.bin`); +} + +function normalizedFileName(fileName: string | undefined): string | undefined { + const trimmed = fileName?.trim(); + return trimmed ? trimmed.slice(0, 255) : undefined; +} + +export async function cleanupStaleScanUploads(): Promise { + const cutoff = Date.now() - uploadTtlMs; + let userDirs: string[]; + try { + userDirs = await readdir(uploadRoot); + } catch { + return; + } + + await Promise.all( + userDirs.map(async userDir => { + const userPath = join(uploadRoot, userDir); + let uploadDirs: string[]; + try { + uploadDirs = await readdir(userPath); + } catch { + return; + } + + await Promise.all( + uploadDirs.map(async uploadDir => { + const dir = join(userPath, uploadDir); + try { + const info = await stat(dir); + if (info.mtimeMs < cutoff) { + await rm(dir, { force: true, recursive: true }); + } + } catch { + return; + } + }) + ); + }) + ); +} + +export async function writeScanUploadChunk({ + chunkBase64, + chunkIndex, + uploadId, + userId +}: { + readonly chunkBase64: string; + readonly chunkIndex: number; + readonly uploadId: string; + readonly userId: unknown; +}): Promise { + const chunk = Buffer.from(chunkBase64, 'base64'); + if ( + chunk.length === 0 || + chunk.length > TransactionScanLimits.uploadChunkBytes + ) { + throw new Error('Invalid scan upload chunk.'); + } + + const dir = chunksDir(userId, uploadId); + await mkdir(dir, { recursive: true }); + await writeFile(chunkPath(userId, uploadId, chunkIndex), chunk); + return chunk; +} + +export async function assembleScanUploadChunks({ + expectedSize, + totalChunks, + uploadId, + userId +}: { + readonly expectedSize: number; + readonly totalChunks: number; + readonly uploadId: string; + readonly userId: unknown; +}): Promise { + const chunks = await Promise.all( + Array.from({ length: totalChunks }, async (_, index) => + readFile(chunkPath(userId, uploadId, index)) + ) + ); + const buffer = Buffer.concat(chunks); + if (buffer.length !== expectedSize) { + throw new Error('Scan upload size did not match.'); + } + return buffer; +} + +export async function storeScanUpload({ + buffer, + fileName, + mimeType, + uploadId, + userId +}: { + readonly buffer: Buffer; + readonly fileName?: string; + readonly mimeType: AllowedScanImageType; + readonly uploadId: string; + readonly userId: unknown; +}): Promise { + const dir = scanUploadDir(userId, uploadId); + await mkdir(dir, { recursive: true }); + await writeFile(imagePath(userId, uploadId), buffer); + await writeFile( + metadataPath(userId, uploadId), + JSON.stringify({ + createdAt: new Date().toISOString(), + fileName: normalizedFileName(fileName), + mimeType, + size: buffer.length, + uploadId + } satisfies StoredScanMetadata) + ); + await rm(chunksDir(userId, uploadId), { force: true, recursive: true }); + return { + fileName: normalizedFileName(fileName), + mimeType, + uploadId + }; +} + +export async function readScanUploadAttachment( + userId: unknown, + uploadId: string +): Promise> { + if (!isScanUploadId(uploadId)) { + throw new Error('Invalid scan upload.'); + } + + const metadata = JSON.parse( + await readFile(metadataPath(userId, uploadId), 'utf8') + ) as StoredScanMetadata; + if (!isAllowedScanImageType(metadata.mimeType)) { + throw new Error('Invalid scan upload.'); + } + + const buffer = await readFile(imagePath(userId, uploadId)); + const sizeError = scanUploadFileSizeError(buffer.length); + if (sizeError || buffer.length !== metadata.size) { + throw new Error('Invalid scan upload.'); + } + + return { + imageBase64: buffer.toString('base64'), + mimeType: metadata.mimeType, + fileName: normalizedFileName(metadata.fileName) + }; +} + +export async function deleteScanUpload( + userId: unknown, + uploadId: string +): Promise { + if (!isScanUploadId(uploadId)) { + return; + } + await rm(scanUploadDir(userId, uploadId), { force: true, recursive: true }); +} diff --git a/packages/contracts/src/limits.ts b/packages/contracts/src/limits.ts index 351e713..13d9ec1 100644 --- a/packages/contracts/src/limits.ts +++ b/packages/contracts/src/limits.ts @@ -27,5 +27,6 @@ export const FieldLimits = { } as const; export const TransactionScanLimits = { - maxImageBytes: 10 * 1024 * 1024 + maxImageBytes: 10 * 1024 * 1024, + uploadChunkBytes: 384 * 1024 } as const; From f8ac5dcdd439205e49eb8384a9737b07e9690173 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 07:06:45 +0000 Subject: [PATCH 6/9] fix: extend chunked scan timeout --- apps/web/app/api/transaction-scans/route.test.ts | 2 +- apps/web/lib/transaction-scan-upload-store.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/transaction-scans/route.test.ts b/apps/web/app/api/transaction-scans/route.test.ts index 98e0bee..9099b2b 100644 --- a/apps/web/app/api/transaction-scans/route.test.ts +++ b/apps/web/app/api/transaction-scans/route.test.ts @@ -90,7 +90,7 @@ describe('transaction scan route', () => { baseUrl: 'https://api.example.test', getToken: expect.any(Function), retryOnTimeout: false, - timeoutMs: 60_000 + timeoutMs: 95_000 }); expect(mocks.transactionScanCreate).toHaveBeenCalledWith({ body: { diff --git a/apps/web/lib/transaction-scan-upload-store.ts b/apps/web/lib/transaction-scan-upload-store.ts index d4c68ab..53d0f1a 100644 --- a/apps/web/lib/transaction-scan-upload-store.ts +++ b/apps/web/lib/transaction-scan-upload-store.ts @@ -20,7 +20,7 @@ export const allowedScanImageTypes = [ 'image/webp' ] as const; -export const transactionScanTimeoutMs = 60_000; +export const transactionScanTimeoutMs = 95_000; const uploadRoot = join(tmpdir(), 'xpenser-transaction-scan-uploads'); const uploadTtlMs = 24 * 60 * 60 * 1000; From c58af99f9c6237947c9f0cbbbe0d9d6aeca88f25 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Thu, 4 Jun 2026 07:27:13 +0000 Subject: [PATCH 7/9] feat: stream transaction scan progress --- apps/api/src/api/endpoints.ts | 20 ++ apps/api/src/api/handlers/index.ts | 6 +- .../api/src/api/handlers/transaction-scans.ts | 27 +- .../src/application/transaction-scan-jobs.ts | 284 ++++++++++++++++++ apps/api/src/application/transaction-scans.ts | 12 +- .../app/api/transaction-scans/route.test.ts | 27 +- apps/web/app/api/transaction-scans/route.ts | 26 +- .../components/transaction-scan-capture.tsx | 109 ++++++- apps/web/lib/transaction-scan-upload-store.ts | 2 - docker-compose.prod.yml | 2 + docker-compose.yml | 2 + packages/contracts/src/api.test.ts | 2 + packages/contracts/src/api.ts | 17 ++ packages/contracts/src/schemas.test.ts | 33 ++ packages/contracts/src/schemas.ts | 65 ++++ pr-env.sh | 3 + 16 files changed, 594 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/application/transaction-scan-jobs.ts diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index 1dadb3d..16dad81 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -317,6 +317,24 @@ export const CreateTransactionScanEndpoint = api.transactionScans.create .tags('transaction-scans') .operationId('createTransactionScan'); +export const StartTransactionScanJobEndpoint = api.transactionScans.start + .authorize(PrincipalSchema) + .inject({ db: DbToken, config: ConfigToken }) + .summary('Start transaction image scan') + .description( + 'Starts an asynchronous multimodal scan job and returns a short-lived progress token.' + ) + .tags('transaction-scans') + .operationId('startTransactionScanJob'); + +export const TransactionScanProgressEndpoint = api.transactionScans.progress + .summary('Transaction image scan progress') + .description( + 'Streams progress and the final scan result for a short-lived scan job token.' + ) + .tags('transaction-scans') + .operationId('transactionScanProgress'); + export const DecideTransactionScanItemEndpoint = api.transactionScans.decide .authorize(PrincipalSchema) .inject({ db: DbToken }) @@ -420,6 +438,8 @@ export const endpoints = { }, transactionScans: { create: CreateTransactionScanEndpoint, + start: StartTransactionScanJobEndpoint, + progress: TransactionScanProgressEndpoint, decide: DecideTransactionScanItemEndpoint }, dashboard: { diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index 4527ad1..346d4df 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -31,7 +31,9 @@ import { } from './telegram.js'; import { createTransactionScanHandler, - decideTransactionScanItemHandler + decideTransactionScanItemHandler, + startTransactionScanJobHandler, + transactionScanProgressHandler } from './transaction-scans.js'; import { categoryTrendHandler, @@ -108,6 +110,8 @@ export const handlers = { }, transactionScans: { create: createTransactionScanHandler, + start: startTransactionScanJobHandler, + progress: transactionScanProgressHandler, decide: decideTransactionScanItemHandler }, dashboard: { diff --git a/apps/api/src/api/handlers/transaction-scans.ts b/apps/api/src/api/handlers/transaction-scans.ts index 62c931d..7e89a8f 100644 --- a/apps/api/src/api/handlers/transaction-scans.ts +++ b/apps/api/src/api/handlers/transaction-scans.ts @@ -1,4 +1,12 @@ -import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + ActionResult, + type Handler, + type SubscriptionHandler +} from '@cleverbrush/server'; +import { + startTransactionScanJob, + subscribeTransactionScanJob +} from '../../application/transaction-scan-jobs.js'; import { recordTransactionScanDecision, scanTransactionsFromImage, @@ -7,7 +15,9 @@ import { } from '../../application/transaction-scans.js'; import type { CreateTransactionScanEndpoint, - DecideTransactionScanItemEndpoint + DecideTransactionScanItemEndpoint, + StartTransactionScanJobEndpoint, + TransactionScanProgressEndpoint } from '../endpoints.js'; export const createTransactionScanHandler: Handler< @@ -32,6 +42,19 @@ export const createTransactionScanHandler: Handler< } }; +export const startTransactionScanJobHandler: Handler< + typeof StartTransactionScanJobEndpoint +> = async ({ body, principal }, { db, config }) => { + const job = startTransactionScanJob(db, config, principal.userId, body); + return ActionResult.accepted(job); +}; + +export const transactionScanProgressHandler: SubscriptionHandler< + typeof TransactionScanProgressEndpoint +> = async function* ({ query, signal }) { + yield* subscribeTransactionScanJob(query, signal); +}; + export const decideTransactionScanItemHandler: Handler< typeof DecideTransactionScanItemEndpoint > = async ({ body, params, principal }, { db }) => { diff --git a/apps/api/src/application/transaction-scan-jobs.ts b/apps/api/src/application/transaction-scan-jobs.ts new file mode 100644 index 0000000..e069eb7 --- /dev/null +++ b/apps/api/src/application/transaction-scan-jobs.ts @@ -0,0 +1,284 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import type { + TransactionScanBody, + TransactionScanJobResponse, + TransactionScanProgressEvent, + TransactionScanProgressQuery, + TransactionScanResponse +} from '@xpenser/contracts'; +import type { Config } from '../config.js'; +import type { AppDb } from '../db/schemas.js'; +import { + scanTransactionsFromImage, + TransactionScanInputError +} from './transaction-scans.js'; + +const jobTtlMs = 30 * 60 * 1_000; + +type MutableScanJob = { + readonly events: TransactionScanProgressEvent[]; + readonly id: string; + readonly listeners: Set<() => void>; + readonly token: string; + readonly userId: number; + deleteTimer: ReturnType | null; + done: boolean; + expiresAt: number; +}; + +type ProgressStage = Exclude< + TransactionScanProgressEvent['stage'], + 'complete' | 'failed' | 'queued' +>; + +const jobs = new Map(); + +function scheduleDelete(job: MutableScanJob): void { + if (job.deleteTimer) { + return; + } + + job.deleteTimer = setTimeout(() => { + if (jobs.get(job.id) === job) { + jobs.delete(job.id); + } + }, jobTtlMs); + job.deleteTimer.unref?.(); +} + +function cleanupExpiredJobs(): void { + const now = Date.now(); + for (const [jobId, job] of jobs) { + if (job.expiresAt <= now) { + jobs.delete(jobId); + if (job.deleteTimer) { + clearTimeout(job.deleteTimer); + } + } + } +} + +function notify(job: MutableScanJob): void { + for (const listener of job.listeners) { + listener(); + } + job.listeners.clear(); +} + +function event({ + error = null, + job, + message, + progress, + scan = null, + stage +}: { + readonly error?: string | null; + readonly job: MutableScanJob; + readonly message: string; + readonly progress: number; + readonly scan?: TransactionScanResponse | null; + readonly stage: TransactionScanProgressEvent['stage']; +}): TransactionScanProgressEvent { + return { + jobId: job.id, + stage, + message, + progress, + scan, + error + }; +} + +function emit(job: MutableScanJob, nextEvent: TransactionScanProgressEvent) { + if (job.done) { + return; + } + + job.events.push(nextEvent); + if (nextEvent.stage === 'complete' || nextEvent.stage === 'failed') { + job.done = true; + job.expiresAt = Date.now() + jobTtlMs; + scheduleDelete(job); + } + notify(job); +} + +function progressMessage(stage: ProgressStage): string { + switch (stage) { + case 'preparing': + return 'Loading categories, vendors, and prior scan corrections.'; + case 'analyzing': + return 'Asking AI to extract transactions from the image.'; + case 'saving': + return 'Saving scan suggestions for review.'; + } + return 'Scanning image.'; +} + +function progressValue(stage: ProgressStage): number { + switch (stage) { + case 'preparing': + return 15; + case 'analyzing': + return 45; + case 'saving': + return 85; + } + return 50; +} + +function failureMessage(err: unknown): string { + return err instanceof TransactionScanInputError + ? err.message + : 'Could not scan the image. Try again.'; +} + +async function runJob( + job: MutableScanJob, + db: AppDb, + config: Config, + body: TransactionScanBody +): Promise { + try { + const scan = await scanTransactionsFromImage( + db, + config, + job.userId, + body, + { + onProgress: stage => + emit( + job, + event({ + job, + message: progressMessage(stage), + progress: progressValue(stage), + stage + }) + ) + } + ); + const count = scan.drafts.length; + emit( + job, + event({ + job, + message: + count === 1 + ? 'Found 1 transaction for review.' + : `Found ${count} transactions for review.`, + progress: 100, + scan, + stage: 'complete' + }) + ); + } catch (err) { + emit( + job, + event({ + error: failureMessage(err), + job, + message: 'Scan failed.', + progress: 100, + stage: 'failed' + }) + ); + } +} + +function createJob(userId: number): MutableScanJob { + return { + events: [], + id: randomUUID(), + listeners: new Set(), + token: randomBytes(32).toString('base64url'), + userId, + deleteTimer: null, + done: false, + expiresAt: Date.now() + jobTtlMs + }; +} + +export function startTransactionScanJob( + db: AppDb, + config: Config, + userId: number, + body: TransactionScanBody +): TransactionScanJobResponse { + cleanupExpiredJobs(); + const job = createJob(userId); + jobs.set(job.id, job); + emit( + job, + event({ + job, + message: 'Scan queued.', + progress: 0, + stage: 'queued' + }) + ); + void runJob(job, db, config, body); + return { jobId: job.id, token: job.token }; +} + +function authorizedJob(query: TransactionScanProgressQuery) { + cleanupExpiredJobs(); + const job = jobs.get(query.jobId); + return job && job.token === query.token ? job : undefined; +} + +function waitForEvent(job: MutableScanJob, signal: AbortSignal): Promise { + if (signal.aborted || job.done) { + return Promise.resolve(); + } + + return new Promise(resolve => { + let resolved = false; + const listener = () => { + if (resolved) { + return; + } + resolved = true; + job.listeners.delete(listener); + signal.removeEventListener('abort', abortListener); + resolve(); + }; + const abortListener = () => listener(); + job.listeners.add(listener); + signal.addEventListener('abort', abortListener, { once: true }); + }); +} + +export async function* subscribeTransactionScanJob( + query: TransactionScanProgressQuery, + signal: AbortSignal +): AsyncGenerator { + const job = authorizedJob(query); + if (!job) { + yield { + jobId: query.jobId, + stage: 'failed', + message: 'Scan job was not found.', + progress: 100, + scan: null, + error: 'Scan job was not found.' + }; + return; + } + + let index = 0; + while (!signal.aborted) { + while (index < job.events.length) { + const nextEvent = job.events[index]; + index += 1; + if (nextEvent) { + yield nextEvent; + } + } + if (job.done) { + return; + } + await waitForEvent(job, signal); + } +} diff --git a/apps/api/src/application/transaction-scans.ts b/apps/api/src/application/transaction-scans.ts index b656258..e73a5c0 100644 --- a/apps/api/src/application/transaction-scans.ts +++ b/apps/api/src/application/transaction-scans.ts @@ -105,6 +105,12 @@ type CorrectionExample = { readonly corrected: unknown; }; +type ScanProgressStage = 'analyzing' | 'preparing' | 'saving'; + +type ScanProgressOptions = { + readonly onProgress?: (stage: ScanProgressStage) => void; +}; + type TransactionScanItemQuery = Promise & { readonly first: () => Promise; readonly update: ( @@ -660,10 +666,12 @@ export async function scanTransactionsFromImage( db: AppDb, config: Config, userId: number, - body: TransactionScanBody + body: TransactionScanBody, + options: ScanProgressOptions = {} ): Promise { const buffer = imageBuffer(body); const imageHash = createHash('sha256').update(buffer).digest('hex'); + options.onProgress?.('preparing'); const user = await getUser(db, userId); const [categories, vendors, recentTransactions, examples] = await Promise.all([ @@ -677,6 +685,7 @@ export async function scanTransactionsFromImage( correctionExamples(db, userId) ]); + options.onProgress?.('analyzing'); const parsed = await generateStructuredJsonFromContent( config, { @@ -708,6 +717,7 @@ export async function scanTransactionsFromImage( } ); + options.onProgress?.('saving'); const scan = await db.transactionScans.insert({ userId, documentKind: documentKind(parsed.documentKind), diff --git a/apps/web/app/api/transaction-scans/route.test.ts b/apps/web/app/api/transaction-scans/route.test.ts index 9099b2b..f6c6356 100644 --- a/apps/web/app/api/transaction-scans/route.test.ts +++ b/apps/web/app/api/transaction-scans/route.test.ts @@ -5,7 +5,7 @@ import { deleteScanUpload } from '@/lib/transaction-scan-upload-store'; const mocks = vi.hoisted(() => ({ auth: vi.fn(), createXpenserClient: vi.fn(), - transactionScanCreate: vi.fn() + transactionScanStart: vi.fn() })); vi.mock('@/auth', () => ({ @@ -55,13 +55,11 @@ describe('transaction scan route', () => { user: { id: userId } }); mocks.createXpenserClient.mockReturnValue({ - transactionScans: { create: mocks.transactionScanCreate } + transactionScans: { start: mocks.transactionScanStart } }); - mocks.transactionScanCreate.mockResolvedValue({ - scanId: 42, - documentKind: 'receipt', - warnings: [], - drafts: [] + mocks.transactionScanStart.mockResolvedValue({ + jobId: '00000000-0000-4000-8000-000000000042', + token: 'scan-token' }); }); @@ -78,21 +76,18 @@ describe('transaction scan route', () => { mimeType: 'image/jpeg', uploadId }, - scan: { - scanId: 42, - documentKind: 'receipt', - warnings: [], - drafts: [] + job: { + jobId: '00000000-0000-4000-8000-000000000042', + token: 'scan-token' } }); expect(response.status).toBe(200); expect(mocks.createXpenserClient).toHaveBeenCalledWith({ baseUrl: 'https://api.example.test', getToken: expect.any(Function), - retryOnTimeout: false, - timeoutMs: 95_000 + retryOnTimeout: false }); - expect(mocks.transactionScanCreate).toHaveBeenCalledWith({ + expect(mocks.transactionScanStart).toHaveBeenCalledWith({ body: { imageBase64: Buffer.from('receipt bytes').toString('base64'), mimeType: 'image/jpeg', @@ -114,6 +109,6 @@ describe('transaction scan route', () => { error: 'Image must be 10 MB or smaller.' }); expect(response.status).toBe(413); - expect(mocks.transactionScanCreate).not.toHaveBeenCalled(); + expect(mocks.transactionScanStart).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/transaction-scans/route.ts b/apps/web/app/api/transaction-scans/route.ts index 25fb3ac..fb4205d 100644 --- a/apps/web/app/api/transaction-scans/route.ts +++ b/apps/web/app/api/transaction-scans/route.ts @@ -1,5 +1,5 @@ import { createXpenserClient } from '@xpenser/client'; -import type { TransactionScanResponse } from '@xpenser/contracts'; +import type { TransactionScanJobResponse } from '@xpenser/contracts'; import { NextResponse } from 'next/server'; import { auth } from '@/auth'; import { webConfig } from '@/lib/config'; @@ -13,7 +13,6 @@ import { scanUploadChunkCountError, scanUploadFileSizeError, storeScanUpload, - transactionScanTimeoutMs, writeScanUploadChunk } from '@/lib/transaction-scan-upload-store'; @@ -21,12 +20,12 @@ export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; type ScanRouteResponse = - | { readonly error: string; readonly scan?: undefined } + | { readonly error: string; readonly job?: undefined } | { readonly error?: undefined; readonly uploaded: true } | { readonly attachment: StoredScanAttachment; readonly error?: undefined; - readonly scan: TransactionScanResponse; + readonly job: TransactionScanJobResponse; }; type ScanChunkBody = { @@ -182,19 +181,11 @@ export async function POST(request: Request) { const client = createXpenserClient({ baseUrl: webConfig.apiBaseUrl, getToken: () => session.apiToken, - retryOnTimeout: false, - timeoutMs: transactionScanTimeoutMs + retryOnTimeout: false }); try { const imageBase64 = image.toString('base64'); - const scan = await client.transactionScans.create({ - body: { - imageBase64, - mimeType, - fileName: body.fileName - } - }); const attachment = await storeScanUpload({ buffer: image, fileName: body.fileName, @@ -202,7 +193,14 @@ export async function POST(request: Request) { uploadId: body.uploadId, userId }); - return NextResponse.json({ attachment, scan }); + const job = await client.transactionScans.start({ + body: { + imageBase64, + mimeType, + fileName: body.fileName + } + }); + return NextResponse.json({ attachment, job }); } catch (err) { const status = apiErrorStatus(err); if (status === 400) { diff --git a/apps/web/components/transaction-scan-capture.tsx b/apps/web/components/transaction-scan-capture.tsx index 240d495..7822bf0 100644 --- a/apps/web/components/transaction-scan-capture.tsx +++ b/apps/web/components/transaction-scan-capture.tsx @@ -1,12 +1,15 @@ 'use client'; +import { createXpenserClient } from '@xpenser/client'; import { type Category, type Currency, type Transaction, type TransactionScanDecisionBody, type TransactionScanDraft, + type TransactionScanJobResponse, TransactionScanLimits, + type TransactionScanProgressEvent, type TransactionScanResponse, type Vendor } from '@xpenser/contracts'; @@ -78,15 +81,23 @@ type TransactionType = Category['type']; const maxImageBytes = TransactionScanLimits.maxImageBytes; const uploadChunkBytes = TransactionScanLimits.uploadChunkBytes; -type ScanRouteResponse = +type ScanResultResponse = | { readonly error: string; readonly scan?: undefined } - | { readonly error?: undefined; readonly uploaded: true } | { readonly attachment: ScanAttachment; readonly error?: undefined; readonly scan: TransactionScanResponse; }; +type ScanUploadRouteResponse = + | { readonly error: string; readonly job?: undefined } + | { readonly error?: undefined; readonly uploaded: true } + | { + readonly attachment: ScanAttachment; + readonly error?: undefined; + readonly job: TransactionScanJobResponse; + }; + function blobBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -104,9 +115,63 @@ function blobBase64(blob: Blob): Promise { }); } -async function scanImageFile(file: File): Promise { +function browserApiBaseUrl(): string { + const configured = process.env.NEXT_PUBLIC_API_BASE_URL; + if (configured) { + return configured.replace(/\/$/, ''); + } + if ( + window.location.hostname === 'localhost' && + window.location.port === '3000' + ) { + return 'http://localhost:4000'; + } + return new URL('/external-api', window.location.href) + .toString() + .replace(/\/$/, ''); +} + +function scanError(event: TransactionScanProgressEvent): string { + return event.error ?? 'Could not scan the image. Try again.'; +} + +async function waitForScanJob( + job: TransactionScanJobResponse, + onProgress: (message: string) => void +): Promise { + const client = createXpenserClient({ + baseUrl: browserApiBaseUrl(), + retryOnTimeout: false + }); + const subscription = client.transactionScans.progress({ + query: { jobId: job.jobId, token: job.token }, + reconnect: { maxRetries: 3, backoffLimit: 5_000 } + }); + + try { + for await (const event of subscription) { + onProgress(event.message); + if (event.stage === 'failed') { + throw new Error(scanError(event)); + } + if (event.stage === 'complete' && event.scan) { + return event.scan; + } + } + } finally { + subscription.close(); + } + + throw new Error('Could not connect to scan progress. Try again.'); +} + +async function uploadAndScanImageFile( + file: File, + onProgress: (message: string) => void +): Promise { const uploadId = crypto.randomUUID(); const totalChunks = Math.ceil(file.size / uploadChunkBytes); + onProgress('Uploading image.'); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { const start = chunkIndex * uploadChunkBytes; @@ -127,7 +192,7 @@ async function scanImageFile(file: File): Promise { }); const result = (await response .json() - .catch(() => null)) as ScanRouteResponse | null; + .catch(() => null)) as ScanUploadRouteResponse | null; if (!response.ok) { return { @@ -141,9 +206,25 @@ async function scanImageFile(file: File): Promise { if (result?.error) { return result; } - if (result && 'scan' in result) { - return result; + if (result && 'attachment' in result && 'job' in result) { + onProgress('Connecting to scan progress.'); + try { + const scan = await waitForScanJob(result.job, onProgress); + return { attachment: result.attachment, scan }; + } catch (err) { + return { + error: + err instanceof Error + ? err.message + : 'Could not scan the image. Try again.' + }; + } } + onProgress( + `Uploading image (${Math.round( + ((chunkIndex + 1) / totalChunks) * 100 + )}%).` + ); } return { error: 'Could not scan the image. Try again.' }; @@ -241,6 +322,7 @@ function ScanUpload({ const [file, setFile] = useState(null); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [progressMessage, setProgressMessage] = useState(null); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -259,8 +341,12 @@ function ScanUpload({ setPending(true); setError(null); + setProgressMessage('Uploading image.'); try { - const result = await scanImageFile(file); + const result = await uploadAndScanImageFile( + file, + setProgressMessage + ); if (result.error) { setError(result.error); return; @@ -270,6 +356,7 @@ function ScanUpload({ return; } onScanned(result.scan, result.attachment); + setProgressMessage(null); } catch { setError('Could not scan the image. Try again.'); } finally { @@ -303,6 +390,14 @@ function ScanUpload({ {error ? ( {error} ) : null} + {pending && progressMessage ? ( +
+ {progressMessage} +
+ ) : null} - + ); @@ -1084,10 +1270,12 @@ function ScanWizard({ Note - setNote(event.target.value)} + rows={5} value={note} /> diff --git a/package-lock.json b/package-lock.json index 571a3b3..c623a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,8 @@ "@xpenser/contracts": "0.1.0", "@xpenser/timezone": "0.1.0", "knex": "3.1.0", - "pg": "8.13.3" + "pg": "8.13.3", + "sharp": "0.34.5" }, "devDependencies": { "@types/node": "22.15.3", diff --git a/packages/contracts/src/limits.ts b/packages/contracts/src/limits.ts index 13d9ec1..bb50e3d 100644 --- a/packages/contracts/src/limits.ts +++ b/packages/contracts/src/limits.ts @@ -16,7 +16,7 @@ export const FieldLimits = { telegramUserId: 64, telegramUsername: 64, timeZone: 64, - transactionNote: 500, + transactionNote: 2000, transactionSearch: 500, vendorDescription: 1000, vendorDomain: 255, diff --git a/packages/ui/src/components/primitives.test.tsx b/packages/ui/src/components/primitives.test.tsx index 7ab20c7..b336f49 100644 --- a/packages/ui/src/components/primitives.test.tsx +++ b/packages/ui/src/components/primitives.test.tsx @@ -32,6 +32,7 @@ import { TableHeader, TableRow } from './table.js'; +import { Textarea } from './textarea.js'; describe('ui primitives', () => { it('renders card regions with caller props and classes', () => { @@ -105,6 +106,18 @@ describe('ui primitives', () => { expect(spinner?.getAttribute('class')).toContain('animate-spin'); }); + it('renders textarea fields with caller props and classes', () => { + render( + + Note +