From 7141c03a13e10c00e220841af56aef4322a32638 Mon Sep 17 00:00:00 2001 From: chitrank2050 Date: Fri, 22 May 2026 14:06:14 +0530 Subject: [PATCH 1/4] feat(payments): payment intent service + webhook receiver with handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentIntentService: creates payment intents, schedules retries with backoff - WebhookController: POST /webhooks/stripe + /webhooks/fake endpoints - WebhookProcessingService: signature validation, deduplication, event routing - PaymentSuccessHandler: marks invoice PAID, creates ledger PAYMENT, advances period - PaymentFailureHandler: records failure, schedules retry, PAST_DUE after max retries - Webhook deduplication via UNIQUE provider_event_id constraint - Raw body parser enabled for Stripe signature validation - Schema: payment_attempts + webhook_events tables with indexes - Tested: success flow, failure flow, dedup — all passing --- src/main.ts | 1 + .../handlers/payment-failure.handler.ts | 90 ++++++++ .../handlers/payment-success.handler.ts | 106 +++++++++ .../payments/payment-intent.service.ts | 201 ++++++++++++++++++ src/modules/payments/payments.module.ts | 34 +-- .../payments/webhook-processing.service.ts | 146 +++++++++++++ src/modules/payments/webhook.controller.ts | 121 +++++++++++ 7 files changed, 683 insertions(+), 16 deletions(-) create mode 100644 src/modules/payments/handlers/payment-failure.handler.ts create mode 100644 src/modules/payments/handlers/payment-success.handler.ts create mode 100644 src/modules/payments/payment-intent.service.ts create mode 100644 src/modules/payments/webhook-processing.service.ts create mode 100644 src/modules/payments/webhook.controller.ts diff --git a/src/main.ts b/src/main.ts index 51f18b7..426d63c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { logger: WinstonModule.createLogger(getWinstonConfig(ENV)), + rawBody: true, }); // ============================================================= diff --git a/src/modules/payments/handlers/payment-failure.handler.ts b/src/modules/payments/handlers/payment-failure.handler.ts new file mode 100644 index 0000000..02f43c5 --- /dev/null +++ b/src/modules/payments/handlers/payment-failure.handler.ts @@ -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 { + // 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`, + ); + } + } +} diff --git a/src/modules/payments/handlers/payment-success.handler.ts b/src/modules/payments/handlers/payment-success.handler.ts new file mode 100644 index 0000000..31d081f --- /dev/null +++ b/src/modules/payments/handlers/payment-success.handler.ts @@ -0,0 +1,106 @@ +/** + * PaymentSuccessHandler - Handles payment.succeeded webhook events. + * + * Flow: + * 1. Find the payment attempt by providerPaymentId + * 2. Update attempt status to SUCCEEDED + * 3. Mark the invoice as PAID (creates ledger PAYMENT entry) + * 4. Advance the subscription to the next billing period + * + * Idempotent: if invoice is already PAID, skip silently. + */ +import { Injectable, Logger } from '@nestjs/common'; + +import { PrismaService } from '@app-prisma/prisma.service'; + +import { BillingPeriodService } from '@modules/invoices/billing-period.service'; +import { InvoiceLifecycleService } from '@modules/invoices/invoice-lifecycle.service'; + +import { Prisma } from '@prisma/client'; + +import type { WebhookEvent } from '../payment-provider.base'; + +@Injectable() +export class PaymentSuccessHandler { + private readonly logger = new Logger(PaymentSuccessHandler.name); + + constructor( + private readonly prisma: PrismaService, + private readonly invoiceLifecycle: InvoiceLifecycleService, + private readonly billingPeriod: BillingPeriodService, + ) {} + + /** + * Handle a successful payment event. + */ + async handle(event: WebhookEvent): Promise { + // Find the payment attempt + const attempt = await this.prisma.paymentAttempt.findUnique({ + where: { providerPaymentId: event.providerPaymentId }, + include: { + invoice: { + select: { + id: true, + tenantId: true, + subscriptionId: true, + status: true, + invoiceNumber: true, + }, + }, + }, + }); + + if (!attempt) { + this.logger.warn( + `Payment attempt not found for provider ID: ${event.providerPaymentId}`, + ); + return; + } + + // Idempotent: if invoice is already PAID, skip + if (attempt.invoice.status === 'PAID') { + this.logger.debug( + `Invoice ${attempt.invoice.invoiceNumber} already PAID - skipping`, + ); + return; + } + + // Update payment attempt status + await this.prisma.paymentAttempt.update({ + where: { id: attempt.id }, + data: { + status: 'SUCCEEDED', + providerResponse: event.rawPayload as unknown as Prisma.InputJsonValue, + }, + }); + + // Mark invoice as PAID (creates ledger PAYMENT entry) + await this.invoiceLifecycle.markPaid( + attempt.invoiceId, + attempt.tenantId, + event.providerPaymentId, + ); + + // Advance subscription to next billing period + const subscription = await this.prisma.subscription.findUnique({ + where: { id: attempt.invoice.subscriptionId }, + select: { + id: true, + currentPeriodEnd: true, + price: { select: { interval: true } }, + }, + }); + + if (subscription) { + await this.billingPeriod.advancePeriod( + subscription.id, + subscription.currentPeriodEnd, + subscription.price.interval, + ); + } + + this.logger.log( + `Payment succeeded for invoice ${attempt.invoice.invoiceNumber}: ${event.providerPaymentId}`, + ); + } +} diff --git a/src/modules/payments/payment-intent.service.ts b/src/modules/payments/payment-intent.service.ts new file mode 100644 index 0000000..1714c5c --- /dev/null +++ b/src/modules/payments/payment-intent.service.ts @@ -0,0 +1,201 @@ +/** + * PaymentIntentService - Creates payment intents when invoices are finalized. + * + * Flow: + * 1. Invoice finalized → this service is called + * 2. Creates a payment intent via the adapter (Stripe or fake) + * 3. Records a payment_attempts row with status PENDING + * 4. If the adapter returns immediate success (fake adapter), + * triggers the success handler directly + * + * Retry flow (called by PaymentFailureHandler): + * 1. Previous attempt failed + * 2. Creates a new payment intent with incremented attemptNumber + * 3. Links to previous attempt via retryOf + * + * The service doesn't wait for webhooks - it creates the intent + * and returns. The webhook handler processes the async result. + */ +import { + BadRequestException, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; + +import { PrismaService } from '@app-prisma/prisma.service'; + +import { ERRORS } from '@common/constants'; + +import { PAYMENT_PROVIDER, PaymentProviderBase } from './payment-provider.base'; + +/** Max payment retries before giving up. */ +const MAX_RETRIES = 3; + +/** Retry delays in milliseconds: day 1, day 3, day 7. */ +const RETRY_DELAYS_MS = [ + 1 * 24 * 60 * 60 * 1000, + 3 * 24 * 60 * 60 * 1000, + 7 * 24 * 60 * 60 * 1000, +]; + +@Injectable() +export class PaymentIntentService { + private readonly logger = new Logger(PaymentIntentService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(PAYMENT_PROVIDER) + private readonly paymentProvider: PaymentProviderBase, + ) {} + + /** + * Create a payment intent for a finalized invoice. + * + * @param invoiceId - Invoice UUID + * @param tenantId - Tenant UUID + * @param amount - Amount in cents + * @param currency - ISO 4217 currency code + * @returns The created payment attempt record + */ + async createForInvoice( + invoiceId: string, + tenantId: string, + amount: number, + currency: string, + ) { + // Verify invoice is FINALIZED + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, tenantId, status: 'FINALIZED' }, + select: { id: true, invoiceNumber: true }, + }); + + if (!invoice) { + throw new BadRequestException(ERRORS.PAYMENT.INVOICE_NOT_FINALIZED); + } + + // Check for existing successful payment + const existingSuccess = await this.prisma.paymentAttempt.findFirst({ + where: { invoiceId, status: 'SUCCEEDED' }, + select: { id: true }, + }); + + if (existingSuccess) { + throw new BadRequestException(ERRORS.PAYMENT.ALREADY_SUCCEEDED); + } + + // Count previous attempts for this invoice + const previousAttempts = await this.prisma.paymentAttempt.count({ + where: { invoiceId }, + }); + + if (previousAttempts >= MAX_RETRIES) { + throw new BadRequestException( + ERRORS.PAYMENT.MAX_RETRIES_EXCEEDED(invoiceId), + ); + } + + // Create payment intent via provider adapter + const result = await this.paymentProvider.createPaymentIntent( + amount, + currency, + { + invoiceId, + invoiceNumber: invoice.invoiceNumber ?? '', + tenantId, + }, + ); + + // Find the last failed attempt for retry linking + const lastFailed = await this.prisma.paymentAttempt.findFirst({ + where: { invoiceId, status: 'FAILED' }, + orderBy: { createdAt: 'desc' }, + select: { id: true }, + }); + + // Record the payment attempt + const attempt = await this.prisma.paymentAttempt.create({ + data: { + invoiceId, + tenantId, + providerPaymentId: result.providerPaymentId, + provider: process.env.PAYMENT_PROVIDER ?? 'fake', + status: this.mapInitialStatus(result.status), + amount, + currency, + attemptNumber: previousAttempts, + retryOf: lastFailed?.id ?? null, + }, + }); + + this.logger.log( + `Payment intent created: ${result.providerPaymentId} for invoice ${invoice.invoiceNumber} (attempt ${previousAttempts + 1}/${MAX_RETRIES})`, + ); + + return { + ...attempt, + providerStatus: result.status, + clientSecret: result.clientSecret, + }; + } + + /** + * Schedule a retry for a failed payment. + * + * @param paymentAttemptId - The failed payment attempt + * @returns The new payment attempt, or null if max retries exceeded + */ + async scheduleRetry(paymentAttemptId: string) { + const failed = await this.prisma.paymentAttempt.findUnique({ + where: { id: paymentAttemptId }, + include: { + invoice: { + select: { + id: true, + tenantId: true, + total: true, + currency: true, + invoiceNumber: true, + }, + }, + }, + }); + + if (!failed || failed.status !== 'FAILED') return null; + + const delayMs = + RETRY_DELAYS_MS[failed.attemptNumber] ?? + RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]; + const nextRetryAt = new Date(Date.now() + delayMs); + + // Update the failed attempt with retry schedule + await this.prisma.paymentAttempt.update({ + where: { id: paymentAttemptId }, + data: { nextRetryAt }, + }); + + this.logger.log( + `Retry scheduled for payment ${paymentAttemptId}: attempt ${failed.attemptNumber + 2}/${MAX_RETRIES} at ${nextRetryAt.toISOString()}`, + ); + + return { nextRetryAt, attemptNumber: failed.attemptNumber + 1 }; + } + + /** + * Map the provider's initial status to our enum. + */ + private mapInitialStatus( + providerStatus: string, + ): 'PENDING' | 'SUCCEEDED' | 'FAILED' | 'REQUIRES_ACTION' { + switch (providerStatus) { + case 'succeeded': + return 'SUCCEEDED'; + case 'failed': + return 'FAILED'; + case 'requires_action': + return 'REQUIRES_ACTION'; + default: + return 'PENDING'; + } + } +} diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index ba41914..e7fb10e 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -1,24 +1,18 @@ /** - * PaymentsModule - Payment provider integration. - * - * Selects the adapter based on PAYMENT_PROVIDER env var: - * - 'stripe': real Stripe SDK (requires STRIPE_SECRET_KEY) - * - 'fake' (default): simulated payments for dev/testing - * - * To add a new provider: - * 1. Create adapters/new-provider.adapter.ts extending PaymentProviderBase - * 2. Implement the 4 abstract methods - * 3. Add a case to the switch below - * 4. Add env vars for the provider's API keys - * - * The adapter is injected via the PAYMENT_PROVIDER token. - * Services use: @Inject(PAYMENT_PROVIDER) private readonly payments: PaymentProviderBase + * PaymentsModule - Payment provider integration, webhook handling, payment lifecycle. */ import { Global, Logger, Module } from '@nestjs/common'; +import { InvoicesModule } from '@modules/invoices'; + import { FakePaymentAdapter } from './adapters/fake-payment.adapter'; import { StripePaymentAdapter } from './adapters/stripe-payment.adapter'; +import { PaymentFailureHandler } from './handlers/payment-failure.handler'; +import { PaymentSuccessHandler } from './handlers/payment-success.handler'; +import { PaymentIntentService } from './payment-intent.service'; import { PAYMENT_PROVIDER } from './payment-provider.base'; +import { WebhookProcessingService } from './webhook-processing.service'; +import { WebhookController } from './webhook.controller'; const logger = new Logger('PaymentsModule'); @@ -43,7 +37,15 @@ const paymentProviderFactory = { @Global() @Module({ - providers: [paymentProviderFactory], - exports: [PAYMENT_PROVIDER], + imports: [InvoicesModule], + controllers: [WebhookController], + providers: [ + paymentProviderFactory, + PaymentIntentService, + WebhookProcessingService, + PaymentSuccessHandler, + PaymentFailureHandler, + ], + exports: [PAYMENT_PROVIDER, PaymentIntentService], }) export class PaymentsModule {} diff --git a/src/modules/payments/webhook-processing.service.ts b/src/modules/payments/webhook-processing.service.ts new file mode 100644 index 0000000..7d12549 --- /dev/null +++ b/src/modules/payments/webhook-processing.service.ts @@ -0,0 +1,146 @@ +/** + * WebhookProcessingService - Validates, deduplicates, and routes webhook events. + * + * Flow: + * 1. Validate signature via payment adapter + * 2. Check deduplication (providerEventId UNIQUE in webhook_events) + * 3. Persist raw event to webhook_events table + * 4. Route to handler by event type + * 5. Update webhook_events status (PROCESSED or FAILED) + * + * Deduplication is two-layer: + * 1. DB UNIQUE on provider_event_id - catches duplicates at insert + * 2. State check in handler - if invoice is already PAID, skip + */ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { PrismaService } from '@app-prisma/prisma.service'; + +import { isUniqueConstraintError } from '@common/utils/prisma-errors'; + +import { Prisma } from '@prisma/client'; + +import { PaymentFailureHandler } from './handlers/payment-failure.handler'; +import { PaymentSuccessHandler } from './handlers/payment-success.handler'; +import { PAYMENT_PROVIDER, PaymentProviderBase } from './payment-provider.base'; + +@Injectable() +export class WebhookProcessingService { + private readonly logger = new Logger(WebhookProcessingService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(PAYMENT_PROVIDER) + private readonly paymentProvider: PaymentProviderBase, + private readonly successHandler: PaymentSuccessHandler, + private readonly failureHandler: PaymentFailureHandler, + ) {} + + /** + * Process a raw webhook payload. + * + * @param rawBody - Raw request body bytes + * @param signature - Provider signature header + * @param provider - Provider name ('stripe', 'fake') + */ + async processWebhook( + rawBody: Buffer, + signature: string, + provider: string, + ): Promise { + // 1. Validate and parse via adapter + let event; + try { + event = await this.paymentProvider.constructWebhookEvent( + rawBody, + signature, + ); + } catch (error) { + this.logger.error( + `Webhook signature validation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + // 2. Deduplicate - persist to webhook_events (UNIQUE on provider_event_id) + let webhookEventId: string; + try { + const webhookEvent = await this.prisma.webhookEvent.create({ + data: { + providerEventId: event.providerEventId, + provider, + eventType: event.type, + rawPayload: event.rawPayload as unknown as Prisma.InputJsonValue, + status: 'PROCESSING', + }, + }); + webhookEventId = webhookEvent.id; + } catch (error) { + if (isUniqueConstraintError(error)) { + this.logger.debug( + `Duplicate webhook skipped: ${event.providerEventId}`, + ); + return; + } + throw error; + } + + // 3. Route to handler by event type + try { + switch (event.type) { + case 'payment.succeeded': + await this.successHandler.handle(event); + break; + + case 'payment.failed': + await this.failureHandler.handle(event); + break; + + case 'payment.requires_action': + this.logger.warn( + `Payment requires action: ${event.providerPaymentId} - manual intervention needed`, + ); + break; + + case 'refund.succeeded': + this.logger.log(`Refund succeeded: ${event.providerPaymentId}`); + // Refund handling comes with Phase 5.5 / Phase 6 + break; + + default: + this.logger.warn(`Unhandled webhook event type: ${event.type}`); + await this.prisma.webhookEvent.update({ + where: { id: webhookEventId }, + data: { status: 'SKIPPED', processedAt: new Date() }, + }); + return; + } + + // 4. Mark as processed + await this.prisma.webhookEvent.update({ + where: { id: webhookEventId }, + data: { status: 'PROCESSED', processedAt: new Date() }, + }); + + this.logger.log( + `Webhook processed: ${event.providerEventId} (${event.type})`, + ); + } catch (error) { + // 5. Mark as failed + const errorMessage = + error instanceof Error ? error.message : String(error); + await this.prisma.webhookEvent.update({ + where: { id: webhookEventId }, + data: { + status: 'FAILED', + processingError: errorMessage, + processedAt: new Date(), + }, + }); + + this.logger.error( + `Webhook processing failed for ${event.providerEventId}: ${errorMessage}`, + ); + } + } +} diff --git a/src/modules/payments/webhook.controller.ts b/src/modules/payments/webhook.controller.ts new file mode 100644 index 0000000..a36bf5b --- /dev/null +++ b/src/modules/payments/webhook.controller.ts @@ -0,0 +1,121 @@ +/** + * WebhookController - Receives webhook events from payment providers. + * + * Single endpoint: POST /api/v1/webhooks/stripe + * + * NO authentication guard - Stripe doesn't send JWT tokens. + * Signature validation IS the authentication (via the adapter). + * + * Critical: uses raw body (not parsed JSON) because Stripe + * signature validation requires the exact bytes as received. + * + * Flow: + * 1. Receive raw body + signature header + * 2. Validate signature via adapter + * 3. Check deduplication (provider event ID) + * 4. Route to handler by event type + * 5. Return 200 immediately + */ +import { + Controller, + Headers, + HttpCode, + HttpStatus, + Logger, + Post, + Req, + Res, +} from '@nestjs/common'; +import { + ApiExcludeEndpoint, + ApiHeader, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import type { Request, Response } from 'express'; + +import { WebhookProcessingService } from './webhook-processing.service'; + +@ApiTags('Webhooks') +@Controller({ + path: 'webhooks', + version: '1', +}) +export class WebhookController { + private readonly logger = new Logger(WebhookController.name); + + constructor(private readonly webhookProcessing: WebhookProcessingService) {} + + /** + * POST /api/v1/webhooks/stripe + * + * Receives Stripe webhook events. + * No auth guard - signature validation is the authentication. + * Returns 200 immediately - Stripe retries on non-2xx. + */ + @Post('stripe') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Receive Stripe webhook events' }) + @ApiHeader({ name: 'stripe-signature', required: true }) + @ApiResponse({ status: 200, description: 'Webhook received' }) + @ApiResponse({ status: 400, description: 'Invalid signature or payload' }) + async handleStripeWebhook( + @Req() req: Request, + @Headers('stripe-signature') signature: string, + @Res() res: Response, + ) { + // Return 200 immediately - don't make Stripe wait + res.status(200).json({ received: true }); + + // Process asynchronously (fire-and-forget) + try { + const rawBody = (req as any).rawBody as Buffer; + + if (!rawBody) { + this.logger.error( + 'Raw body not available - ensure raw body parser is configured', + ); + return; + } + + await this.webhookProcessing.processWebhook( + rawBody, + signature ?? '', + 'stripe', + ); + } catch (error) { + this.logger.error( + `Webhook processing failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * POST /api/v1/webhooks/fake + * + * Test endpoint for the fake payment adapter. + * Accepts JSON body directly (no signature validation). + * Only available when PAYMENT_PROVIDER=fake. + */ + @Post('fake') + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() + async handleFakeWebhook(@Req() req: Request, @Res() res: Response) { + res.status(200).json({ received: true }); + + try { + const body = Buffer.from(JSON.stringify(req.body)); + await this.webhookProcessing.processWebhook( + body, + 'fake-signature', + 'fake', + ); + } catch (error) { + this.logger.error( + `Fake webhook processing failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} From 5b67895d2e997fb9b1705c1de00f91e605783f45 Mon Sep 17 00:00:00 2001 From: chitrank2050 Date: Fri, 22 May 2026 14:20:37 +0530 Subject: [PATCH 2/4] feat(payments): auto-create payment intent on invoice finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InvoicesController.finalize() triggers PaymentIntentService after finalize - Fire-and-forget: response returns immediately, payment created async - Fixed duplicate finalize call bug (was calling finalize twice) - Tested: generate → finalize → payment auto-created with SUCCEEDED status --- src/modules/invoices/invoices.controller.ts | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index 4c90c50..edc446b 100644 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -16,6 +16,7 @@ import { Get, HttpCode, HttpStatus, + Logger, NotFoundException, Param, ParseUUIDPipe, @@ -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'; @@ -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, ) {} /** @@ -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; } /** From ec97f29aad8ca82ae78bc0890aa23628a915c397 Mon Sep 17 00:00:00 2001 From: chitrank2050 Date: Fri, 22 May 2026 14:24:05 +0530 Subject: [PATCH 3/4] =?UTF-8?q?feat(payments):=20payment=20success=20handl?= =?UTF-8?q?er=20=E2=80=94=20verified=20full=20webhook=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Webhook → find attempt → update SUCCEEDED → markPaid → advancePeriod - Tested: INV-2026-0008 FINALIZED → PAID via webhook, ledger PAYMENT created - Tested: subscription period advanced Jul 20 → Aug 20 - Tested: idempotent — duplicate webhook skipped, no double-payment - advancePeriod wrapped in try/catch (non-critical, cron catches failures) --- .../handlers/payment-success.handler.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/modules/payments/handlers/payment-success.handler.ts b/src/modules/payments/handlers/payment-success.handler.ts index 31d081f..ca4d1f6 100644 --- a/src/modules/payments/handlers/payment-success.handler.ts +++ b/src/modules/payments/handlers/payment-success.handler.ts @@ -8,6 +8,13 @@ * 4. Advance the subscription to the next billing period * * Idempotent: if invoice is already PAID, skip silently. + * + * Transaction note: markPaid uses its own Prisma transaction internally. + * advancePeriod is a separate call — if it fails, the invoice is still + * PAID (correct state) and the billing cron catches un-advanced periods + * on the next daily tick. This is an acceptable trade-off vs forcing + * all operations into a single transaction (which would require + * refactoring markPaid to accept a transaction client). */ import { Injectable, Logger } from '@nestjs/common'; @@ -82,20 +89,29 @@ export class PaymentSuccessHandler { ); // Advance subscription to next billing period - const subscription = await this.prisma.subscription.findUnique({ - where: { id: attempt.invoice.subscriptionId }, - select: { - id: true, - currentPeriodEnd: true, - price: { select: { interval: true } }, - }, - }); + // Wrapped in try/catch — if this fails, invoice is still PAID (correct) + // and the billing cron will catch the un-advanced period on the next tick. + try { + const subscription = await this.prisma.subscription.findUnique({ + where: { id: attempt.invoice.subscriptionId }, + select: { + id: true, + currentPeriodEnd: true, + price: { select: { interval: true } }, + }, + }); - if (subscription) { - await this.billingPeriod.advancePeriod( - subscription.id, - subscription.currentPeriodEnd, - subscription.price.interval, + if (subscription) { + await this.billingPeriod.advancePeriod( + subscription.id, + subscription.currentPeriodEnd, + subscription.price.interval, + ); + } + } catch (error) { + // Non-critical: billing cron retries un-advanced periods daily + this.logger.error( + `Failed to advance period for invoice ${attempt.invoice.invoiceNumber}: ${error instanceof Error ? error.message : String(error)}`, ); } From 9f6906f68bc1ea33abaafe4a0d819ac5826e276e Mon Sep 17 00:00:00 2001 From: chitrank2050 Date: Fri, 22 May 2026 19:48:46 +0530 Subject: [PATCH 4/4] feat: add Swagger tags for payments & webhooks, introduce response DTOs for payment & webhook events --- src/main.ts | 5 ++ src/modules/invoices/invoices.controller.ts | 2 +- src/modules/payments/dto/index.ts | 5 ++ .../dto/payment-attempt-response.dto.ts | 76 +++++++++++++++++++ .../dto/webhook-event-response.dto.ts | 39 ++++++++++ .../handlers/payment-success.handler.ts | 4 +- src/modules/payments/index.ts | 7 ++ 7 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/modules/payments/dto/index.ts create mode 100644 src/modules/payments/dto/payment-attempt-response.dto.ts create mode 100644 src/modules/payments/dto/webhook-event-response.dto.ts diff --git a/src/main.ts b/src/main.ts index 426d63c..799fe3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,6 +203,11 @@ async function bootstrap(): Promise { '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(); diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index edc446b..a24b9c5 100644 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -301,7 +301,7 @@ export class InvoicesController { ) { const result = await this.invoiceLifecycle.finalize(id, tenantId); - // Create payment intent — fire-and-forget (don't block the response) + // Create payment intent - fire-and-forget (don't block the response) this.paymentIntentService .createForInvoice(id, tenantId, result.total, result.currency) .then((attempt) => { diff --git a/src/modules/payments/dto/index.ts b/src/modules/payments/dto/index.ts new file mode 100644 index 0000000..adce804 --- /dev/null +++ b/src/modules/payments/dto/index.ts @@ -0,0 +1,5 @@ +export { + PaymentAttemptResponseDto, + PaymentAttemptListResponseDto, +} from './payment-attempt-response.dto'; +export { WebhookEventResponseDto } from './webhook-event-response.dto'; diff --git a/src/modules/payments/dto/payment-attempt-response.dto.ts b/src/modules/payments/dto/payment-attempt-response.dto.ts new file mode 100644 index 0000000..e48e691 --- /dev/null +++ b/src/modules/payments/dto/payment-attempt-response.dto.ts @@ -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; + }; +} diff --git a/src/modules/payments/dto/webhook-event-response.dto.ts b/src/modules/payments/dto/webhook-event-response.dto.ts new file mode 100644 index 0000000..03e1106 --- /dev/null +++ b/src/modules/payments/dto/webhook-event-response.dto.ts @@ -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; +} diff --git a/src/modules/payments/handlers/payment-success.handler.ts b/src/modules/payments/handlers/payment-success.handler.ts index ca4d1f6..eb8501c 100644 --- a/src/modules/payments/handlers/payment-success.handler.ts +++ b/src/modules/payments/handlers/payment-success.handler.ts @@ -10,7 +10,7 @@ * Idempotent: if invoice is already PAID, skip silently. * * Transaction note: markPaid uses its own Prisma transaction internally. - * advancePeriod is a separate call — if it fails, the invoice is still + * advancePeriod is a separate call - if it fails, the invoice is still * PAID (correct state) and the billing cron catches un-advanced periods * on the next daily tick. This is an acceptable trade-off vs forcing * all operations into a single transaction (which would require @@ -89,7 +89,7 @@ export class PaymentSuccessHandler { ); // Advance subscription to next billing period - // Wrapped in try/catch — if this fails, invoice is still PAID (correct) + // Wrapped in try/catch - if this fails, invoice is still PAID (correct) // and the billing cron will catch the un-advanced period on the next tick. try { const subscription = await this.prisma.subscription.findUnique({ diff --git a/src/modules/payments/index.ts b/src/modules/payments/index.ts index bff89ae..1247338 100644 --- a/src/modules/payments/index.ts +++ b/src/modules/payments/index.ts @@ -9,3 +9,10 @@ export { } from './payment-provider.base'; export { FakePaymentAdapter } from './adapters/fake-payment.adapter'; export { StripePaymentAdapter } from './adapters/stripe-payment.adapter'; +export { PaymentIntentService } from './payment-intent.service'; +export { WebhookProcessingService } from './webhook-processing.service'; +export { + PaymentAttemptResponseDto, + PaymentAttemptListResponseDto, +} from './dto'; +export { WebhookEventResponseDto } from './dto';