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
18 changes: 18 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
# PORT and NODE_ENV are read by src/main.ts and the auth cookie settings.
PORT=6000
NODE_ENV=development

# Maximum request body size accepted by the JSON and urlencoded parsers.
# The webhook parser still captures the exact raw bytes for HMAC checks.
REQUEST_BODY_LIMIT=1mb

# Structured JSON logging
# Production automatically uses pino; set LOG_JSON=true to enable it in
# another environment. LOG_JSON=false does not disable the production default.
LOG_JSON=

# Exact, comma-separated browser origins. Whitespace is ignored. In development
# and test, an empty value falls back to localhost:3000 and localhost:3001; in
# production an unset or empty value is an empty allowlist and denies all
Expand Down Expand Up @@ -135,6 +145,14 @@ PAYMENT_WEBHOOK_SECRET=change-me-in-every-environment
# payment CONFIRMED on its own.
PAYMENT_VERIFY_TIMEOUT_MS=3000

# Payment provider circuit breaker (issue #1797)
# Bounds one outbound provider initiation, opens after enough provider
# failures, and waits this long before trying a half-open request again.
# The breaker also requires ten requests in its rolling window before it can open.
PAYMENT_PROVIDER_BREAKER_TIMEOUT_MS=10000
PAYMENT_PROVIDER_BREAKER_ERROR_THRESHOLD_PERCENT=50
PAYMENT_PROVIDER_BREAKER_RESET_TIMEOUT_MS=30000

# Payments reconciliation engine (issue #1572)
# A payment isn't eligible for its first reconciliation pass until it's
# been AWAITING_CONFIRMATION for at least this long — gives the webhook a
Expand Down
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@
"nestjs-command": "^3.1.5",
"nodemailer": "^7.0.12",
"nodemailer-mjml": "^1.6.0",
"opossum": "^8.2.0",
"otplib": "^13.3.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"pino": "^9.5.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
Expand Down
113 changes: 113 additions & 0 deletions backend/src/common/structured-logger.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { LoggerService, LogLevel } from '@nestjs/common';
import pino from 'pino';
import { currentRequestId } from './request-context';

type PinoLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug';

/**
* Nest-compatible logger backed by pino. Nest supplies the logger context as
* the first optional parameter, so it is promoted to a structured field
* rather than being flattened into the message text.
*/
export class StructuredLoggerService implements LoggerService {
private readonly logger = pino();

log(message: any, ...optionalParams: any[]): void {
this.write('info', message, optionalParams);
}

error(message: any, ...optionalParams: any[]): void {
this.write('error', message, optionalParams);
}

warn(message: any, ...optionalParams: any[]): void {
this.write('warn', message, optionalParams);
}

debug(message: any, ...optionalParams: any[]): void {
this.write('debug', message, optionalParams);
}

verbose(message: any, ...optionalParams: any[]): void {
this.write('debug', message, optionalParams);
}

fatal(message: any, ...optionalParams: any[]): void {
this.write('fatal', message, optionalParams);
}

setLogLevels(levels: LogLevel[]): void {
if (levels.length === 0) {
this.logger.level = 'silent';
} else if (levels.includes('verbose') || levels.includes('debug')) {
this.logger.level = 'debug';
} else if (levels.includes('log')) {
this.logger.level = 'info';
} else if (levels.includes('warn')) {
this.logger.level = 'warn';
} else if (levels.includes('error')) {
this.logger.level = 'error';
} else {
this.logger.level = 'fatal';
}
}

private write(
level: PinoLevel,
message: any,
optionalParams: any[],
): void {
const fields: Record<string, unknown> = {};
const context =
typeof optionalParams[0] === 'string' ? optionalParams[0] : undefined;
const requestId = currentRequestId();

if (context) {
fields.context = context;
}
if (requestId) {
fields.requestId = requestId;
}
if (message instanceof Error) {
fields.err = message;
}

const text = this.stringifyMessage(message);
switch (level) {
case 'fatal':
this.logger.fatal(fields, text);
break;
case 'error':
this.logger.error(fields, text);
break;
case 'warn':
this.logger.warn(fields, text);
break;
case 'info':
this.logger.info(fields, text);
break;
case 'debug':
this.logger.debug(fields, text);
break;
}
}

private stringifyMessage(message: any): string {
if (typeof message === 'string') {
return message;
}
if (message instanceof Error) {
return message.stack ?? message.message;
}
try {
const serialized = JSON.stringify(message);
return serialized === undefined ? String(message) : serialized;
} catch {
try {
return String(message);
} catch {
return '[unserializable log message]';
}
}
}
}
52 changes: 52 additions & 0 deletions backend/src/common/structured-request-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import pino from 'pino';
import { NextFunction, Request, Response } from 'express';
import { currentRequestId } from './request-context';

