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
5 changes: 3 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ MAILTRAP_USER=your_mailtrap_user
MAILTRAP_PASS=your_mailtrap_pass

# Company and frontend information
# COMPANY_NAME and SUPPORT_EMAIL are read by the reconciliation alert email;
# FRONTEND_URL is the separate Socket.IO gateway origin in payments.gateway.ts.
# COMPANY_NAME and SUPPORT_EMAIL are read by the reconciliation alert email.
# The Socket.IO gateway shares the CORS_ORIGINS allowlist above (issue #1943);
# FRONTEND_URL is retained for compatibility but no longer gates sockets.
COMPANY_NAME=ManageHub
SUPPORT_EMAIL=support@yourdomain.com
FRONTEND_URL=http://localhost:3000
Expand Down
36 changes: 36 additions & 0 deletions backend/src/common/metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,42 @@ export class MetricsService {
this.setGauge('managehub_manual_review_queue_depth', {}, depth);
}

/**
* Gauge for the number of ledger/provider-state mismatches a single
* reconciliation run detected (issue #1942). Reset on every run so a
* scrape always reflects the most recent pass; the companion counter
* below accumulates the running total for rate-based alerting.
*/
setReconciliationMismatchCount(count: number): void {
this.setGauge('managehub_reconciliation_mismatch_count', {}, count);
}

/** Running total of mismatches detected across reconciliation runs (issue #1942). */
recordReconciliationMismatches(count: number): void {
this.incrementCounter(
'managehub_reconciliation_mismatches_total',
{},
count,
);
}

/**
* Counts a payment state transition that the state machine refused to
* apply (issue #1941), labelled by the states and the origin of the
* attempt, so an incident can be reconstructed from `/metrics` alone.
*/
recordRejectedPaymentTransition(
from: string,
to: string,
source: string,
): void {
this.incrementCounter('managehub_payment_rejected_transitions_total', {
from,
to,
source,
});
}

recordSettlementPayoutAttempt(
result: 'submitted' | 'confirmed' | 'failed',
): void {
Expand Down
15 changes: 0 additions & 15 deletions backend/src/common/reconciliation-mismatch-metric.util.ts

This file was deleted.

76 changes: 76 additions & 0 deletions backend/src/common/ws-cors.io-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { WsCorsIoAdapter } from './ws-cors.io-adapter';

function makeConfig(values: Record<string, string | undefined>) {
return {
get: jest.fn((key: string) => values[key]),
};
}

describe('WsCorsIoAdapter (issue #1943)', () => {
afterEach(() => {
jest.restoreAllMocks();
});

function buildCors(values: Record<string, string | undefined>) {
const config = makeConfig(values);
const adapter = new WsCorsIoAdapter({} as any, config as any);

// Capture the options the adapter hands to the underlying socket.io
// server instead of opening a real port.
const superCreate = jest
.spyOn(IoAdapter.prototype, 'createIOServer')
.mockReturnValue({} as any);

adapter.createIOServer(0, { namespace: '/payments' } as any);
const passedOptions = superCreate.mock.calls[0][1] as any;
return passedOptions.cors as {
credentials: boolean;
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => void;
};
}

it('allows an origin present in CORS_ORIGINS and rejects one outside it', () => {
const cors = buildCors({
CORS_ORIGINS: 'https://app.example.com,https://admin.example.com',
NODE_ENV: 'production',
});

expect(cors.credentials).toBe(true);

const allowed = jest.fn();
cors.origin('https://admin.example.com', allowed);
expect(allowed).toHaveBeenCalledWith(null, true);

const rejected = jest.fn();
cors.origin('https://evil.example.com', rejected);
expect(rejected.mock.calls[0][0]).toBeInstanceOf(Error);
expect(rejected.mock.calls[0][1]).toBe(false);
});

it('reads the allowlist at runtime, so .env-only configuration works', () => {
const cors = buildCors({
CORS_ORIGINS: 'https://from-dotenv.example.com',
NODE_ENV: 'production',
});

const allowed = jest.fn();
cors.origin('https://from-dotenv.example.com', allowed);
expect(allowed).toHaveBeenCalledWith(null, true);
});

it('falls back to the development defaults when NODE_ENV is development', () => {
const cors = buildCors({ NODE_ENV: 'development' });

const allowed = jest.fn();
cors.origin('http://localhost:3000', allowed);
expect(allowed).toHaveBeenCalledWith(null, true);

const rejected = jest.fn();
cors.origin('https://evil.example.com', rejected);
expect(rejected.mock.calls[0][1]).toBe(false);
});
});
53 changes: 53 additions & 0 deletions backend/src/common/ws-cors.io-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IoAdapter } from '@nestjs/platform-socket.io';
import type { ServerOptions } from 'socket.io';
import { isOriginAllowed, resolveAllowedOrigins } from './cors';

