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
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async function bootstrap(): Promise<void> {

const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(getWinstonConfig(ENV)),
rawBody: true,
});

// =============================================================
Expand Down Expand Up @@ -202,6 +203,11 @@ async function bootstrap(): Promise<void> {
'Draft generation, lifecycle, and invoice item details',
)
.addTag('Billing', 'Tenant double-entry ledger and balance querying')
.addTag('Payments', 'Payment processing and invoice payment attempts')
.addTag(
'Webhooks',
'Payment provider webhook events (Stripe, fake, etc.)',
)
.addTag('Health', 'System uptime and dependency checks')
.build();

Expand Down
23 changes: 22 additions & 1 deletion src/modules/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
ParseUUIDPipe,
Expand All @@ -41,6 +42,7 @@ import { ErrorResponseDto } from '@common/dto';
import { RolesGuard, TenantGuard } from '@common/guards';

import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { PaymentIntentService } from '@modules/payments/payment-intent.service';

import { MembershipRole } from '@prisma/client';

Expand All @@ -58,10 +60,13 @@ import { InvoiceLifecycleService } from './invoice-lifecycle.service';
version: '1',
})
export class InvoicesController {
private readonly logger = new Logger(InvoicesController.name);

constructor(
private readonly prisma: PrismaService,
private readonly invoiceGeneration: InvoiceGenerationService,
private readonly invoiceLifecycle: InvoiceLifecycleService,
private readonly paymentIntentService: PaymentIntentService,
) {}

/**
Expand Down Expand Up @@ -294,7 +299,23 @@ export class InvoicesController {
@Param('id', ParseUUIDPipe) id: string,
@TenantId() tenantId: string,
) {
return this.invoiceLifecycle.finalize(id, tenantId);
const result = await this.invoiceLifecycle.finalize(id, tenantId);

// Create payment intent - fire-and-forget (don't block the response)
this.paymentIntentService
.createForInvoice(id, tenantId, result.total, result.currency)
.then((attempt) => {
this.logger.log(
`Payment intent created for ${result.invoiceNumber}: ${attempt.providerPaymentId}`,
);
})
.catch((error) => {
this.logger.error(
`Failed to create payment intent for ${result.invoiceNumber}: ${error instanceof Error ? error.message : String(error)}`,
);
});

return result;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/modules/payments/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
PaymentAttemptResponseDto,
PaymentAttemptListResponseDto,
} from './payment-attempt-response.dto';
export { WebhookEventResponseDto } from './webhook-event-response.dto';
76 changes: 76 additions & 0 deletions src/modules/payments/dto/payment-attempt-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Response DTOs for payment attempt endpoints.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class PaymentAttemptResponseDto {
@ApiProperty({ example: 'a1b2c3d4-...' })
id!: string;

@ApiProperty({ example: 'e2fb5571-...' })
invoiceId!: string;

@ApiProperty({ example: 'f239538d-...' })
tenantId!: string;

@ApiProperty({
example: 'fake_pi_718cac9ddd9349719ac37fce',
description: 'Provider-specific payment intent ID',
})
providerPaymentId!: string;

@ApiProperty({ example: 'fake', description: 'Payment provider name' })
provider!: string;

@ApiProperty({
enum: ['PENDING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REQUIRES_ACTION'],
example: 'SUCCEEDED',
})
status!: string;

@ApiProperty({ example: 9900, description: 'Amount in cents' })
amount!: number;

@ApiProperty({ example: 'usd' })
currency!: string;

@ApiPropertyOptional({
example: 'Insufficient funds',
nullable: true,
description: 'Failure reason from provider (null if not failed)',
})
failureReason!: string | null;

@ApiProperty({
example: 0,
description: 'Attempt number (0 = first, 1 = first retry, etc.)',
})
attemptNumber!: number;

@ApiPropertyOptional({
nullable: true,
description: 'When the next retry is scheduled',
})
nextRetryAt!: Date | null;

@ApiProperty({ example: '2026-05-22T08:34:08.751Z' })
createdAt!: Date;

@ApiProperty({ example: '2026-05-22T08:34:08.751Z' })
updatedAt!: Date;
}

export class PaymentAttemptListResponseDto {
@ApiProperty({ type: [PaymentAttemptResponseDto] })
data!: PaymentAttemptResponseDto[];

@ApiProperty({
example: { total: 3, page: 1, limit: 20, totalPages: 1 },
})
meta!: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
39 changes: 39 additions & 0 deletions src/modules/payments/dto/webhook-event-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Response DTOs for webhook event endpoints (admin use).
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class WebhookEventResponseDto {
@ApiProperty({ example: 'a1b2c3d4-...' })
id!: string;

@ApiProperty({
example: 'fake_evt_success_008',
description: 'Provider event ID (deduplication key)',
})
providerEventId!: string;

@ApiProperty({ example: 'fake' })
provider!: string;

@ApiProperty({ example: 'payment.succeeded' })
eventType!: string;

@ApiProperty({
enum: ['PENDING', 'PROCESSING', 'PROCESSED', 'FAILED', 'SKIPPED'],
example: 'PROCESSED',
})
status!: string;

@ApiPropertyOptional({
nullable: true,
description: 'Error message if processing failed',
})
processingError!: string | null;

@ApiPropertyOptional({ nullable: true })
processedAt!: Date | null;

@ApiProperty({ example: '2026-05-22T08:51:56.397Z' })
createdAt!: Date;
}
90 changes: 90 additions & 0 deletions src/modules/payments/handlers/payment-failure.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* PaymentFailureHandler - Handles payment.failed webhook events.
*
* Flow:
* 1. Find the payment attempt by providerPaymentId
* 2. Update attempt status to FAILED with reason
* 3. If retries remain: schedule next retry
* 4. If max retries exceeded: transition subscription to PAST_DUE
*/
import { Injectable, Logger } from '@nestjs/common';

import { PrismaService } from '@app-prisma/prisma.service';

import { Prisma } from '@prisma/client';

import { PaymentIntentService } from '../payment-intent.service';
import type { WebhookEvent } from '../payment-provider.base';

/** Max total attempts (including first attempt). */
const MAX_ATTEMPTS = 3;

@Injectable()
export class PaymentFailureHandler {
private readonly logger = new Logger(PaymentFailureHandler.name);

constructor(
private readonly prisma: PrismaService,
private readonly paymentIntentService: PaymentIntentService,
) {}

/**
* Handle a failed payment event.
*/
async handle(event: WebhookEvent): Promise<void> {
// Find the payment attempt
const attempt = await this.prisma.paymentAttempt.findUnique({
where: { providerPaymentId: event.providerPaymentId },
include: {
invoice: {
select: {
id: true,
tenantId: true,
subscriptionId: true,
invoiceNumber: true,
},
},
},
});

if (!attempt) {
this.logger.warn(
`Payment attempt not found for provider ID: ${event.providerPaymentId}`,
);
return;
}

// Update payment attempt status to FAILED
await this.prisma.paymentAttempt.update({
where: { id: attempt.id },
data: {
status: 'FAILED',
failureReason: event.failureReason ?? 'Unknown failure',
providerResponse: event.rawPayload as unknown as Prisma.InputJsonValue,
},
});

this.logger.warn(
`Payment failed for invoice ${attempt.invoice.invoiceNumber}: ${event.failureReason ?? 'Unknown reason'} (attempt ${attempt.attemptNumber + 1}/${MAX_ATTEMPTS})`,
);

// Check if retries remain
if (attempt.attemptNumber + 1 < MAX_ATTEMPTS) {
// Schedule retry
await this.paymentIntentService.scheduleRetry(attempt.id);
this.logger.log(
`Retry scheduled for invoice ${attempt.invoice.invoiceNumber}`,
);
} else {
// Max retries exceeded - transition subscription to PAST_DUE
await this.prisma.subscription.update({
where: { id: attempt.invoice.subscriptionId },
data: { status: 'PAST_DUE' },
});

this.logger.error(
`Max retries exceeded for invoice ${attempt.invoice.invoiceNumber} - subscription moved to PAST_DUE`,
);
}
}
}
Loading
Loading