diff --git a/backend/node_modules/.package-lock.json b/backend/node_modules/.package-lock.json index 8e901e26..80acbd98 100644 --- a/backend/node_modules/.package-lock.json +++ b/backend/node_modules/.package-lock.json @@ -709,6 +709,46 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@elastic/elasticsearch": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.4.2.tgz", + "integrity": "sha512-H9myMlLUeotkZhZ4ppinoMGDFxmW3lY8/s+4TIk1vFHyCvWU1Ej4T7azX5buCzemyFApgN0ywnEuvOtpel2VZg==", + "license": "Apache-2.0", + "dependencies": { + "@elastic/transport": "^9.3.5", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "apache-arrow": "18.x - 21.x" + }, + "peerDependenciesMeta": { + "apache-arrow": { + "optional": true + } + } + }, + "node_modules/@elastic/transport": { + "version": "9.3.7", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-9.3.7.tgz", + "integrity": "sha512-L38Ax21uF2OPUmCRWycZ/dZdMYf7gMrtClcxvVrqJVFmn8ET2M++GYmFGJpLqOHS1beATxOXLWe7y2ijSQz/ng==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "1.x", + "@opentelemetry/core": "2.x", + "debug": "^4.4.1", + "hpagent": "^1.2.0", + "ms": "^2.1.3", + "secure-json-parse": "^4.0.0", + "tslib": "^2.8.1", + "undici": "^7.19.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2520,6 +2560,39 @@ "npm": ">=5.10.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -6006,6 +6079,15 @@ "node": ">= 0.4" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8795,6 +8877,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -9957,6 +10055,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/backend/package-lock.json b/backend/package-lock.json index 59fdb685..e68555e1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@elastic/elasticsearch": "^9.4.2", "@google/genai": "^2.7.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -20,6 +21,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "csv-parse": "^6.2.1", "mongoose": "^9.6.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -37,6 +39,7 @@ "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", @@ -762,6 +765,46 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@elastic/elasticsearch": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.4.2.tgz", + "integrity": "sha512-H9myMlLUeotkZhZ4ppinoMGDFxmW3lY8/s+4TIk1vFHyCvWU1Ej4T7azX5buCzemyFApgN0ywnEuvOtpel2VZg==", + "license": "Apache-2.0", + "dependencies": { + "@elastic/transport": "^9.3.5", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "apache-arrow": "18.x - 21.x" + }, + "peerDependenciesMeta": { + "apache-arrow": { + "optional": true + } + } + }, + "node_modules/@elastic/transport": { + "version": "9.3.7", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-9.3.7.tgz", + "integrity": "sha512-L38Ax21uF2OPUmCRWycZ/dZdMYf7gMrtClcxvVrqJVFmn8ET2M++GYmFGJpLqOHS1beATxOXLWe7y2ijSQz/ng==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "1.x", + "@opentelemetry/core": "2.x", + "debug": "^4.4.1", + "hpagent": "^1.2.0", + "ms": "^2.1.3", + "secure-json-parse": "^4.0.0", + "tslib": "^2.8.1", + "undici": "^7.19.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2626,6 +2669,39 @@ "npm": ">=5.10.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3021,6 +3097,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -5072,6 +5158,12 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6423,6 +6515,15 @@ "node": ">= 0.4" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9212,6 +9313,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -10374,6 +10491,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/backend/package.json b/backend/package.json index 07501c19..884acaea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@elastic/elasticsearch": "^9.4.2", "@google/genai": "^2.7.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -31,6 +32,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "csv-parse": "^6.2.1", "mongoose": "^9.6.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -48,6 +50,7 @@ "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bccc394a..d86779bc 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { GeminiModule } from './gemini/gemini.module'; import { StripeModule } from './stripe/stripe.module'; import { AuthModule } from './auth/auth.module'; import { DevStudioModule } from './dev-studio/dev-studio.module'; +import { ReconStudioModule } from './recon-studio/recon-studio.module'; import { User, UserSchema } from './schemas/user.schema'; import { ApiConnection, ApiConnectionSchema } from './schemas/api-connection.schema'; import { TestRun, TestRunSchema } from './schemas/test-run.schema'; @@ -29,6 +30,7 @@ import { ReconciliationReport, ReconciliationReportSchema } from './schemas/reco StripeModule, AuthModule, DevStudioModule, + ReconStudioModule, MongooseModule.forFeature([ { name: User.name, schema: UserSchema }, { name: ApiConnection.name, schema: ApiConnectionSchema }, diff --git a/backend/src/dev-studio/dev-studio.controller.ts b/backend/src/dev-studio/dev-studio.controller.ts index 430f3382..dd5339ae 100644 --- a/backend/src/dev-studio/dev-studio.controller.ts +++ b/backend/src/dev-studio/dev-studio.controller.ts @@ -1,13 +1,25 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { Controller, Post, Get, Body, Query } from '@nestjs/common'; import { DevStudioService } from './dev-studio.service'; +import { ElasticService } from '../elastic/elastic.service'; import type { RunTestParams } from './dev-studio.service'; @Controller('dev-studio') export class DevStudioController { - constructor(private readonly devStudioService: DevStudioService) {} + constructor( + private readonly devStudioService: DevStudioService, + private readonly elasticService: ElasticService, + ) {} @Post('run') run(@Body() body: RunTestParams) { return this.devStudioService.runTest(body); } + + @Get('history') + history( + @Query('userId') userId: string, + @Query('q') query?: string, + ) { + return this.elasticService.searchTestRuns(userId, query); + } } diff --git a/backend/src/dev-studio/dev-studio.module.ts b/backend/src/dev-studio/dev-studio.module.ts index 6864ad0b..4a8361c5 100644 --- a/backend/src/dev-studio/dev-studio.module.ts +++ b/backend/src/dev-studio/dev-studio.module.ts @@ -3,9 +3,11 @@ import { DevStudioService } from './dev-studio.service'; import { DevStudioController } from './dev-studio.controller'; import { StripeModule } from '../stripe/stripe.module'; import { WebhookModule } from '../webhook/webhook.module'; +import { ElasticModule } from '../elastic/elastic.module'; +import { GeminiModule } from '../gemini/gemini.module'; @Module({ - imports: [StripeModule, WebhookModule], + imports: [StripeModule, WebhookModule, ElasticModule, GeminiModule], controllers: [DevStudioController], providers: [DevStudioService], }) diff --git a/backend/src/dev-studio/dev-studio.service.ts b/backend/src/dev-studio/dev-studio.service.ts index b9b8f2b3..6ead0b6b 100644 --- a/backend/src/dev-studio/dev-studio.service.ts +++ b/backend/src/dev-studio/dev-studio.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { StripeService } from '../stripe/stripe.service'; import { WebhookService, WebhookResult } from '../webhook/webhook.service'; +import { ElasticService } from '../elastic/elastic.service'; +import { GeminiService } from '../gemini/gemini.service'; import type { PaymentSimulationResult } from '../stripe/stripe.service'; export interface RunTestParams { testApiKey: string; scenario: string; webhookEndpointUrl: string; + userId?: string; amount?: number; currency?: string; } @@ -14,6 +17,7 @@ export interface RunTestParams { export interface TestRunResult { payment: PaymentSimulationResult; webhook: WebhookResult; + explanation: string; } @Injectable() @@ -21,12 +25,13 @@ export class DevStudioService { constructor( private readonly stripeService: StripeService, private readonly webhookService: WebhookService, + private readonly elasticService: ElasticService, + private readonly geminiService: GeminiService, ) {} async runTest(params: RunTestParams): Promise { - const { testApiKey, scenario, webhookEndpointUrl, amount, currency } = params; + const { testApiKey, scenario, webhookEndpointUrl, userId, amount, currency } = params; - // Step 1: simulate the payment const payment = await this.stripeService.simulatePayment({ testApiKey, scenario, @@ -34,19 +39,52 @@ export class DevStudioService { currency, }); - // Step 2: build a Stripe-shaped webhook event from the result const eventType = payment.status === 'succeeded' ? 'charge.succeeded' : 'charge.failed'; const webhookPayload = this.webhookService.buildStripeEvent( eventType, (payment.raw ?? {}) as Record, ); - // Step 3: fire the webhook to the developer's endpoint and capture the response const webhook = await this.webhookService.fire({ endpointUrl: webhookEndpointUrl, payload: webhookPayload, }); - return { payment, webhook }; + // Gemini explains the result in plain English + const explanation = await this.geminiService.explainTestRun({ + scenario, + provider: 'stripe', + testCard: payment.selectedCard.token, + paymentResult: { + status: payment.status, + chargeId: payment.chargeId, + amount: payment.amount, + currency: payment.currency, + errorCode: payment.errorCode, + errorMessage: payment.errorMessage, + }, + webhookStatusCode: webhook.statusCode ?? undefined, + webhookBody: webhook.body ? (webhook.body as Record) : undefined, + webhookError: webhook.error ?? undefined, + }); + + // Save to Elastic — non-blocking, errors are swallowed inside indexTestRun + await this.elasticService.indexTestRun({ + userId: userId ?? 'anonymous', + scenario, + provider: 'stripe', + selectedCard: payment.selectedCard.token, + paymentStatus: payment.status, + chargeId: payment.chargeId ?? '', + webhookFired: webhook.fired, + webhookStatusCode: webhook.statusCode ?? null, + webhookDurationMs: webhook.durationMs, + webhookError: webhook.error ?? null, + errorCode: payment.errorCode ?? (payment.outcome?.reason as string | undefined), + errorMessage: payment.errorMessage ?? (payment.outcome?.sellerMessage as string | undefined), + createdAt: new Date().toISOString(), + }); + + return { payment, webhook, explanation }; } } diff --git a/backend/src/elastic/elastic.module.ts b/backend/src/elastic/elastic.module.ts new file mode 100644 index 00000000..37f3d676 --- /dev/null +++ b/backend/src/elastic/elastic.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ElasticService } from './elastic.service'; + +@Module({ + providers: [ElasticService], + exports: [ElasticService], +}) +export class ElasticModule {} diff --git a/backend/src/elastic/elastic.service.ts b/backend/src/elastic/elastic.service.ts new file mode 100644 index 00000000..0aa12c4a --- /dev/null +++ b/backend/src/elastic/elastic.service.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from '@elastic/elasticsearch'; + +const INDEX = 'paylens-test-runs'; + +export interface TestRunLog { + userId: string; + scenario: string; + provider: string; + selectedCard: string; + paymentStatus: string; + chargeId: string; + webhookFired: boolean; + webhookStatusCode: number | null; + webhookDurationMs: number; + webhookError: string | null; + errorCode?: string; + errorMessage?: string; + createdAt: string; +} + +@Injectable() +export class ElasticService implements OnModuleInit { + private readonly logger = new Logger(ElasticService.name); + private client: Client; + + private enabled = false; + + constructor(private readonly config: ConfigService) { + const url = config.get('ELASTIC_URL'); + if (!url) { + this.logger.warn('ELASTIC_URL not set — Elasticsearch indexing disabled'); + return; + } + + const apiKey = config.get('ELASTIC_API_KEY'); + this.client = new Client({ + node: url, + ...(apiKey ? { auth: { apiKey } } : {}), + }); + this.enabled = true; + } + + async onModuleInit() { + if (!this.enabled) return; + try { + await this.ensureIndex(); + this.logger.log('Elasticsearch connected'); + } catch (err) { + this.logger.warn(`Elasticsearch not reachable: ${(err as Error).message}`); + } + } + + async indexTestRun(log: TestRunLog): Promise { + if (!this.enabled) return; + try { + await this.client.index({ index: INDEX, document: log }); + } catch (err) { + this.logger.warn(`Failed to index test run: ${(err as Error).message}`); + } + } + + async searchTestRuns(userId: string, query?: string): Promise { + if (!this.enabled) return []; + + const must: object[] = [{ term: { userId } }]; + + if (query) { + must.push({ + multi_match: { + query, + fields: ['scenario', 'provider', 'paymentStatus', 'errorMessage'], + }, + }); + } + + const res = await this.client.search({ + index: INDEX, + query: { bool: { must } }, + sort: [{ createdAt: { order: 'desc' } }], + size: 50, + }); + + return res.hits.hits.map((h) => h._source as TestRunLog); + } + + private async ensureIndex() { + if (!this.enabled) return; + const exists = await this.client.indices.exists({ index: INDEX }); + if (exists) return; + + await this.client.indices.create({ + index: INDEX, + mappings: { + properties: { + userId: { type: 'keyword' }, + scenario: { type: 'text' }, + provider: { type: 'keyword' }, + selectedCard: { type: 'keyword' }, + paymentStatus: { type: 'keyword' }, + chargeId: { type: 'keyword' }, + webhookFired: { type: 'boolean' }, + webhookStatusCode: { type: 'integer' }, + webhookDurationMs: { type: 'integer' }, + webhookError: { type: 'text' }, + errorCode: { type: 'keyword' }, + errorMessage: { type: 'text' }, + createdAt: { type: 'date' }, + }, + }, + }); + + this.logger.log(`Created index: ${INDEX}`); + } +} diff --git a/backend/src/recon-studio/recon-studio.controller.ts b/backend/src/recon-studio/recon-studio.controller.ts new file mode 100644 index 00000000..d60fdaf4 --- /dev/null +++ b/backend/src/recon-studio/recon-studio.controller.ts @@ -0,0 +1,33 @@ +import { + Controller, + Post, + Get, + Query, + UploadedFile, + UseInterceptors, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ReconStudioService } from './recon-studio.service'; + +@Controller('recon-studio') +export class ReconStudioController { + constructor(private readonly reconStudioService: ReconStudioService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadInvoices( + @UploadedFile() file: Express.Multer.File, + @Query('userId') userId: string, + ) { + if (!file) throw new BadRequestException('No file uploaded'); + if (!userId) throw new BadRequestException('userId query param required'); + return this.reconStudioService.uploadInvoicesCsv(userId, file.buffer); + } + + @Get('invoices') + getInvoices(@Query('userId') userId: string) { + if (!userId) throw new BadRequestException('userId query param required'); + return this.reconStudioService.getInvoices(userId); + } +} diff --git a/backend/src/recon-studio/recon-studio.module.ts b/backend/src/recon-studio/recon-studio.module.ts new file mode 100644 index 00000000..cad65065 --- /dev/null +++ b/backend/src/recon-studio/recon-studio.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { ReconStudioService } from './recon-studio.service'; +import { ReconStudioController } from './recon-studio.controller'; +import { Invoice, InvoiceSchema } from '../schemas/invoice.schema'; + +@Module({ + imports: [MongooseModule.forFeature([{ name: Invoice.name, schema: InvoiceSchema }])], + controllers: [ReconStudioController], + providers: [ReconStudioService], + exports: [ReconStudioService], +}) +export class ReconStudioModule {} diff --git a/backend/src/recon-studio/recon-studio.service.ts b/backend/src/recon-studio/recon-studio.service.ts new file mode 100644 index 00000000..1a6940c2 --- /dev/null +++ b/backend/src/recon-studio/recon-studio.service.ts @@ -0,0 +1,103 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { parse } from 'csv-parse/sync'; +import { Invoice, InvoiceDocument } from '../schemas/invoice.schema'; + +export interface ParsedInvoiceRow { + invoiceNumber: string; + customerName: string; + amount: number; + currency: string; + dueDate?: string; +} + +export interface UploadResult { + inserted: number; + skipped: number; + errors: string[]; +} + +@Injectable() +export class ReconStudioService { + constructor( + @InjectModel(Invoice.name) private readonly invoiceModel: Model, + ) {} + + async uploadInvoicesCsv(userId: string, fileBuffer: Buffer): Promise { + let rows: Record[]; + + try { + rows = parse(fileBuffer, { + columns: true, // first row is header + skip_empty_lines: true, + trim: true, + }) as Record[]; + } catch { + throw new BadRequestException('Could not parse CSV — check the file format'); + } + + const result: UploadResult = { inserted: 0, skipped: 0, errors: [] }; + + for (const row of rows) { + const parsed = this.parseRow(row); + if (!parsed) { + result.errors.push(`Skipped row — missing required fields: ${JSON.stringify(row)}`); + result.skipped++; + continue; + } + + try { + await this.invoiceModel.create({ + userId: new Types.ObjectId(userId), + invoiceNumber: parsed.invoiceNumber, + customerName: parsed.customerName, + amount: parsed.amount, + currency: parsed.currency, + dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined, + status: 'unpaid', + rawData: row, + }); + result.inserted++; + } catch (err: unknown) { + const msg = (err as Error).message ?? ''; + if (msg.includes('duplicate key') || msg.includes('E11000')) { + result.skipped++; + } else { + result.errors.push(`Row ${parsed.invoiceNumber}: ${msg}`); + result.skipped++; + } + } + } + + return result; + } + + async getInvoices(userId: string) { + return this.invoiceModel + .find({ userId: new Types.ObjectId(userId) }) + .sort({ createdAt: -1 }) + .lean(); + } + + private parseRow(row: Record): ParsedInvoiceRow | null { + // Accept common column name variations + const invoiceNumber = + row['invoiceNumber'] ?? row['invoice_number'] ?? row['InvoiceNumber'] ?? row['Invoice Number']; + const customerName = + row['customerName'] ?? row['customer_name'] ?? row['CustomerName'] ?? row['Customer Name']; + const amountRaw = + row['amount'] ?? row['Amount'] ?? row['total'] ?? row['Total']; + const currency = + row['currency'] ?? row['Currency'] ?? 'USD'; + const dueDate = + row['dueDate'] ?? row['due_date'] ?? row['DueDate'] ?? row['Due Date']; + + if (!invoiceNumber || !customerName || !amountRaw) return null; + + const amount = parseFloat(amountRaw.replace(/[^0-9.]/g, '')); + if (isNaN(amount)) return null; + + return { invoiceNumber, customerName, amount, currency, dueDate }; + } +}