Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1630%20passing-brightgreen" alt="1630 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1635%20passing-brightgreen" alt="1635 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/modules/payment/stripe-financial-state.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 22 additions & 0 deletions apps/api/src/modules/payment/stripe-financial-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type HaipMetadataClassification = 'external' | 'owned-valid' | 'owned-malformed';

export function hasHaipFinancialMetadata(
metadata: Record<string, string> | 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<string, string> | null | undefined,
): HaipMetadataClassification {
if (!hasHaipFinancialMetadata(metadata)) return 'external';
return Object.entries(metadata ?? {}).some(([key, value]) => key.startsWith('haip_') && !value)
? 'owned-malformed'
: 'owned-valid';
}
28 changes: 21 additions & 7 deletions apps/api/src/modules/payment/stripe-webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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`);
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
61 changes: 61 additions & 0 deletions apps/api/src/modules/payment/stripe-webhook.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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<unknown>) => {
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>(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();
});
});
});
6 changes: 3 additions & 3 deletions docs/test-stats.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading