From aadffcb6c322f5f94e88d87d40926f4c289598ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:08:05 +0000 Subject: [PATCH] fix(payments): align classifyHaipMetadata comment with lookup-first Describe classification as post-lookup for unmatched intents, matching the deliberate legacy-compatible resolvePaymentForIntent contract. Rebuilt on main after #352; regenerated README counts (1635/229). Co-authored-by: telivity-otaip --- README.md | 8 +-- .../payment/stripe-financial-state.spec.ts | 10 +++ .../modules/payment/stripe-financial-state.ts | 22 +++++++ .../payment/stripe-webhook.controller.ts | 28 ++++++--- .../modules/payment/stripe-webhook.spec.ts | 61 +++++++++++++++++++ docs/test-stats.json | 6 +- 6 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/modules/payment/stripe-financial-state.spec.ts create mode 100644 apps/api/src/modules/payment/stripe-financial-state.ts diff --git a/README.md b/README.md index 52ef4fcb..7eb670b2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 1630 Tests Passing 12 AI Agents + 1635 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1630 passing tests across 228 files with passing tests) | Unit and integration tests | +| Testing | Vitest (1635 passing tests across 229 files with passing tests) | Unit and integration tests | | Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -643,7 +643,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# Passing-test count: 1630 test cases across 228 files (skipped excluded) +# Passing-test count: 1635 test cases across 229 files (skipped excluded) # API tests only pnpm --filter @telivityhaip/api test @@ -1191,7 +1191,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1630 passing, 228 files with passes; skipped excluded) +pnpm test # Run all tests (1635 passing, 229 files with passes; skipped excluded) pnpm lint # ESLint ``` diff --git a/apps/api/src/modules/payment/stripe-financial-state.spec.ts b/apps/api/src/modules/payment/stripe-financial-state.spec.ts new file mode 100644 index 00000000..c6d8d236 --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.spec.ts @@ -0,0 +1,10 @@ +import { classifyHaipMetadata } from './stripe-financial-state'; + +describe('Stripe financial metadata classification', () => { + it('classifies HAIP-owned vs external PaymentIntent metadata', () => { + expect(classifyHaipMetadata({})).toBe('external'); + expect(classifyHaipMetadata({ unrelated: 'value' })).toBe('external'); + expect(classifyHaipMetadata({ haip_payment_id: 'payment-1' })).toBe('owned-valid'); + expect(classifyHaipMetadata({ haip_payment_id: '' })).toBe('owned-malformed'); + }); +}); diff --git a/apps/api/src/modules/payment/stripe-financial-state.ts b/apps/api/src/modules/payment/stripe-financial-state.ts new file mode 100644 index 00000000..b95c2b9e --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.ts @@ -0,0 +1,22 @@ +export type HaipMetadataClassification = 'external' | 'owned-valid' | 'owned-malformed'; + +export function hasHaipFinancialMetadata( + metadata: Record | null | undefined, +): boolean { + return Object.keys(metadata ?? {}).some((key) => key.startsWith('haip_')); +} + +/** + * Classifies PaymentIntent metadata for intents that remain unmatched after the + * legacy-compatible gateway transaction lookup. Separates Stripe-account noise + * from HAIP-owned traffic; event-specific correlation parsers remain responsible + * for exact required fields. + */ +export function classifyHaipMetadata( + metadata: Record | null | undefined, +): HaipMetadataClassification { + if (!hasHaipFinancialMetadata(metadata)) return 'external'; + return Object.entries(metadata ?? {}).some(([key, value]) => key.startsWith('haip_') && !value) + ? 'owned-malformed' + : 'owned-valid'; +} diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 9aba09a3..9d8b2aec 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -17,6 +17,7 @@ import { DRIZZLE } from '../../database/database.module'; import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; import { sumRefundChildren } from './payment-ledger'; +import { classifyHaipMetadata } from './stripe-financial-state'; import Stripe from 'stripe'; /** @@ -126,11 +127,8 @@ export class StripeWebhookController { } private async handlePaymentIntentSucceeded(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); - if (!payment) { - this.logger.warn(`No payment found for PaymentIntent ${pi.id}`); - return; - } + const payment = await this.resolvePaymentForIntent(pi); + if (!payment) return; if (payment.status === 'captured') { this.logger.debug(`Payment ${payment.id} already captured, skipping`); @@ -157,7 +155,7 @@ export class StripeWebhookController { } private async handlePaymentIntentFailed(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); + const payment = await this.resolvePaymentForIntent(pi); if (!payment) return; if (payment.status === 'failed') return; @@ -184,7 +182,7 @@ export class StripeWebhookController { } private async handlePaymentIntentCanceled(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); + const payment = await this.resolvePaymentForIntent(pi); if (!payment) return; if (payment.status === 'voided') return; @@ -305,6 +303,22 @@ export class StripeWebhookController { ); } + /** + * Correlate a PaymentIntent to a HAIP payment row. Lookup by gateway id first + * so legacy instant-booking intents without haip_* metadata still reconcile; + * only unmatched intents with no HAIP metadata are treated as external noise. + */ + private async resolvePaymentForIntent(pi: Stripe.PaymentIntent) { + const payment = await this.findPaymentByGatewayTransactionId(pi.id); + if (payment) return payment; + if (classifyHaipMetadata(pi.metadata) === 'external') { + this.logger.debug(`Ignoring unrelated Stripe PaymentIntent ${pi.id}`); + return null; + } + this.logger.warn(`No payment found for PaymentIntent ${pi.id}`); + return null; + } + private async findPaymentByGatewayTransactionId(transactionId: string) { const [payment] = await this.db .select() diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 9e3d92de..c425a404 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -279,5 +279,66 @@ describe('StripeWebhookController', () => { expect(emptyDb.update).not.toHaveBeenCalled(); }); + + it.each([ + ['handlePaymentIntentSucceeded', { id: 'pi_external_success', metadata: {} }], + ['handlePaymentIntentFailed', { id: 'pi_external_failure', metadata: {} }], + ['handlePaymentIntentCanceled', { id: 'pi_external_cancel', metadata: {} }], + ])('ignores unrelated %s after legacy lookup misses', async (handlerName, paymentIntent) => { + const emptyDb = createMockDb([]); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: emptyDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + const ctrl = module.get(StripeWebhookController); + + await (ctrl as any)[handlerName](paymentIntent); + + expect(emptyDb.select).toHaveBeenCalled(); + expect(emptyDb.update).not.toHaveBeenCalled(); + expect(mockWebhookService.emit).not.toHaveBeenCalled(); + expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); + }); + + it('acknowledges charge.refunded when parent payment is gone after lookup', async () => { + const capturedDb = createRefundWebhookDb({ ...mockPayment, status: 'captured', method: 'credit_card' }); + capturedDb.transaction.mockImplementation(async (callback: (tx: any) => Promise) => { + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockResolvedValue([]), + }), + }), + }), + }; + return callback(tx); + }); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + const ctrl = module.get(StripeWebhookController); + + await expect((ctrl as any).handleChargeRefunded({ + id: 'ch_deleted_parent', + payment_intent: 'pi_test_123', + amount_refunded: 2500, + })).resolves.toBeUndefined(); + + expect(mockWebhookService.emit).not.toHaveBeenCalled(); + expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); + expect(capturedDb.transaction).toHaveBeenCalled(); + }); }); }); diff --git a/docs/test-stats.json b/docs/test-stats.json index 9235a5bf..3f772b35 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,7 +1,7 @@ { - "tests": 1630, - "files": 228, + "tests": 1635, + "files": 229, "scope": "all workspace packages with a test script", "semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded", - "updatedAt": "2026-08-27T17:54:52.422Z" + "updatedAt": "2026-08-27T18:07:12.315Z" }