From 19904577ae6cc6eae14aa1e11481abefb6b908a2 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:48:10 +0200 Subject: [PATCH] Fix import of activities with Ghostfolio AI --- .../src/app/endpoints/ai/ai.schemas.spec.ts | 12 +---- apps/api/src/app/endpoints/ai/ai.schemas.ts | 6 +-- .../src/app/endpoints/ai/ai.service.spec.ts | 4 +- apps/api/src/app/endpoints/ai/ai.service.ts | 7 ++- apps/api/src/app/user/user.service.ts | 1 + .../import-activities-dialog.component.ts | 50 +++++++++++++++++-- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/apps/api/src/app/endpoints/ai/ai.schemas.spec.ts b/apps/api/src/app/endpoints/ai/ai.schemas.spec.ts index fc01e0c55f..613043356c 100644 --- a/apps/api/src/app/endpoints/ai/ai.schemas.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.schemas.spec.ts @@ -34,16 +34,7 @@ describe('ACTIVITY_ROWS_OUTPUT', () => { expect(ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [row] }).success).toBe(false); }); - it('Refuses a negative fee, quantity or unit price', () => { - for (const row of [{ fee: -1 }, { quantity: -1 }, { unitPrice: -1 }]) { - expect( - ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [createActivityRow(row)] }) - .success - ).toBe(false); - } - }); - - it('Accepts a currency, a date, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => { + it('Accepts a currency, a date, a number, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => { expect( ACTIVITY_ROWS_OUTPUT.safeParse({ rows: [ @@ -52,6 +43,7 @@ describe('ACTIVITY_ROWS_OUTPUT', () => { date: '15.01.2026', name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) }), + createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }), ...Array.from({ length: MCP_MAX_ACTIVITIES }, () => { return createActivityRow(); }) diff --git a/apps/api/src/app/endpoints/ai/ai.schemas.ts b/apps/api/src/app/endpoints/ai/ai.schemas.ts index 24a20a4a12..66f1e92bf1 100644 --- a/apps/api/src/app/endpoints/ai/ai.schemas.ts +++ b/apps/api/src/app/endpoints/ai/ai.schemas.ts @@ -26,17 +26,17 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({ date: z .string() .describe('The date of the activity, as an ISO 8601 date'), - fee: z.number().min(0).describe('The fee of the activity'), + fee: z.number().describe('The fee of the activity'), isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), name: PRINTED_TEXT.describe( 'The name of the asset, or the description of a fee, an interest or a liability, as printed' ), - quantity: z.number().min(0).describe('The quantity of the activity'), + quantity: z.number().describe('The quantity of the activity'), symbol: PRINTED_TEXT.describe( 'The ticker symbol of the asset, as printed' ), type: z.enum(ActivityType).describe('The type of the activity'), - unitPrice: z.number().min(0).describe('The unit price of the activity') + unitPrice: z.number().describe('The unit price of the activity') }) ) .describe(`The activities of the file, at most ${MCP_MAX_ACTIVITIES}`) diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index a7f99c068e..5beb9a1f10 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -158,7 +158,7 @@ describe('AiService', () => { expect(chat).toHaveBeenCalledWith('vision-model', { plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], - provider: { zdr: true }, + provider: { require_parameters: true, zdr: true }, usage: { include: true } }); }); @@ -646,7 +646,7 @@ describe('AiService', () => { }); expect(chat).toHaveBeenCalledWith('agent-model', { - provider: { zdr: true }, + provider: { require_parameters: true, zdr: true }, usage: { include: true } }); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index dccaa5baf4..dbbaf51292 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -305,7 +305,7 @@ export class AiService { modelPropertyKey: PROPERTY_OPENROUTER_MODEL_VISION, settings: { plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], - provider: { zdr: true }, + provider: { require_parameters: true, zdr: true }, usage: { include: true } } }), @@ -479,7 +479,10 @@ export class AiService { ].join('\n'), model: await this.getOpenRouterChatModel({ modelPropertyKey: PROPERTY_OPENROUTER_MODEL_AGENT, - settings: { provider: { zdr: true }, usage: { include: true } } + settings: { + provider: { require_parameters: true, zdr: true }, + usage: { include: true } + } }), output: Output.object({ schema: ROW_MATCHES_OUTPUT }), prepareStep: ({ stepNumber }) => { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4e77faeefb..4c3796dc8b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -529,6 +529,7 @@ export class UserService { return !( permission.startsWith('create') || permission.startsWith('delete') || + permission.startsWith('import') || permission.startsWith('update') ); }); diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index bcef62f67d..acbf5be3b1 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -351,6 +351,26 @@ export class GfImportActivitiesDialogComponent { }); } + /** + * Gives the activities of a CSV file in the format of Ghostfolio, which is + * imported without Ghostfolio AI, or undefined for any other CSV file + */ + private async getActivitiesOfCsvInGhostfolioFormat(file: File) { + this.snackBar.open('⏳ ' + $localize`Validating data...`); + + try { + return await this.importActivitiesService.importCsv({ + fileContent: await file.text(), + isDryRun: true, + userAccounts: this.data.user.accounts + }); + } catch { + return undefined; + } finally { + this.snackBar.dismiss(); + } + } + /** * Gives the error message of a file which the server rejects, as a rejected * file counts towards the daily limit of Ghostfolio AI @@ -380,6 +400,11 @@ export class GfImportActivitiesDialogComponent { private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) { const fileExtension = file.name.split('.').pop()?.toLowerCase(); + this.details = []; + this.errorMessages = []; + this.isMaxActivitiesOfGhostfolioAiReached = false; + this.rowsWithoutMatch = []; + if ( this.isGhostfolioAiEnabled && GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.includes( @@ -498,8 +523,9 @@ export class GfImportActivitiesDialogComponent { } /** - * Reads the file with Ghostfolio AI. The request is canceled if the dialog - * is closed, which stops the models on the server. + * Reads the file with Ghostfolio AI, except a CSV file in the format of + * Ghostfolio. The request is canceled if the dialog is closed, which stops + * the models on the server. */ private async handleFileWithGhostfolioAi({ file, @@ -513,10 +539,28 @@ export class GfImportActivitiesDialogComponent { this.accounts = []; this.activities = []; this.assetProfiles = []; - this.isMaxActivitiesOfGhostfolioAiReached = false; this.platforms = []; this.tags = []; + if (fileExtension === 'csv') { + const csvInGhostfolioFormat = + await this.getActivitiesOfCsvInGhostfolioFormat(file); + + if (csvInGhostfolioFormat) { + const { activities, assetProfiles } = csvInGhostfolioFormat; + + this.activities = activities; + this.assetProfiles = assetProfiles; + this.dataSource = new MatTableDataSource(activities.reverse()); + this.pageIndex = 0; + this.totalItems = activities.length; + + this.showSelectActivitiesStep(stepper); + + return; + } + } + const errorMessage = await this.getErrorMessageOfFileForGhostfolioAi({ file, fileExtension