/**
* Emits one structured record after a response finishes. It only attaches a
* listener and never writes headers or buffers, so the existing security and
* request-context middleware remain in control of the response.
*/
@Injectable()
export class StructuredRequestLoggerMiddleware implements NestMiddleware {
private readonly logger = pino();

use(req: Request, res: Response, next: NextFunction): void {
const startedAt = Date.now();
let logged = false;

const logCompletedResponse = (): void => {
if (logged) {
return;
}
logged = true;

const fields: Record<string, unknown> = {
method: req.method,
url: req.originalUrl,
path: req.path,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt,
};
const responseRequestId =
typeof res.getHeader === 'function'
? res.getHeader('x-request-id')
: undefined;
const requestId =
currentRequestId() ??
(typeof req.header === 'function'
? req.header('x-request-id')
: undefined) ??
(typeof responseRequestId === 'string' ? responseRequestId : undefined);
if (requestId) {
fields.requestId = requestId;
}

this.logger.info(fields, 'HTTP request');
};

res.on('finish', logCompletedResponse);
next();
}
}
51 changes: 45 additions & 6 deletions backend/src/credits/credits-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiProduces,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
Expand Down Expand Up @@ -61,6 +64,9 @@ import {
SettlementBatchBreakdownResponseDto,
SettlementBatchResponseDto,
} from './dto/settlement-response.dto';
import { LedgerIntegrityResponseDto } from './dto/ledger-integrity-response.dto';
import { PaymentSweepResponseDto } from './dto/payment-sweep-response.dto';
import { SettlementRunResponseDto } from './dto/settlement-run-response.dto';

