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 @@
-
+
@@ -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"
}