/**
* Resolves the Socket.IO allowlist at runtime instead of at import time
* (issue #1943).
*
* `@WebSocketGateway({ cors: { origin: process.env.FRONTEND_URL ... } })` is
* evaluated when the gateway file is imported — before `ConfigModule.forRoot`
* has loaded `.env` — so an `.env`-only `FRONTEND_URL` was silently ignored
* and a single hard-coded origin was used. This adapter reads the same
* `CORS_ORIGINS` allowlist the HTTP API uses (via `resolveAllowedOrigins` /
* `isOriginAllowed`), so both surfaces share one policy and multiple origins
* are supported. It is installed during application bootstrap, after config
* has loaded and before gateways are discovered.
*/
export class WsCorsIoAdapter extends IoAdapter {
private readonly logger = new Logger(WsCorsIoAdapter.name);
private readonly allowedOrigins: readonly string[];

constructor(app: INestApplicationContext, config: ConfigService) {
super(app);
this.allowedOrigins = resolveAllowedOrigins({
CORS_ORIGINS: config.get<string>('CORS_ORIGINS'),
NODE_ENV: config.get<string>('NODE_ENV'),
});
}

createIOServer(port: number, options?: ServerOptions) {
return super.createIOServer(port, {
...options,
cors: {
credentials: true,
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
if (isOriginAllowed(origin, this.allowedOrigins)) {
callback(null, true);
return;
}

const rejection = `WebSocket CORS origin "${origin}" is not allowlisted`;
this.logger.warn(rejection);
callback(new Error(rejection), false);
},
},
});
}
}
9 changes: 9 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/http-exception.filter';
import { isOriginAllowed, resolveAllowedOrigins } from './common/cors';
import { WsCorsIoAdapter } from './common/ws-cors.io-adapter';
import {
GracefulShutdownServer,
installGracefulShutdown,
Expand Down Expand Up @@ -46,6 +48,13 @@ async function bootstrap() {
app.use((req, res, next) => requestLogger.use(req, res, next));
}

// The websocket gateway must share the HTTP API's CORS_ORIGINS allowlist
// (issue #1943). The IoAdapter is installed here, after ConfigModule has
// loaded `.env` but before app.init() discovers gateways — setting it in
// an onModuleInit hook would be too late because Nest connects gateways in
// registerWsModule(), which runs before lifecycle hooks.
app.useWebSocketAdapter(new WsCorsIoAdapter(app, app.get(ConfigService)));

// Behind a reverse proxy/load balancer, TRUST_PROXY makes Express take the
// client IP from X-Forwarded-For so the shared (Redis) rate limiter keys
// on the real client instead of the proxy (issue #1955).
Expand Down
22 changes: 21 additions & 1 deletion backend/src/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ mass-flag every in-flight payment.

### Admin recovery actions (`PaymentsAdminController`, `ADMIN` role only)

- `GET /payments/admin/manual-review` — the review queue, oldest first.
- `GET /payments/admin/manual-review` — the review queue, oldest first,
paginated via `limit` (1–100, default 25) and `offset` (default 0); the
response is `{ items, total }`.
- `GET /payments/admin/metrics` — manual-review queue depth + alert status.
- `POST /payments/admin/:id/force-reconcile` — reconcile one payment now,
bypassing the backoff schedule.
Expand Down Expand Up @@ -99,6 +101,24 @@ scheduled job logs a `WARN`-level alert when it does. This is a
log-based signal by design — this module doesn't assume any particular
metrics/observability backend is wired up yet.

Each reconciliation run also records ledger/provider drift on `/metrics`
(issue #1942) as:

- `managehub_reconciliation_mismatch_count` (gauge) — mismatches detected by
the **most recent** run. A mismatch is any payment whose stored status the
run found out of line with the authoritative source: one the provider had
already settled (resolved), one past its TTL (expired-swept), or one
escalated to `MANUAL_REVIEW`.
- `managehub_reconciliation_mismatches_total` (counter) — cumulative
mismatches across all runs.

**Alert threshold:** page when `managehub_reconciliation_mismatch_count` is
non-zero on consecutive runs, or when
`rate(managehub_reconciliation_mismatches_total[1h])` climbs — a single
non-zero sample can be a normal webhook-vs-poll race, but sustained drift
means the webhook/confirmation path is failing and payments are only being
settled by the reconciler.

## Webhook payload contract

Every payment rail adapter maps its provider-specific confirmation event
Expand Down
37 changes: 37 additions & 0 deletions backend/src/payments/dto/list-manual-review-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';
import {
DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE,
} from '../payments-admin-pagination.util';

/**
* Offset pagination for `GET /payments/admin/manual-review` (issue #1944).
* Bounds the manual-review queue response so a provider outage that floods
* the queue can't produce an unbounded, unusable payload. Defaults and
* maximums are shared with `payments-admin-pagination.util.ts`.
*/
export class ListManualReviewQueryDto {
@ApiPropertyOptional({
description: 'Page size (1–100)',
default: DEFAULT_PAGE_SIZE,
minimum: 1,
maximum: MAX_PAGE_SIZE,
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_PAGE_SIZE)
limit: number = DEFAULT_PAGE_SIZE;

@ApiPropertyOptional({
description: 'Rows to skip from the start of the oldest-first queue',
default: 0,
minimum: 0,
})
@Type(() => Number)
@IsInt()
@Min(0)
offset: number = 0;
}
18 changes: 18 additions & 0 deletions backend/src/payments/dto/manual-review-page-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { PaymentResponseDto } from './payment-response.dto';

/**
* Paginated manual-review queue response (issue #1944). `total` is the
* full count of MANUAL_REVIEW payments, independent of the current page,
* so a client can compute the last page and render a pager.
*/
export class ManualReviewPageResponseDto {
@ApiProperty({ type: [PaymentResponseDto] })
items: PaymentResponseDto[];

@ApiProperty({
description: 'Total MANUAL_REVIEW payments across all pages',
type: Number,
})
total: number;
}
1 change: 1 addition & 0 deletions backend/src/payments/payment-confirmation.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe('PaymentConfirmationService', () => {
expect(paymentsService.transitionStatus).toHaveBeenCalledWith(
payment,
PaymentStatus.CONFIRMED,
'webhook',
);
expect(gateway.emitPaymentUpdate).toHaveBeenCalledWith(
payment.id,
Expand Down
21 changes: 20 additions & 1 deletion backend/src/payments/payment-confirmation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@ import { PaymentVerificationOutcome } from './interfaces/payment-rail-adapter.in
import { PaymentsService } from './payments.service';
import { PaymentsGateway } from './payments.gateway';
import { PaymentRailRegistry } from './payment-rail-registry';
import { PaymentTransitionSource } from './payment-state-machine';
import { withTimeout } from './utils/with-timeout';
import { withRequestId } from '../common/request-context';

const DEFAULT_VERIFY_TIMEOUT_MS = 3000;

/** Maps a ConfirmationSource to the transition-source label used when a refused transition is logged (issue #1941). */
function transitionSourceFor(
source: ConfirmationSource,
): PaymentTransitionSource {
switch (source) {
case ConfirmationSource.WEBHOOK:
return 'webhook';
case ConfirmationSource.RECONCILIATION:
return 'reconciliation';
default:
return 'verify_return';
}
}

const TERMINAL_STATUSES: ReadonlySet<PaymentStatus> = new Set([
PaymentStatus.CONFIRMED,
PaymentStatus.FAILED,
Expand Down Expand Up @@ -125,7 +140,11 @@ export class PaymentConfirmationService {
const nextStatus =
outcome === 'confirmed' ? PaymentStatus.CONFIRMED : PaymentStatus.FAILED;

this.paymentsService.transitionStatus(payment, nextStatus);
this.paymentsService.transitionStatus(
payment,
nextStatus,
transitionSourceFor(source),
);
const saved = await this.paymentRepository.save(payment);

await this.logEvent({
Expand Down
Loading
Loading