/**
* Admin surface for the credit ledger (issue #1575): account policy,
Expand Down Expand Up @@ -89,6 +95,7 @@ export class CreditsAdminController {

@Get('accounts')
@ApiOperation({ summary: 'List ledger accounts' })
@ApiQuery({ name: 'currency', required: false, type: String })
@ApiResponse({ status: 200, type: [LedgerAccountResponseDto] })
async listAccounts(
@Query('currency') currency?: string,
Expand All @@ -101,6 +108,9 @@ export class CreditsAdminController {

@Get('accounts/export')
@ApiOperation({ summary: 'Export ledger accounts as xlsx' })
@ApiQuery({ name: 'currency', required: false, type: String })
@ApiProduces('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@ApiResponse({ status: 200, description: 'xlsx workbook stream' })
async exportAccounts(
@Query('currency') currency: string | undefined,
@Res({ passthrough: true }) res: Response,
Expand Down Expand Up @@ -165,6 +175,7 @@ export class CreditsAdminController {
}

@Patch('accounts/:id')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Update an account’s policy (overdraft, payout address, freeze)',
description:
Expand All @@ -190,6 +201,8 @@ export class CreditsAdminController {

@Get('balances/:userId')
@ApiOperation({ summary: 'A member’s credit balance' })
@ApiParam({ name: 'userId', type: String, format: 'uuid' })
@ApiQuery({ name: 'currency', required: false, type: String })
@ApiResponse({ status: 200, type: CreditBalanceResponseDto })
async getBalance(
@Param('userId', ParseUUIDPipe) userId: string,
Expand Down Expand Up @@ -227,8 +240,14 @@ export class CreditsAdminController {
'reports drift, plus any transaction whose debits and credits do not ' +
'cancel. Both lists empty is the healthy state.',
})
checkIntegrity(@Query('currency') currency?: string) {
return this.ledger.checkIntegrity(currency);
@ApiQuery({ name: 'currency', required: false, type: String })
@ApiResponse({ status: 200, type: LedgerIntegrityResponseDto })
async checkIntegrity(
@Query('currency') currency?: string,
): Promise<LedgerIntegrityResponseDto> {
return LedgerIntegrityResponseDto.fromView(
await this.ledger.checkIntegrity(currency),
);
}

// ── revenue splits ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -264,6 +283,7 @@ export class CreditsAdminController {
}

@Get('splits/:id')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({ summary: 'Get one revenue split config' })
@ApiResponse({ status: 200, type: RevenueSplitConfigResponseDto })
async getSplit(
Expand All @@ -274,6 +294,7 @@ export class CreditsAdminController {
}

@Put('splits/:id/recipients')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Replace a config’s recipients',
description:
Expand All @@ -290,6 +311,7 @@ export class CreditsAdminController {
}

@Post('splits/:id/active')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({ summary: 'Activate or deactivate a config' })
@ApiResponse({ status: 200, type: RevenueSplitConfigResponseDto })
async setActive(
Expand Down Expand Up @@ -329,6 +351,7 @@ export class CreditsAdminController {
// ── payment integration ────────────────────────────────────────────────

@Post('payments/:paymentId/split-config')
@ApiParam({ name: 'paymentId', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Attach a revenue split config to a payment',
description:
Expand All @@ -350,6 +373,7 @@ export class CreditsAdminController {
}

@Post('payments/:paymentId/top-up')
@ApiParam({ name: 'paymentId', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Mark a payment as funding the payer’s credit balance',
description:
Expand All @@ -368,8 +392,11 @@ export class CreditsAdminController {
@ApiOperation({
summary: 'Run the confirmed-payment credit sweep immediately',
})
sweepPayments() {
return this.paymentCredits.sweepConfirmedPayments();
@ApiResponse({ status: 200, type: PaymentSweepResponseDto })
async sweepPayments(): Promise<PaymentSweepResponseDto> {
return PaymentSweepResponseDto.fromView(
await this.paymentCredits.sweepConfirmedPayments(),
);
}

// ── settlement ─────────────────────────────────────────────────────────
Expand All @@ -382,8 +409,11 @@ export class CreditsAdminController {
'Safe to call at any time — the same guarantees the scheduled job ' +
'relies on.',
})
runSettlement() {
return this.settlement.runSettlement();
@ApiResponse({ status: 200, type: SettlementRunResponseDto })
async runSettlement(): Promise<SettlementRunResponseDto> {
return SettlementRunResponseDto.fromView(
await this.settlement.runSettlement(),
);
}

@Post('settlement/batches')
Expand Down Expand Up @@ -413,6 +443,11 @@ export class CreditsAdminController {

@Get('settlement/batches')
@ApiOperation({ summary: 'List settlement batches, newest first' })
@ApiQuery({
name: 'status',
required: false,
enum: SettlementBatchStatus,
})
@ApiResponse({ status: 200, type: [SettlementBatchResponseDto] })
async listBatches(
@Query('status') status?: SettlementBatchStatus,
Expand All @@ -422,6 +457,7 @@ export class CreditsAdminController {
}

@Get('settlement/batches/:id')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Full breakdown of one batch',
description:
Expand All @@ -437,6 +473,7 @@ export class CreditsAdminController {
}

@Post('settlement/batches/:id/execute')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Advance one batch by a step (submit pending, poll submitted)',
})
Expand All @@ -457,6 +494,7 @@ export class CreditsAdminController {
}

@Post('settlement/batches/:id/retry')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Re-queue a batch’s failed payouts',
description:
Expand All @@ -480,6 +518,7 @@ export class CreditsAdminController {
}

@Post('settlement/batches/:id/abandon')
@ApiParam({ name: 'id', type: String, format: 'uuid' })
@ApiOperation({
summary: 'Give up on a batch and release its unsettled claims',
description:
Expand Down
Loading
Loading